diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..0ab358c --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,122 @@ +coverage: + status: + project: + default: + threshold: 2% + +comment: + layout: "header, diff, flags, components, files" + after_n_builds: 2 + +ignore: + - "*_easyjson.go" + - "**/*_easyjson.go" + - "*.pb.go" + - "**/*.pb.go" + +component_management: + individual_components: + - component_id: module_root + name: root + paths: + - "*.go" + - component_id: module_api + name: api + paths: + - api/** + - component_id: module_async + name: async + paths: + - async/** + - component_id: module_client + name: client + paths: + - client/** + - component_id: module_cmd_client + name: cmd/client + paths: + - cmd/client/** + - component_id: module_cmd_proxy + name: cmd/proxy + paths: + - cmd/proxy/** + - component_id: module_cmd_server + name: cmd/server + paths: + - cmd/server/** + - component_id: module_config + name: config + paths: + - config/** + - component_id: module_container + name: container + paths: + - container/** + - component_id: module_dns + name: dns + paths: + - dns/** + - component_id: module_etcd + name: etcd + paths: + - etcd/** + - component_id: module_geoip + name: geoip + paths: + - geoip/** + - component_id: module_grpc + name: grpc + paths: + - grpc/** + - component_id: module_internal + name: internal + paths: + - internal/** + - component_id: module_log + name: log + paths: + - log/** + - component_id: module_metrics + name: metrics + paths: + - metrics/** + - component_id: module_mock + name: mock + paths: + - mock/** + - component_id: module_nats + name: nats + paths: + - nats/** + - component_id: module_pool + name: pool + paths: + - pool/** + - component_id: module_proxy + name: proxy + paths: + - proxy/** + - component_id: module_security + name: security + paths: + - security/** + - component_id: module_server + name: server + paths: + - server/** + - component_id: module_session + name: session + paths: + - session/** + - component_id: module_sfu + name: sfu + paths: + - sfu/** + - component_id: module_talk + name: talk + paths: + - talk/** + - component_id: module_test + name: test + paths: + - test/** diff --git a/.github/workflows/check-continentmap.yml b/.github/workflows/check-continentmap.yml index eff3c10..3c26ed2 100644 --- a/.github/workflows/check-continentmap.yml +++ b/.github/workflows/check-continentmap.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check continentmap run: make check-continentmap diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 045b013..c65e540 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,15 +36,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/command-rebase.yml b/.github/workflows/command-rebase.yml index 7fd90cd..489574b 100644 --- a/.github/workflows/command-rebase.yml +++ b/.github/workflows/command-rebase.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Add reaction on start - uses: peter-evans/create-or-update-comment@v4 + uses: peter-evans/create-or-update-comment@v5 with: token: ${{ secrets.COMMAND_BOT_PAT }} repository: ${{ github.event.repository.full_name }} @@ -31,7 +31,7 @@ jobs: reaction-type: "+1" - name: Checkout the latest code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.COMMAND_BOT_PAT }} @@ -42,7 +42,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.COMMAND_BOT_PAT }} - name: Add reaction on failure - uses: peter-evans/create-or-update-comment@v4 + uses: peter-evans/create-or-update-comment@v5 if: failure() with: token: ${{ secrets.COMMAND_BOT_PAT }} diff --git a/.github/workflows/deploydocker.yml b/.github/workflows/deploydocker.yml index 2471061..411e59a 100644 --- a/.github/workflows/deploydocker.yml +++ b/.github/workflows/deploydocker.yml @@ -1,4 +1,4 @@ -name: Deploy to Docker Hub / GHCR +name: Deploy to Docker Hub / GHCR / quay.io on: pull_request: @@ -27,18 +27,19 @@ jobs: steps: - name: Check Out Repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Generate Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | strukturag/nextcloud-spreed-signaling ghcr.io/strukturag/nextcloud-spreed-signaling + quay.io/strukturag/nextcloud-spreed-signaling tags: | type=ref,event=branch type=semver,pattern={{version}} @@ -46,7 +47,7 @@ jobs: type=semver,pattern={{major}} type=sha,prefix= - name: Cache Docker layers - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} @@ -54,26 +55,34 @@ jobs: ${{ runner.os }}-buildx- - name: Login to Docker Hub if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Login to GHCR if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to quay.io + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + registry: quay.io + username: ${{ secrets.QUAY_IO_USERNAME }} + password: ${{ secrets.QUAY_IO_ACCESS_TOKEN }} + - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and push id: docker_build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./docker/server/Dockerfile @@ -92,18 +101,19 @@ jobs: steps: - name: Check Out Repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Generate Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | strukturag/nextcloud-spreed-signaling ghcr.io/strukturag/nextcloud-spreed-signaling + quay.io/strukturag/nextcloud-spreed-signaling labels: | org.opencontainers.image.title=nextcloud-spreed-signaling-proxy org.opencontainers.image.description=Signaling proxy for the standalone signaling server for Nextcloud Talk. @@ -116,7 +126,7 @@ jobs: type=semver,pattern={{major}} type=sha,prefix= - name: Cache Docker layers - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} @@ -124,26 +134,34 @@ jobs: ${{ runner.os }}-buildx- - name: Login to Docker Hub if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Login to GHCR if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to quay.io + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + registry: quay.io + username: ${{ secrets.QUAY_IO_USERNAME }} + password: ${{ secrets.QUAY_IO_ACCESS_TOKEN }} + - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and push id: docker_build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./docker/proxy/Dockerfile diff --git a/.github/workflows/docker-compose.yml b/.github/workflows/docker-compose.yml index 5f6ae64..3cc8019 100644 --- a/.github/workflows/docker-compose.yml +++ b/.github/workflows/docker-compose.yml @@ -22,26 +22,16 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Update docker-compose - run: | - curl -SL https://github.com/docker/compose/releases/download/v2.15.1/docker-compose-linux-x86_64 -o docker-compose - chmod a+x docker-compose + - uses: actions/checkout@v6 - name: Pull Docker images - run: ./docker-compose -f docker/docker-compose.yml pull + run: docker compose -f docker/docker-compose.yml pull build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Update docker-compose - run: | - curl -SL https://github.com/docker/compose/releases/download/v2.15.1/docker-compose-linux-x86_64 -o docker-compose - chmod a+x docker-compose + - uses: actions/checkout@v6 - name: Build Docker images - run: ./docker-compose -f docker/docker-compose.yml build + run: docker compose -f docker/docker-compose.yml build diff --git a/.github/workflows/docker-janus.yml b/.github/workflows/docker-janus.yml index d96888e..3869d51 100644 --- a/.github/workflows/docker-janus.yml +++ b/.github/workflows/docker-janus.yml @@ -23,13 +23,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: docker/janus load: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9b75474..2edce0a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -33,16 +33,16 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: docker/server/Dockerfile @@ -52,16 +52,16 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: docker/proxy/Dockerfile diff --git a/.github/workflows/generated.yml b/.github/workflows/generated.yml index 8a19e1b..b649a23 100644 --- a/.github/workflows/generated.yml +++ b/.github/workflows/generated.yml @@ -5,20 +5,24 @@ on: branches: [ master ] paths: - '.github/workflows/generated.yml' - - 'api*.go' - - '*_easyjson.go' - - '*.pb.go' - - '*.proto' + - '**/api*.go' + - '**/*_easyjson.go' + - '**/*.pb.go' + - '**/*.proto' - 'go.*' + - 'api/signaling.go' + - 'talk/ocs.go' pull_request: branches: [ master ] paths: - '.github/workflows/generated.yml' - - 'api*.go' - - '*_easyjson.go' - - '*.pb.go' - - '*.proto' + - '**/api*.go' + - '**/*_easyjson.go' + - '**/*.pb.go' + - '**/*.proto' - 'go.*' + - 'api/signaling.go' + - 'talk/ocs.go' env: CODE_GENERATOR_NAME: struktur AG service user @@ -53,12 +57,12 @@ jobs: contents: write continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: token: ${{ secrets.CODE_GENERATOR_PAT }} ref: ${{ github.event.pull_request.head.ref }} - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: "stable" @@ -78,16 +82,15 @@ jobs: if [ "$CHECKOUT_SHA" != "${{github.event.pull_request.head.sha}}" ]; then echo "More changes since this commit ${{github.event.pull_request.head.sha}}, skipping" else - git add *_easyjson.go *.pb.go + git add --all CHANGES=$(git status --porcelain) if [ -z "$CHANGES" ]; then echo "No files have changed, no need to commit / push." else go mod tidy - git add go.* git config user.name "$CODE_GENERATOR_NAME" git config user.email "$CODE_GENERATOR_EMAIL" - git commit --author="$(git log -n 1 --pretty=format:%an) <$(git log -n 1 --pretty=format:%ae)>" -m "Update generated files from ${{github.event.pull_request.head.sha}}" + git commit --all --author="$(git log -n 1 --pretty=format:%an) <$(git log -n 1 --pretty=format:%ae)>" -m "Update generated files from ${{github.event.pull_request.head.sha}}" git push fi fi @@ -97,8 +100,8 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: "stable" @@ -113,5 +116,5 @@ jobs: - name: Check generated files run: | - git add *.go - git diff --cached --exit-code *.go + git add --all + git diff --cached --exit-code diff --git a/.github/workflows/govuln.yml b/.github/workflows/govuln.yml index a50bc62..e0047fa 100644 --- a/.github/workflows/govuln.yml +++ b/.github/workflows/govuln.yml @@ -24,13 +24,14 @@ jobs: strategy: matrix: go-version: - - "1.22" - - "1.23" + - "1.25" + - "1.26" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} + check-latest: true - run: date diff --git a/.github/workflows/licensecheck.yml b/.github/workflows/licensecheck.yml index eec2df3..55f52a3 100644 --- a/.github/workflows/licensecheck.yml +++ b/.github/workflows/licensecheck.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install licensecheck run: | @@ -33,7 +33,7 @@ jobs: run: | { echo 'CHECK_RESULT<> "$GITHUB_ENV" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1856787..1e69405 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,6 +8,7 @@ on: - '.golangci.yml' - '**.go' - 'go.*' + - 'Makefile' pull_request: branches: [ master ] paths: @@ -15,6 +16,7 @@ on: - '.golangci.yml' - '**.go' - 'go.*' + - 'Makefile' permissions: contents: read @@ -25,31 +27,60 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: - go-version: "1.22" + go-version: "1.25" - name: lint - uses: golangci/golangci-lint-action@v6.2.0 + uses: golangci/golangci-lint-action@v9.2.0 with: version: latest args: --timeout=2m0s skip-cache: true + modernize: + name: modernize + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + + - name: moderize + run: | + go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest -any=false -reflecttypefor=false -test ./... + + checklocks: + name: checklocks + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + check-latest: true + + - name: checklocks + run: | + make checklocks + dependencies: name: dependencies runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: "stable" - name: Check minimum supported version of Go run: | - go mod tidy -go=1.22.0 -compat=1.22.0 + go mod tidy -go=1.25.0 -compat=1.25.0 - name: Check go.mod / go.sum run: | diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 7cadc02..31954f7 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -20,7 +20,7 @@ jobs: name: shellcheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: shellcheck run: | diff --git a/.github/workflows/tarball.yml b/.github/workflows/tarball.yml index 747254a..5a19f10 100644 --- a/.github/workflows/tarball.yml +++ b/.github/workflows/tarball.yml @@ -24,12 +24,12 @@ jobs: strategy: matrix: go-version: - - "1.22" - - "1.23" + - "1.25" + - "1.26" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} @@ -39,26 +39,71 @@ jobs: make tarball - name: Upload tarball - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: tarball-${{ matrix.go-version }} path: nextcloud-spreed-signaling*.tar.gz + build: + strategy: + matrix: + go-version: + - "1.25" + - "1.26" + runs-on: ubuntu-latest + needs: [create] + steps: + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + + - name: Download tarball + uses: actions/download-artifact@v8 + with: + name: tarball-${{ matrix.go-version }} + + - name: Extract tarball + run: | + mkdir -p tmp + tar xvf nextcloud-spreed-signaling*.tar.gz --strip-components=1 -C tmp + [ -d "tmp/vendor" ] || exit 1 + [ -f "tmp/version.txt" ] || exit 1 + + - name: Build + run: | + echo "Building with $(nproc) threads" + make -C tmp build client -j$(nproc) + UNKNOWN=$(./tmp/bin/signaling -version | grep unknown || true) + if [ -n "$UNKNOWN" ]; then \ + echo "Found unknown version: $UNKNOWN"; \ + exit 1; \ + fi + UNKNOWN=$(./tmp/bin/proxy -version | grep unknown || true) + if [ -n "$UNKNOWN" ]; then \ + echo "Found unknown version: $UNKNOWN"; \ + exit 1; \ + fi + UNKNOWN=$(./tmp/bin/client -version | grep unknown || true) + if [ -n "$UNKNOWN" ]; then \ + echo "Found unknown version: $UNKNOWN"; \ + exit 1; \ + fi + test: strategy: matrix: go-version: - - "1.22" - - "1.23" + - "1.25" + - "1.26" runs-on: ubuntu-latest needs: [create] steps: - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - name: Download tarball - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: tarball-${{ matrix.go-version }} @@ -68,11 +113,6 @@ jobs: tar xvf nextcloud-spreed-signaling*.tar.gz --strip-components=1 -C tmp [ -d "tmp/vendor" ] || exit 1 - - name: Build - run: | - echo "Building with $(nproc) threads" - make -C tmp build -j$(nproc) - - name: Run tests env: USE_DB_IP_GEOIP_DATABASE: "1" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c74638..2dff4ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,7 @@ on: branches: [ master ] paths: - '.github/workflows/test.yml' + - '.codecov.yml' - '**.go' - 'go.*' - 'Makefile' @@ -12,6 +13,7 @@ on: branches: [ master ] paths: - '.github/workflows/test.yml' + - '.codecov.yml' - '**.go' - 'go.*' - 'Makefile' @@ -20,19 +22,16 @@ permissions: contents: read jobs: - go: - env: - MAXMIND_GEOLITE2_LICENSE: ${{ secrets.MAXMIND_GEOLITE2_LICENSE }} - USE_DB_IP_GEOIP_DATABASE: "1" + build: strategy: matrix: go-version: - - "1.22" - - "1.23" + - "1.25" + - "1.26" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} @@ -43,38 +42,69 @@ jobs: make proxy -j$(nproc) make server -j$(nproc) + go: + env: + MAXMIND_GEOLITE2_LICENSE: ${{ secrets.MAXMIND_GEOLITE2_LICENSE }} + USE_DB_IP_GEOIP_DATABASE: "1" + strategy: + matrix: + go-version: + - "1.25" + - "1.26" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + - name: Run tests run: | make test TIMEOUT=120s + benchmark: + env: + MAXMIND_GEOLITE2_LICENSE: ${{ secrets.MAXMIND_GEOLITE2_LICENSE }} + USE_DB_IP_GEOIP_DATABASE: "1" + strategy: + matrix: + go-version: + - "1.25" + - "1.26" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + + - name: Run benchmarks + run: | + make benchmark + + coverage: + env: + MAXMIND_GEOLITE2_LICENSE: ${{ secrets.MAXMIND_GEOLITE2_LICENSE }} + USE_DB_IP_GEOIP_DATABASE: "1" + strategy: + matrix: + go-version: + - "1.25" + - "1.26" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + - name: Generate coverage report run: | make cover TIMEOUT=120s - echo "GOROOT=$(go env GOROOT)" >> $GITHUB_ENV - - name: Convert coverage to lcov - uses: jandelgado/gcov2lcov-action@v1.1.1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 with: - infile: cover.out - outfile: cover.lcov - - - name: Coveralls Parallel - uses: coverallsapp/github-action@v2.3.4 - env: - COVERALLS_FLAG_NAME: run-${{ matrix.go-version }} - with: - path-to-lcov: cover.lcov - github-token: ${{ secrets.github_token }} - parallel: true - - finish: - permissions: - contents: none - needs: go - runs-on: ubuntu-latest - steps: - - name: Coveralls Finished - uses: coverallsapp/github-action@v2.3.4 - with: - github-token: ${{ secrets.github_token }} - parallel-finished: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ./cover.out + flags: go-${{ matrix.go-version }} diff --git a/.gitignore b/.gitignore index 680cc06..3ba4e09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin/ +tmp/ vendor/ *.pem diff --git a/.golangci.yml b/.golangci.yml index c62551d..c7c039b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,34 +1,80 @@ +version: "2" linters: enable: - - gofmt + - errchkjson + - exptostd + - gocritic + - misspell + - modernize + - paralleltest + - perfsprint - revive - -linters-settings: - revive: - ignoreGeneratedHeader: true - severity: warning - rules: - - name: blank-imports - - name: context-as-argument - - name: context-keys-type - - name: dot-imports - - name: error-return - #- name: error-strings - - name: error-naming - - name: exported - - name: if-return - - name: increment-decrement - #- name: var-naming - - name: var-declaration - - name: package-comments - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - #- name: indent-error-flow - - name: errorf - - name: empty-block - - name: superfluous-else - #- name: unused-parameter - - name: unreachable-code - - name: redefines-builtin-id + - testifylint + settings: + errchkjson: + check-error-free-encoding: true + report-no-exported: true + gocritic: + disabled-checks: + - singleCaseSwitch + settings: + ifElseChain: + # Min number of if-else blocks that makes the warning trigger. + # Default: 2 + minThreshold: 3 + govet: + enable: + - nilness + disable: + - stdversion + revive: + severity: warning + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + #- name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + #- name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + #- name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + #- name: unused-parameter + - name: unreachable-code + - name: use-any + - name: redefines-builtin-id + testifylint: + disable: + - require-error + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f67415..0a48ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,565 @@ All notable changes to this project will be documented in this file. +## 2.1.1 - 2026-03-12 + +### Changed +- Drop support for Golang 1.24 + [#1197](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1197) +- Simplify error type checks. + [#1190](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1190) +- readme: Add example websocket urls for Janus events. + [#1191](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1191) +- CI: Test with Golang 1.26 + [#1196](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1196) +- CI: Use "docker compose" instead of downloading docker-compose binary. + [#1203](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1203) +- docker: pin spreedbackend uid and add user group + [#1202](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1202) +- Bump module version to "v2" so versions 2.x can be imported by others. + [#1211](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1211) +- Update generated files for v2 module. + [#1218](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1218) +- Simplify async notifier code + [#1220](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1220) + +### Fixed +- Update "go.opentelemetry.io/otel/sdk" to fix "GO-2026-4394". + [#1207](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1207) +- Don't limit size of received Janus events. + [#1208](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1208) + +### Dependencies +- Bump google.golang.org/grpc/cmd/protoc-gen-go-grpc from 1.6.0 to 1.6.1 + [#1189](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1189) +- Bump markdown from 3.10.1 to 3.10.2 in /docs + [#1192](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1192) +- Bump github.com/pion/dtls/v3 from 3.0.10 to 3.1.0 + [#1193](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1193) +- Bump golang from 1.25-alpine to 1.26-alpine in /docker/proxy + [#1194](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1194) +- Bump golang from 1.25-alpine to 1.26-alpine in /docker/server + [#1195](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1195) +- Bump google.golang.org/grpc from 1.78.0 to 1.79.1 + [#1201](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1201) +- Bump github.com/pion/dtls/v3 from 3.1.0 to 3.1.1 + [#1199](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1199) +- Bump the etcd group with 4 updates + [#1200](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1200) +- Bump github.com/pion/sdp/v3 from 3.0.17 to 3.0.18 + [#1204](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1204) +- Bump github.com/pion/ice/v4 from 4.2.0 to 4.2.1 + [#1205](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1205) +- Bump github.com/nats-io/nats.go from 1.48.0 to 1.49.0 + [#1206](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1206) +- Bump the artifacts group with 2 updates + [#1209](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1209) +- Bump module version to "v2" so versions 2.x can be imported by others. + [#1211](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1211) +- Bump docker/login-action from 3 to 4 + [#1212](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1212) +- Bump docker/setup-qemu-action from 3 to 4 + [#1213](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1213) +- Bump docker/metadata-action from 5 to 6 + [#1214](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1214) +- Bump docker/setup-buildx-action from 3 to 4 + [#1215](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1215) +- Bump docker/build-push-action from 6 to 7 + [#1217](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1217) +- Bump google.golang.org/grpc from 1.79.1 to 1.79.2 + [#1216](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1216) +- Bump github.com/nats-io/nats-server/v2 from 2.12.4 to 2.12.5 + [#1219](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1219) + + +## 2.1.0 - 2026-02-03 + +### Added +- Introduce "internal" package + [#1082](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1082) +- Add etcd TLS tests. + [#1084](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1084) +- Add missing stats registration. + [#1086](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1086) +- Add commands to the readme on how to build Docker images locally. + [#1088](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1088) +- Use gvisor checklocks for static lock analysis. + [#1078](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1078) +- Support relaying of chat messages. + [#868](https://github.com/strukturag/nextcloud-spreed-signaling/pull/868) +- Return bandwidth information in room responses. + [#1099](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1099) +- Expose real bandwidth usage through metrics. + [#1102](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1102) +- Add type to store bandwidths. + [#1108](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1108) +- Add more WebRTC-related metrics + [#1109](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1109) +- Add metrics about client bytes/messages sent/received. + [#1134](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1134) +- Introduce transient session data. + [#1120](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1120) +- Include "version.txt" in tarball. + [#1142](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1142) +- CI: Also upload images to quay.io/strukturag/nextcloud-spreed-signaling + [#1159](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1159) +- Add more metrics about sessions in calls. + [#1183](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1183) + +### Changed +- dockerfile: create system user instead of normal user, avoid home directory + [#1058](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1058) +- Use "testing/synctest" to simplify timing-dependent tests. + [#1067](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1067) +- Add dedicated types for different session ids. + [#1066](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1066) +- Move "StringMap" class to api module. + [#1077](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1077) +- CI: Disable "stdversion" check of govet. + [#1079](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1079) +- CI: Use codecov components. + [#1080](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1080) +- Add interface for method "GetInCall". + [#1083](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1083) +- Make LruCache typed through generics. + [#1085](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1085) +- Protect access to the debug pprof handlers. + [#1094](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1094) +- Don't use environment to keep per-test properties. + [#1106](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1106) +- Add formatting to bandwidth values. + [#1114](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1114) +- Don't format zero bandwidth as "unlimited". + [#1115](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1115) +- CI: Split test jobs to speed up total actions time. + [#1118](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1118) +- Stop using global logger + [#1117](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1117) +- CI: Split tarball jobs to speed up total actions time. + [#1129](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1129) +- Update client code + [#1130](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1130) +- Don't use fmt.Sprintf where not necessary. + [#1131](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1131) +- Use test-related logger for embedded etcd. + [#1132](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1132) +- No need to use list of pointers, use objects directly. + [#1133](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1133) +- Generate shorter session ids. + [#1140](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1140) +- client: Include version, optimize JSON processing. + [#1143](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1143) +- Enable more linters + [#1145](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1145) +- Parallelize more tests. + [#1149](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1149) +- Move logging code to separate package. + [#1150](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1150) +- Close subscriber synchronously on errors. + [#1152](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1152) +- CI: Run "modernize" with Go 1.25 + [#1160](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1160) +- CI: Always use latest patch release for govuln checks. + [#1161](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1161) +- Process all NATS messages for same target from single goroutine. + [#1165](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1165) +- CI: Process files in all folders with licensecheck. + [#1169](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1169) +- CI: Run checklocks with Go 1.25 + [#1170](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1170) +- Refactor code into packages + [#1151](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1151) +- Remove unused testing code. + [#1174](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1174) +- checklocks: Remove ignore since generics are supported now. + [#1184](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1184) +- Support receiving and forwarding multiple chat messages from Talk. + [#1185](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1185) +- Move tests closer to code being checked + [#1186](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1186) + +### Fixed +- A proxy connection is only connected after a hello has been processed. + [#1071](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1071) +- Fix URL to send federated ping requests. + [#1081](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1081) +- Reconnect proxy connection even if shutdown was scheduled before. + [#1100](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1100) +- Federation cleanup fixes. + [#1105](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1105) +- Also rewrite token in comment for federated chat relay. + [#1112](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1112) +- Fix transient data for clustered setups. + [#1121](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1121) +- Fix initial transient data in clustered setups + [#1127](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1127) +- fix(docs): already_joined error response + [#1126](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1126) +- Fix storing initial data when clustered. + [#1128](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1128) +- Fix flaky tests that fail under load. + [#1153](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1153) + +### Dependencies +- Bump google.golang.org/grpc from 1.74.2 to 1.75.0 + [#1054](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1054) +- Bump github.com/nats-io/nats.go from 1.44.0 to 1.45.0 + [#1057](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1057) +- Bump google.golang.org/protobuf from 1.36.7 to 1.36.8 + [#1056](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1056) +- Bump github.com/stretchr/testify from 1.10.0 to 1.11.0 + [#1059](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1059) +- Bump github.com/stretchr/testify from 1.11.0 to 1.11.1 + [#1060](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1060) +- Bump markdown from 3.8.2 to 3.9 in /docs + [#1065](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1065) +- Bump github.com/pion/sdp/v3 from 3.0.15 to 3.0.16 + [#1061](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1061) +- Bump github.com/prometheus/client_golang from 1.23.0 to 1.23.2 + [#1064](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1064) +- Bump actions/setup-go from 5 to 6 + [#1062](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1062) +- Bump google.golang.org/grpc from 1.75.0 to 1.75.1 + [#1070](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1070) +- Bump google.golang.org/protobuf from 1.36.8 to 1.36.9 + [#1069](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1069) +- Bump github.com/nats-io/nats-server/v2 from 2.11.8 to 2.11.9 + [#1068](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1068) +- Bump github.com/mailru/easyjson from 0.9.0 to 0.9.1 + [#1072](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1072) +- Bump the etcd group with 4 updates + [#1073](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1073) +- Bump github.com/nats-io/nats.go from 1.45.0 to 1.46.0 + [#1076](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1076) +- Bump github.com/nats-io/nats-server/v2 from 2.11.9 to 2.12.0 + [#1075](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1075) +- Bump github.com/nats-io/nats.go from 1.46.0 to 1.46.1 + [#1087](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1087) +- Bump google.golang.org/grpc from 1.75.1 to 1.76.0 + [#1092](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1092) +- Bump github/codeql-action from 3 to 4 + [#1093](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1093) +- Bump peter-evans/create-or-update-comment from 4 to 5 + [#1090](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1090) +- Bump google.golang.org/protobuf from 1.36.9 to 1.36.10 + [#1089](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1089) +- Bump github.com/nats-io/nats.go from 1.46.1 to 1.47.0 + [#1096](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1096) +- Bump github.com/nats-io/nats-server/v2 from 2.12.0 to 2.12.1 + [#1095](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1095) +- Bump the artifacts group with 2 updates + [#1101](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1101) +- Bump markdown from 3.9 to 3.10 in /docs + [#1104](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1104) +- Bump golangci/golangci-lint-action from 8.0.0 to 9.0.0 + [#1110](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1110) +- Bump the etcd group with 4 updates + [#1111](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1111) +- Bump github.com/nats-io/nats-server/v2 from 2.12.1 to 2.12.2 + [#1113](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1113) +- Bump google.golang.org/grpc from 1.76.0 to 1.77.0 + [#1116](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1116) +- Bump golang.org/x/crypto from 0.43.0 to 0.45.0 + [#1119](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1119) +- Bump go.uber.org/zap from 1.27.0 to 1.27.1 + [#1123](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1123) +- Bump actions/checkout from 5 to 6 + [#1124](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1124) +- Bump golangci/golangci-lint-action from 9.0.0 to 9.1.0 + [#1125](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1125) +- Bump github.com/pion/ice/v4 from 4.0.10 to 4.0.11 + [#1135](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1135) +- Bump google.golang.org/grpc/cmd/protoc-gen-go-grpc from 1.5.1 to 1.6.0 + [#1136](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1136) +- Bump github.com/pion/ice/v4 from 4.0.11 to 4.0.12 + [#1138](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1138) +- Bump golangci/golangci-lint-action from 9.1.0 to 9.2.0 + [#1144](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1144) +- Bump github.com/pion/ice/v4 from 4.0.12 to 4.0.13 + [#1146](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1146) +- Bump actions/cache from 4 to 5 + [#1157](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1157) +- Bump google.golang.org/protobuf from 1.36.10 to 1.36.11 + [#1156](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1156) +- Bump github.com/pion/ice/v4 from 4.0.13 to 4.1.0 + [#1155](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1155) +- Bump the artifacts group with 2 updates + [#1154](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1154) +- Bump github.com/nats-io/nats.go from 1.47.0 to 1.48.0 + [#1163](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1163) +- Bump the etcd group with 4 updates + [#1162](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1162) +- Bump github.com/nats-io/nats-server/v2 from 2.12.2 to 2.12.3 + [#1164](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1164) +- Bump github.com/pion/sdp/v3 from 3.0.16 to 3.0.17 + [#1166](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1166) +- Bump google.golang.org/grpc from 1.77.0 to 1.78.0 + [#1167](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1167) +- Bump github.com/pion/ice/v4 from 4.1.0 to 4.2.0 + [#1171](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1171) +- Bump sphinx-rtd-theme from 3.0.2 to 3.1.0 in /docs + [#1172](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1172) +- Bump sphinx from 8.2.3 to 9.1.0 in /docs + [#1168](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1168) +- Bump markdown from 3.10 to 3.10.1 in /docs + [#1177](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1177) +- Bump github.com/nats-io/nats-server/v2 from 2.12.3 to 2.12.4 + [#1179](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1179) +- Bump github.com/golang-jwt/jwt/v5 from 5.3.0 to 5.3.1 + [#1182](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1182) + + +## 2.0.4 - 2025-08-18 + +### Added +- Comment / document possible error responses. + [#1004](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1004) +- Support multiple sessions for dialout. + [#1005](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1005) +- Support filtering candidates received by clients. + [#1000](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1000) +- Describe how to pass caller information for outgoing calls. + [#1019](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1019) +- Support multiple urls per backend + [#770](https://github.com/strukturag/nextcloud-spreed-signaling/pull/770) +- Return connection / publisher tokens for remote publishers. + [#1025](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1025) + +### Changed +- Drop support for Go 1.23 + [#1049](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1049) +- Only forward actor id / -type in "addsession" request if both are given. + [#1009](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1009) +- Use backend id in backend client stats to match other stats. + [#1020](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1020) +- Remove debug output. + [#1022](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1022) +- Only forward actor details in leave virtual sessions request if both are given. + [#1026](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1026) +- Delete (unused) proxy publisher/subscriber created after local timeout. + [#1032](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1032) +- modernize: Replace "interface{}" with "any". + [#1033](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1033) +- Add type for string maps. + [#1034](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1034) +- CI: Migrate to codecov. + [#1037](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1037) + [#1038](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1038) +- Use testify assertions to check expected fields / values internally. + [#1035](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1035) +- CI: Test with Golang 1.25 + [#1048](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1048) +- Modernize Go code and check from CI. + [#1050](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1050) +- Test "HasAnyPermission" method. + [#1051](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1051) +- Use standard library where possible. + [#1052](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1052) + +### Fixed +- Fix deadlock when setting transient data while removing listener. + [#992](https://github.com/strukturag/nextcloud-spreed-signaling/pull/992) +- Fixes for file watcher special cases + [#1017](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1017) +- Fix updating metric "signaling_mcu_subscribers" in various error cases. + [#1027](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1027) + +### Dependencies +- Bump google.golang.org/grpc from 1.72.0 to 1.72.1 + [#989](https://github.com/strukturag/nextcloud-spreed-signaling/pull/989) +- Bump github.com/pion/sdp/v3 from 3.0.11 to 3.0.12 + [#991](https://github.com/strukturag/nextcloud-spreed-signaling/pull/991) +- Bump the etcd group with 4 updates + [#990](https://github.com/strukturag/nextcloud-spreed-signaling/pull/990) +- Bump github.com/nats-io/nats-server/v2 from 2.11.3 to 2.11.4 + [#993](https://github.com/strukturag/nextcloud-spreed-signaling/pull/993) +- Bump github.com/pion/sdp/v3 from 3.0.12 to 3.0.13 + [#994](https://github.com/strukturag/nextcloud-spreed-signaling/pull/994) +- Bump google.golang.org/grpc from 1.72.1 to 1.72.2 + [#995](https://github.com/strukturag/nextcloud-spreed-signaling/pull/995) +- Bump github.com/nats-io/nats.go from 1.42.0 to 1.43.0 + [#999](https://github.com/strukturag/nextcloud-spreed-signaling/pull/999) +- Bump google.golang.org/grpc from 1.72.2 to 1.73.0 + [#1001](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1001) +- Bump the etcd group with 4 updates + [#1002](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1002) +- Bump markdown from 3.8 to 3.8.2 in /docs + [#1008](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1008) +- Bump github.com/pion/sdp/v3 from 3.0.13 to 3.0.14 + [#1006](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1006) +- Bump github.com/nats-io/nats-server/v2 from 2.11.4 to 2.11.5 + [#1010](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1010) +- Bump github.com/nats-io/nats-server/v2 from 2.11.5 to 2.11.6 + [#1011](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1011) +- Bump the etcd group with 4 updates + [#1015](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1015) +- Bump github.com/golang-jwt/jwt/v5 from 5.2.2 to 5.2.3 + [#1016](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1016) +- Bump google.golang.org/grpc from 1.73.0 to 1.74.0 + [#1018](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1018) +- Bump github.com/pion/sdp/v3 from 3.0.14 to 3.0.15 + [#1021](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1021) +- Bump the etcd group with 4 updates + [#1023](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1023) +- Bump google.golang.org/grpc from 1.74.0 to 1.74.2 + [#1024](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1024) +- Bump the etcd group with 4 updates + [#1028](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1028) +- Bump github.com/nats-io/nats.go from 1.43.0 to 1.44.0 + [#1031](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1031) +- Bump github.com/golang-jwt/jwt/v5 from 5.2.3 to 5.3.0 + [#1036](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1036) +- Bump github.com/prometheus/client_golang from 1.22.0 to 1.23.0 + [#1039](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1039) +- Bump github.com/nats-io/nats-server/v2 from 2.11.6 to 2.11.7 + [#1040](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1040) +- Bump google.golang.org/protobuf from 1.36.6 to 1.36.7 + [#1043](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1043) +- Bump actions/checkout from 4 to 5 + [#1044](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1044) +- Bump actions/download-artifact from 4 to 5 in the artifacts group + [#1042](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1042) +- Bump golang from 1.24-alpine to 1.25-alpine in /docker/server + [#1047](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1047) +- Bump golang from 1.24-alpine to 1.25-alpine in /docker/proxy + [#1046](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1046) +- Bump github.com/nats-io/nats-server/v2 from 2.11.7 to 2.11.8 + [#1053](https://github.com/strukturag/nextcloud-spreed-signaling/pull/1053) + + +## 2.0.3 - 2025-05-07 + +### Added +- Allow using environment variables for sessions and clients secrets + [#910](https://github.com/strukturag/nextcloud-spreed-signaling/pull/910) +- Allow using environment variables for backend secrets + [#912](https://github.com/strukturag/nextcloud-spreed-signaling/pull/912) +- Add serverinfo API + [#937](https://github.com/strukturag/nextcloud-spreed-signaling/pull/937) +- Add metrics for backend client requests. + [#973](https://github.com/strukturag/nextcloud-spreed-signaling/pull/973) + +### Changed +- Drop support for Go 1.22 + [#969](https://github.com/strukturag/nextcloud-spreed-signaling/pull/969) +- Do not log nats url credentials + [#911](https://github.com/strukturag/nextcloud-spreed-signaling/pull/911) +- Migrate cache-control parsing to https://github.com/pquerna/cachecontrol + [#916](https://github.com/strukturag/nextcloud-spreed-signaling/pull/916) +- CI: Test with Golang 1.24 + [#922](https://github.com/strukturag/nextcloud-spreed-signaling/pull/922) +- Add "/usr/lib64" to systemd ExecPath + [#963](https://github.com/strukturag/nextcloud-spreed-signaling/pull/963) +- Improve memory allocations + [#870](https://github.com/strukturag/nextcloud-spreed-signaling/pull/870) +- Speedup tests + [#972](https://github.com/strukturag/nextcloud-spreed-signaling/pull/972) +- docker: Make more settings configurable + [#980](https://github.com/strukturag/nextcloud-spreed-signaling/pull/980) +- Add jitter to reconnect intervals. + [#988](https://github.com/strukturag/nextcloud-spreed-signaling/pull/988) + +### Fixed +- nats: Reconnect client indefinitely. + [#935](https://github.com/strukturag/nextcloud-spreed-signaling/pull/935) +- Explicitly set TMPDIR to ensure that it is an executable path + [#956](https://github.com/strukturag/nextcloud-spreed-signaling/pull/956) +- Close subscribers on errors during initial connection. + [#959](https://github.com/strukturag/nextcloud-spreed-signaling/pull/959) +- Fix formatting of errors in "assert.Fail" calls. + [#970](https://github.com/strukturag/nextcloud-spreed-signaling/pull/970) +- Fix race condition in flaky certificate/CA reload tests. + [#971](https://github.com/strukturag/nextcloud-spreed-signaling/pull/971) +- Fix flaky test "Test_GrpcClients_DnsDiscovery". + [#976](https://github.com/strukturag/nextcloud-spreed-signaling/pull/976) +- Fix subscribers not closed when publisher is closed in Janus 1.x + [#986](https://github.com/strukturag/nextcloud-spreed-signaling/pull/986) +- Close subscriber if remote publisher was closed. + [#987](https://github.com/strukturag/nextcloud-spreed-signaling/pull/987) + +### Dependencies +- Bump google.golang.org/grpc from 1.69.4 to 1.70.0 + [#904](https://github.com/strukturag/nextcloud-spreed-signaling/pull/904) +- Bump the etcd group with 4 updates + [#907](https://github.com/strukturag/nextcloud-spreed-signaling/pull/907) +- Bump coverallsapp/github-action from 2.3.4 to 2.3.6 + [#909](https://github.com/strukturag/nextcloud-spreed-signaling/pull/909) +- Bump google.golang.org/protobuf from 1.36.3 to 1.36.4 + [#908](https://github.com/strukturag/nextcloud-spreed-signaling/pull/908) +- Bump github.com/nats-io/nats-server/v2 from 2.10.24 to 2.10.25 + [#903](https://github.com/strukturag/nextcloud-spreed-signaling/pull/903) +- Bump golangci/golangci-lint-action from 6.2.0 to 6.3.0 + [#913](https://github.com/strukturag/nextcloud-spreed-signaling/pull/913) +- Bump github.com/nats-io/nats.go from 1.38.0 to 1.39.0 + [#915](https://github.com/strukturag/nextcloud-spreed-signaling/pull/915) +- Bump golangci/golangci-lint-action from 6.3.0 to 6.3.2 + [#918](https://github.com/strukturag/nextcloud-spreed-signaling/pull/918) +- Bump google.golang.org/protobuf from 1.36.4 to 1.36.5 + [#917](https://github.com/strukturag/nextcloud-spreed-signaling/pull/917) +- build(deps): bump golang from 1.23-alpine to 1.24-alpine in /docker/proxy + [#921](https://github.com/strukturag/nextcloud-spreed-signaling/pull/921) +- build(deps): bump golang from 1.23-alpine to 1.24-alpine in /docker/server + [#919](https://github.com/strukturag/nextcloud-spreed-signaling/pull/919) +- build(deps): bump sphinx from 8.1.3 to 8.2.0 in /docs + [#928](https://github.com/strukturag/nextcloud-spreed-signaling/pull/928) +- build(deps): bump github.com/prometheus/client_golang from 1.20.5 to 1.21.0 + [#927](https://github.com/strukturag/nextcloud-spreed-signaling/pull/927) +- build(deps): bump sphinx from 8.2.0 to 8.2.1 in /docs + [#929](https://github.com/strukturag/nextcloud-spreed-signaling/pull/929) +- build(deps): bump github.com/nats-io/nats.go from 1.39.0 to 1.39.1 + [#926](https://github.com/strukturag/nextcloud-spreed-signaling/pull/926) +- build(deps): bump sphinx from 8.2.1 to 8.2.3 in /docs + [#932](https://github.com/strukturag/nextcloud-spreed-signaling/pull/932) +- build(deps): bump google.golang.org/grpc from 1.70.0 to 1.71.0 + [#933](https://github.com/strukturag/nextcloud-spreed-signaling/pull/933) +- build(deps): bump golangci/golangci-lint-action from 6.3.3 to 6.5.0 + [#925](https://github.com/strukturag/nextcloud-spreed-signaling/pull/925) +- build(deps): bump jinja2 from 3.1.5 to 3.1.6 in /docs + [#938](https://github.com/strukturag/nextcloud-spreed-signaling/pull/938) +- build(deps): bump github.com/pion/sdp/v3 from 3.0.10 to 3.0.11 + [#939](https://github.com/strukturag/nextcloud-spreed-signaling/pull/939) +- build(deps): bump golangci/golangci-lint-action from 6.5.0 to 6.5.1 + [#940](https://github.com/strukturag/nextcloud-spreed-signaling/pull/940) +- build(deps): bump golangci/golangci-lint-action from 6.5.1 to 6.5.2 + [#946](https://github.com/strukturag/nextcloud-spreed-signaling/pull/946) +- build(deps): bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 + [#948](https://github.com/strukturag/nextcloud-spreed-signaling/pull/948) +- build(deps): bump github.com/golang-jwt/jwt/v4 from 4.5.1 to 4.5.2 + [#949](https://github.com/strukturag/nextcloud-spreed-signaling/pull/949) +- build(deps): bump golangci/golangci-lint-action from 6.5.2 to 7.0.0 + [#951](https://github.com/strukturag/nextcloud-spreed-signaling/pull/951) +- build(deps): bump google.golang.org/protobuf from 1.36.5 to 1.36.6 + [#952](https://github.com/strukturag/nextcloud-spreed-signaling/pull/952) +- Bump markdown from 3.7 to 3.8 in /docs + [#966](https://github.com/strukturag/nextcloud-spreed-signaling/pull/966) +- Bump golang.org/x/crypto from 0.32.0 to 0.35.0 + [#967](https://github.com/strukturag/nextcloud-spreed-signaling/pull/967) +- Bump github.com/nats-io/nats.go from 1.39.1 to 1.41.1 + [#964](https://github.com/strukturag/nextcloud-spreed-signaling/pull/964) +- Bump google.golang.org/grpc from 1.71.0 to 1.71.1 + [#957](https://github.com/strukturag/nextcloud-spreed-signaling/pull/957) +- build(deps): bump golang.org/x/net from 0.34.0 to 0.36.0 + [#941](https://github.com/strukturag/nextcloud-spreed-signaling/pull/941) +- build(deps): bump the etcd group with 4 updates + [#936](https://github.com/strukturag/nextcloud-spreed-signaling/pull/936) +- Bump github.com/nats-io/nats-server/v2 from 2.10.25 to 2.11.1 + [#962](https://github.com/strukturag/nextcloud-spreed-signaling/pull/962) +- Bump github.com/prometheus/client_golang from 1.21.1 to 1.22.0 + [#974](https://github.com/strukturag/nextcloud-spreed-signaling/pull/974) +- Bump github.com/fsnotify/fsnotify from 1.8.0 to 1.9.0 + [#975](https://github.com/strukturag/nextcloud-spreed-signaling/pull/975) +- Bump google.golang.org/grpc from 1.71.1 to 1.72.0 + [#978](https://github.com/strukturag/nextcloud-spreed-signaling/pull/978) +- Bump github.com/nats-io/nats.go from 1.41.1 to 1.41.2 + [#977](https://github.com/strukturag/nextcloud-spreed-signaling/pull/977) +- Bump github.com/nats-io/nats-server/v2 from 2.11.1 to 2.11.3 + [#982](https://github.com/strukturag/nextcloud-spreed-signaling/pull/982) +- Bump github.com/nats-io/nats.go from 1.41.2 to 1.42.0 + [#983](https://github.com/strukturag/nextcloud-spreed-signaling/pull/983) +- Bump golangci/golangci-lint-action from 7.0.0 to 8.0.0 + [#985](https://github.com/strukturag/nextcloud-spreed-signaling/pull/985) + + ## 2.0.2 - 2025-01-22 ### Added diff --git a/Makefile b/Makefile index 3db88cf..c2fbab3 100644 --- a/Makefile +++ b/Makefile @@ -6,28 +6,28 @@ GODIR := $(shell dirname "$(GO)") GOFMT := "$(GODIR)/gofmt" GOOS ?= linux GOARCH ?= amd64 -GOVERSION := $(shell "$(GO)" env GOVERSION | sed "s|go||" ) +GOVERSION := $(shell "$(GO)" env GOVERSION | sed -E 's|go([0-9]+\.[0-9]+)\..*|\1|') +TMPDIR := $(CURDIR)/tmp BINDIR := $(CURDIR)/bin VENDORDIR := "$(CURDIR)/vendor" VERSION := $(shell "$(CURDIR)/scripts/get-version.sh") TARVERSION := $(shell "$(CURDIR)/scripts/get-version.sh" --tar) PACKAGENAME := github.com/strukturag/nextcloud-spreed-signaling -ALL_PACKAGES := $(PACKAGENAME) $(PACKAGENAME)/client $(PACKAGENAME)/proxy $(PACKAGENAME)/server -GRPC_PROTO_FILES := $(basename $(wildcard grpc_*.proto)) +GRPC_PROTO_FILES := $(basename $(wildcard grpc/*.proto)) PROTOBUF_VERSION := $(shell grep google.golang.org/protobuf go.mod | xargs | cut -d ' ' -f 2) -PROTO_FILES := $(filter-out $(GRPC_PROTO_FILES),$(basename $(wildcard *.proto))) +PROTO_FILES := $(filter-out $(GRPC_PROTO_FILES),$(basename $(wildcard *.proto */*.proto))) PROTO_GO_FILES := $(addsuffix .pb.go,$(PROTO_FILES)) GRPC_PROTO_GO_FILES := $(addsuffix .pb.go,$(GRPC_PROTO_FILES)) $(addsuffix _grpc.pb.go,$(GRPC_PROTO_FILES)) -TEST_GO_FILES := $(wildcard *_test.go)) -EASYJSON_FILES := $(filter-out $(TEST_GO_FILES),$(wildcard api*.go)) +TEST_GO_FILES := $(wildcard *_test.go */*_test.go */*/*_test.go) +EASYJSON_FILES := $(filter-out $(TEST_GO_FILES),$(wildcard api*.go api/signaling.go */api.go */*/api.go talk/ocs.go)) EASYJSON_GO_FILES := $(patsubst %.go,%_easyjson.go,$(EASYJSON_FILES)) -COMMON_GO_FILES := $(filter-out continentmap.go $(PROTO_GO_FILES) $(GRPC_PROTO_GO_FILES) $(EASYJSON_GO_FILES) $(TEST_GO_FILES),$(wildcard *.go)) -CLIENT_TEST_GO_FILES := $(wildcard client/*_test.go)) -CLIENT_GO_FILES := $(filter-out $(CLIENT_TEST_GO_FILES),$(wildcard client/*.go)) -SERVER_TEST_GO_FILES := $(wildcard server/*_test.go)) -SERVER_GO_FILES := $(filter-out $(SERVER_TEST_GO_FILES),$(wildcard server/*.go)) -PROXY_TEST_GO_FILES := $(wildcard proxy/*_test.go)) -PROXY_GO_FILES := $(filter-out $(PROXY_TEST_GO_FILES),$(wildcard proxy/*.go)) +COMMON_GO_FILES := $(filter-out geoip/continentmap.go $(PROTO_GO_FILES) $(GRPC_PROTO_GO_FILES) $(EASYJSON_GO_FILES) $(TEST_GO_FILES),$(wildcard *.go */*.go */*/*.go)) +CLIENT_TEST_GO_FILES := $(wildcard cmd/client/*_test.go)) +CLIENT_GO_FILES := $(filter-out $(CLIENT_TEST_GO_FILES),$(wildcard cmd/client/*.go)) +SERVER_TEST_GO_FILES := $(wildcard cmd/server/*_test.go)) +SERVER_GO_FILES := $(filter-out $(SERVER_TEST_GO_FILES),$(wildcard cmd/server/*.go)) +PROXY_TEST_GO_FILES := $(wildcard cmd/proxy/*_test.go)) +PROXY_GO_FILES := $(filter-out $(PROXY_TEST_GO_FILES),$(wildcard cmd/proxy/*.go)) ifneq ($(VERSION),) INTERNALLDFLAGS := -X main.version=$(VERSION) @@ -51,6 +51,10 @@ ifeq ($(TIMEOUT),) TIMEOUT := 60s endif +ifeq ($(BENCHMARK),) +BENCHMARK := . +endif + ifneq ($(TEST),) TESTARGS := $(TESTARGS) -run "$(TEST)" endif @@ -73,10 +77,12 @@ else GOPATHBIN := $(GOPATH)/bin/$(GOOS)_$(GOARCH) endif +GOEXPERIMENT := + hook: [ ! -d "$(CURDIR)/.git/hooks" ] || ln -sf "$(CURDIR)/scripts/pre-commit.hook" "$(CURDIR)/.git/hooks/pre-commit" -$(GOPATHBIN)/easyjson: go.mod go.sum +$(GOPATHBIN)/easyjson: go.mod go.sum | $(TMPDIR) $(GO) install github.com/mailru/easyjson/... $(GOPATHBIN)/protoc-gen-go: go.mod go.sum @@ -85,7 +91,10 @@ $(GOPATHBIN)/protoc-gen-go: go.mod go.sum $(GOPATHBIN)/protoc-gen-go-grpc: go.mod go.sum $(GO) install google.golang.org/grpc/cmd/protoc-gen-go-grpc -continentmap.go: +$(GOPATHBIN)/checklocks: go.mod go.sum + $(GO) install gvisor.dev/gvisor/tools/checklocks/cmd/checklocks@go + +geoip/continentmap.go: $(CURDIR)/scripts/get_continent_map.py $@ check-continentmap: @@ -93,38 +102,41 @@ check-continentmap: TMP=$$(mktemp -d) ;\ echo Make sure to remove $$TMP on error ;\ $(CURDIR)/scripts/get_continent_map.py $$TMP/continentmap.go ;\ - diff -u continentmap.go $$TMP/continentmap.go ;\ + diff -u geoip/continentmap.go $$TMP/continentmap.go ;\ rm -rf $$TMP get: $(GO) get $(PACKAGE) fmt: hook | $(PROTO_GO_FILES) - $(GOFMT) -s -w *.go client proxy server + $(GOFMT) -s -w *.go cmd/client cmd/proxy cmd/server vet: - $(GO) vet $(ALL_PACKAGES) + GOEXPERIMENT=$(GOEXPERIMENT) $(GO) vet ./... test: vet - $(GO) test -timeout $(TIMEOUT) $(TESTARGS) $(ALL_PACKAGES) + GOEXPERIMENT=$(GOEXPERIMENT) $(GO) test -timeout $(TIMEOUT) $(TESTARGS) ./... + +benchmark: + GOEXPERIMENT=$(GOEXPERIMENT) $(GO) test -bench=$(BENCHMARK) -benchmem -run=^$$ -timeout $(TIMEOUT) $(TESTARGS) ./... + +checklocks: $(GOPATHBIN)/checklocks + GOEXPERIMENT=$(GOEXPERIMENT) $(GOPATHBIN)/checklocks ./... cover: vet rm -f cover.out && \ - $(GO) test -timeout $(TIMEOUT) -coverprofile cover.out $(ALL_PACKAGES) && \ - sed -i "/_easyjson/d" cover.out && \ - sed -i "/\.pb\.go/d" cover.out && \ - $(GO) tool cover -func=cover.out + GOEXPERIMENT=$(GOEXPERIMENT) $(GO) test -timeout $(TIMEOUT) -coverprofile cover.out ./... coverhtml: vet rm -f cover.out && \ - $(GO) test -timeout $(TIMEOUT) -coverprofile cover.out $(ALL_PACKAGES) && \ + GOEXPERIMENT=$(GOEXPERIMENT) $(GO) test -timeout $(TIMEOUT) -coverprofile cover.out ./... && \ sed -i "/_easyjson/d" cover.out && \ sed -i "/\.pb\.go/d" cover.out && \ $(GO) tool cover -html=cover.out -o coverage.html %_easyjson.go: %.go $(GOPATHBIN)/easyjson | $(PROTO_GO_FILES) rm -f easyjson-bootstrap*.go - PATH="$(GODIR)":$(PATH) "$(GOPATHBIN)/easyjson" -all $*.go + TMPDIR=$(TMPDIR) PATH="$(GODIR)":$(PATH) "$(GOPATHBIN)/easyjson" -all $*.go %.pb.go: %.proto $(GOPATHBIN)/protoc-gen-go $(GOPATHBIN)/protoc-gen-go-grpc PATH="$(GODIR)":"$(GOPATHBIN)":$(PATH) protoc \ @@ -142,32 +154,37 @@ common: $(EASYJSON_GO_FILES) $(PROTO_GO_FILES) $(GRPC_PROTO_GO_FILES) # Optimize easyjson files that could call generated functions instead of duplicating code. for file in $(EASYJSON_FILES); do \ rm -f easyjson-bootstrap*.go; \ - PATH="$(GODIR)":$(PATH) "$(GOPATHBIN)/easyjson" -all $$file; \ + TMPDIR=$(TMPDIR) PATH="$(GODIR)":$(PATH) "$(GOPATHBIN)/easyjson" -all $$file; \ + rm -f *_easyjson_easyjson.go; \ done $(BINDIR): mkdir -p "$(BINDIR)" +$(TMPDIR): + mkdir -p "$(TMPDIR)" + client: $(BINDIR)/client $(BINDIR)/client: go.mod go.sum $(CLIENT_GO_FILES) $(COMMON_GO_FILES) | $(BINDIR) - $(GO) build $(BUILDARGS) -ldflags '$(INTERNALLDFLAGS)' -o $@ ./client/... + $(GO) build $(BUILDARGS) -ldflags '$(INTERNALLDFLAGS)' -o $@ ./cmd/client/... server: $(BINDIR)/signaling $(BINDIR)/signaling: go.mod go.sum $(SERVER_GO_FILES) $(COMMON_GO_FILES) | $(BINDIR) - $(GO) build $(BUILDARGS) -ldflags '$(INTERNALLDFLAGS)' -o $@ ./server/... + $(GO) build $(BUILDARGS) -ldflags '$(INTERNALLDFLAGS)' -o $@ ./cmd/server/... proxy: $(BINDIR)/proxy $(BINDIR)/proxy: go.mod go.sum $(PROXY_GO_FILES) $(COMMON_GO_FILES) | $(BINDIR) - $(GO) build $(BUILDARGS) -ldflags '$(INTERNALLDFLAGS)' -o $@ ./proxy/... + $(GO) build $(BUILDARGS) -ldflags '$(INTERNALLDFLAGS)' -o $@ ./cmd/proxy/... clean: rm -f easyjson-bootstrap*.go rm -f "$(BINDIR)/client" rm -f "$(BINDIR)/signaling" rm -f "$(BINDIR)/proxy" + rm -rf "$(TMPDIR)" clean-generated: clean rm -f $(EASYJSON_GO_FILES) $(PROTO_GO_FILES) $(GRPC_PROTO_GO_FILES) @@ -179,7 +196,7 @@ vendor: go.mod go.sum rm -rf $(VENDORDIR) $(GO) mod vendor -tarball: vendor +tarball: vendor | $(TMPDIR) git archive \ --prefix=nextcloud-spreed-signaling-$(TARVERSION)/ \ -o nextcloud-spreed-signaling-$(TARVERSION).tar \ @@ -189,11 +206,17 @@ tarball: vendor --mtime="$(shell git log -1 --date=iso8601-strict --format=%cd HEAD)" \ --transform "s//nextcloud-spreed-signaling-$(TARVERSION)\//" \ vendor + echo "$(TARVERSION)" > "$(TMPDIR)/version.txt" + tar rf nextcloud-spreed-signaling-$(TARVERSION).tar \ + -C "$(TMPDIR)" \ + --mtime="$(shell git log -1 --date=iso8601-strict --format=%cd HEAD)" \ + --transform "s//nextcloud-spreed-signaling-$(TARVERSION)\//" \ + version.txt gzip --force nextcloud-spreed-signaling-$(TARVERSION).tar dist: tarball .NOTPARALLEL: $(EASYJSON_GO_FILES) -.PHONY: continentmap.go common vendor +.PHONY: geoip/continentmap.go common vendor .SECONDARY: $(EASYJSON_GO_FILES) $(PROTO_GO_FILES) .DELETE_ON_ERROR: diff --git a/README.md b/README.md index 270aedf..ae20958 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Spreed standalone signaling server ![Build Status](https://github.com/strukturag/nextcloud-spreed-signaling/actions/workflows/test.yml/badge.svg) -[![Coverage Status](https://coveralls.io/repos/github/strukturag/nextcloud-spreed-signaling/badge.svg?branch=master)](https://coveralls.io/github/strukturag/nextcloud-spreed-signaling?branch=master) +[![Coverage Status](https://codecov.io/gh/strukturag/nextcloud-spreed-signaling/graph/badge.svg?token=IMXMIRNAJ8)](https://codecov.io/gh/strukturag/nextcloud-spreed-signaling) [![Documentation Status](https://readthedocs.org/projects/nextcloud-spreed-signaling/badge/?version=latest)](https://nextcloud-spreed-signaling.readthedocs.io/en/latest/?badge=latest) [![Go Report](https://goreportcard.com/badge/github.com/strukturag/nextcloud-spreed-signaling)](https://goreportcard.com/report/github.com/strukturag/nextcloud-spreed-signaling) @@ -17,7 +17,7 @@ information on the API of the signaling server. The following tools are required for building the signaling server. - git -- go >= 1.22 +- go >= 1.25 - make Usually the last two versions of Go are supported. This follows the release @@ -94,11 +94,19 @@ systemctl start signaling.service ### Running with Docker -Official docker containers for the signaling server and -proxy are available on +Official docker images for the signaling server and -proxy are available on Docker Hub at https://hub.docker.com/r/strukturag/nextcloud-spreed-signaling -See the `README.md` in the `docker` subfolder for details. +See the `README.md` in the `docker` subfolder for details on how to use and +configure them. +To build the images locally, run the following commands (replace the parameter +after `-t` with the name the image should be tagged as): + +```bash +docker build -f docker/server/Dockerfile -t nextcloud-spreed-signaling . +docker build -f docker/proxy/Dockerfile -t nextcloud-spreed-signaling-proxy . +``` #### Docker Compose @@ -131,14 +139,30 @@ server. A Janus server (from https://github.com/meetecho/janus-gateway) can be used to act as a WebRTC gateway. See the documentation of Janus on how to configure and -run the server. At least the `VideoRoom` plugin and the websocket transport of -Janus must be enabled. +run the server. At least the `VideoRoom` plugin, the websocket transport and the +websocket events handler of Janus must be enabled. Also broadcasting of events +must be enabled. The signaling server uses the `VideoRoom` plugin of Janus to manage sessions. All gateway details are hidden from the clients, all messages are sent through the signaling server. Only WebRTC media is exchanged directly between the gateway and the clients. +To enable sending of events from Janus, the option `broadcast` must be set to +`true` in the block `events` of `janus.jcfg`. In the configuration of the +websocket events handler (`janus.eventhandler.wsevh.jcfg`), the module must be +enabled by setting `enabled` to `true`, the `backend` must be set to the +websocket url of the signaling server (`ws://127.0.0.1:port/spreed`) or -proxy +(`ws://127.0.0.1:port/proxy`) and `subprotocol` must be set to `janus-events`. +At least events of type `handles`, `media` and `webrtc` must be subscribed. + +Warning: If the configuration between Janus and the signaling endpoint is +interrupted or can't be established, unsent events will be queued by Janus +and will use potentially lots of memory there. This can be limited by setting +`events_cap_on_reconnect` in `janus.eventhandler.wsevh.jcfg`. By default, all +events will be queued as the connection between Janus and the signaling endpoint +is assumed to be stable (most likely will be on the same machine). + Edit the `server.conf` and enter the URL to the websocket endpoint of Janus in the section `[mcu]` and key `url`. During startup, the signaling server will connect to Janus and log information of the gateway. diff --git a/api/bandwidth.go b/api/bandwidth.go new file mode 100644 index 0000000..52f66ae --- /dev/null +++ b/api/bandwidth.go @@ -0,0 +1,111 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package api + +import ( + "fmt" + "math" + "sync/atomic" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" +) + +var ( + Kilobit = BandwidthFromBits(1024) + Megabit = BandwidthFromBits(1024) * Kilobit + Gigabit = BandwidthFromBits(1024) * Megabit +) + +// Bandwidth stores a bandwidth in bits per second. +type Bandwidth uint64 + +func formatWithRemainder(value uint64, divisor uint64, format string) string { + if value%divisor == 0 { + return fmt.Sprintf("%d %s", value/divisor, format) + } else { + v := float64(value) / float64(divisor) + v = math.Trunc(v*100) / 100 + return fmt.Sprintf("%.2f %s", v, format) + } +} + +// String returns the formatted bandwidth. +func (b Bandwidth) String() string { + switch { + case b >= Gigabit: + return formatWithRemainder(b.Bits(), Gigabit.Bits(), "Gbps") + case b >= Megabit: + return formatWithRemainder(b.Bits(), Megabit.Bits(), "Mbps") + case b >= Kilobit: + return formatWithRemainder(b.Bits(), Kilobit.Bits(), "Kbps") + default: + return fmt.Sprintf("%d bps", b) + } +} + +// Bits returns the bandwidth in bits per second. +func (b Bandwidth) Bits() uint64 { + return uint64(b) +} + +// Bytes returns the bandwidth in bytes per second. +func (b Bandwidth) Bytes() uint64 { + return b.Bits() / 8 +} + +// BandwidthFromBits creates a bandwidth from bits per second. +func BandwidthFromBits(b uint64) Bandwidth { + return Bandwidth(b) +} + +// BandwithFromBits creates a bandwidth from megabits per second. +func BandwidthFromMegabits(b uint64) Bandwidth { + return Bandwidth(b) * Megabit +} + +// BandwidthFromBytes creates a bandwidth from bytes per second. +func BandwidthFromBytes(b uint64) Bandwidth { + return Bandwidth(b * 8) +} + +// AtomicBandwidth is an atomic Bandwidth. The zero value is zero. +// AtomicBandwidth must not be copied after first use. +type AtomicBandwidth struct { + // 64-bit members that are accessed atomically must be 64-bit aligned. + v uint64 + _ internal.NoCopy +} + +// Load atomically loads and returns the value stored in b. +func (b *AtomicBandwidth) Load() Bandwidth { + return Bandwidth(atomic.LoadUint64(&b.v)) // +checklocksignore +} + +// Store atomically stores v into b. +func (b *AtomicBandwidth) Store(v Bandwidth) { + atomic.StoreUint64(&b.v, uint64(v)) // +checklocksignore +} + +// Swap atomically stores v into b and returns the previous value. +func (b *AtomicBandwidth) Swap(v Bandwidth) Bandwidth { + return Bandwidth(atomic.SwapUint64(&b.v, uint64(v))) // +checklocksignore +} diff --git a/api/bandwidth_test.go b/api/bandwidth_test.go new file mode 100644 index 0000000..5cdb427 --- /dev/null +++ b/api/bandwidth_test.go @@ -0,0 +1,109 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBandwidth(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + + var b Bandwidth + assert.EqualValues(0, b.Bits()) + assert.EqualValues(0, b.Bytes()) + + b = BandwidthFromBits(8000) + assert.EqualValues(8000, b.Bits()) + assert.EqualValues(1000, b.Bytes()) + + b = BandwidthFromBytes(1000) + assert.EqualValues(8000, b.Bits()) + assert.EqualValues(1000, b.Bytes()) + + b = BandwidthFromMegabits(2) + assert.EqualValues(2*1024*1024, b.Bits()) + assert.EqualValues(2*1024*1024/8, b.Bytes()) + + var a AtomicBandwidth + assert.EqualValues(0, a.Load()) + a.Store(1000) + assert.EqualValues(1000, a.Load()) + old := a.Swap(2000) + assert.EqualValues(1000, old) + assert.EqualValues(2000, a.Load()) +} + +func TestBandwidthString(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + testcases := []struct { + value Bandwidth + expected string + }{ + { + 0, + "0 bps", + }, + { + BandwidthFromBits(123), + "123 bps", + }, + { + BandwidthFromBits(1023), + "1023 bps", + }, + { + BandwidthFromBits(1024), + "1 Kbps", + }, + { + BandwidthFromBits(1024 + 512), + "1.50 Kbps", + }, + { + BandwidthFromBits(1024*1024 - 1), + "1023.99 Kbps", + }, + { + BandwidthFromBits(1024 * 1024), + "1 Mbps", + }, + { + BandwidthFromBits(1024*1024*1024 - 1), + "1023.99 Mbps", + }, + { + BandwidthFromBits(1024 * 1024 * 1024), + "1 Gbps", + }, + } + + for idx, tc := range testcases { + assert.Equal(tc.expected, tc.value.String(), "failed for testcase %d (%d)", idx, tc.value.Bits()) + } +} diff --git a/api_signaling.go b/api/signaling.go similarity index 62% rename from api_signaling.go rename to api/signaling.go index 9f978aa..8effa1a 100644 --- a/api_signaling.go +++ b/api/signaling.go @@ -19,20 +19,26 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package api import ( "encoding/json" "errors" "fmt" "log" + "net" "net/url" - "sort" + "slices" "strings" "time" "github.com/golang-jwt/jwt/v5" + "github.com/pion/ice/v4" "github.com/pion/sdp/v3" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/container" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" ) const ( @@ -47,24 +53,54 @@ const ( ) var ( - ErrNoSdp = NewError("no_sdp", "Payload does not contain a SDP.") + // InvalidHelloVersion is returned if the version in the "hello" message is not supported. + InvalidHelloVersion = NewError("invalid_hello_version", "The hello version is not supported.") + + ErrNoSdp = NewError("no_sdp", "Payload does not contain a SDP.") // +checklocksignore: Global readonly variable. ErrInvalidSdp = NewError("invalid_sdp", "Payload does not contain a valid SDP.") + + ErrNoCandidate = NewError("no_candidate", "Payload does not contain a candidate.") + ErrInvalidCandidate = NewError("invalid_candidate", "Payload does not contain a valid candidate.") + + ErrCandidateFiltered = errors.New("candidate was filtered") ) -func makePtr[T any](v T) *T { - return &v +type PrivateSessionId string + +type PublicSessionId string + +type RoomSessionId string + +const ( + FederatedRoomSessionIdPrefix = "federated|" +) + +func (s RoomSessionId) IsFederated() bool { + return strings.HasPrefix(string(s), FederatedRoomSessionIdPrefix) } -func getStringMapEntry[T any](m map[string]interface{}, key string) (s T, ok bool) { - var defaultValue T - v, found := m[key] - if !found { - return defaultValue, false +func (s RoomSessionId) WithoutFederation() RoomSessionId { + return RoomSessionId(strings.TrimPrefix(string(s), FederatedRoomSessionIdPrefix)) +} + +type Permission string + +var ( + PERMISSION_MAY_PUBLISH_MEDIA Permission = "publish-media" + PERMISSION_MAY_PUBLISH_AUDIO Permission = "publish-audio" + PERMISSION_MAY_PUBLISH_VIDEO Permission = "publish-video" + PERMISSION_MAY_PUBLISH_SCREEN Permission = "publish-screen" + PERMISSION_MAY_CONTROL Permission = "control" + PERMISSION_TRANSIENT_DATA Permission = "transient-data" + PERMISSION_HIDE_DISPLAYNAMES Permission = "hide-displaynames" + + // DefaultPermissionOverrides contains permission overrides for users where + // no permissions have been set by the server. If a permission is not set in + // this map, it's assumed the user has that permission. + DefaultPermissionOverrides = map[Permission]bool{ // +checklocksignore: Global readonly variable. + PERMISSION_HIDE_DISPLAYNAMES: false, } - - s, ok = v.(T) - return -} +) // ClientMessage is a message that is sent from a client to the server. type ClientMessage struct { @@ -96,10 +132,10 @@ type ClientMessage struct { func (m *ClientMessage) CheckValid() error { switch m.Type { case "": - return fmt.Errorf("type missing") + return errors.New("type missing") case "hello": if m.Hello == nil { - return fmt.Errorf("hello missing") + return errors.New("hello missing") } else if err := m.Hello.CheckValid(); err != nil { return err } @@ -107,31 +143,31 @@ func (m *ClientMessage) CheckValid() error { // No additional check required. case "room": if m.Room == nil { - return fmt.Errorf("room missing") + return errors.New("room missing") } else if err := m.Room.CheckValid(); err != nil { return err } case "message": if m.Message == nil { - return fmt.Errorf("message missing") + return errors.New("message missing") } else if err := m.Message.CheckValid(); err != nil { return err } case "control": if m.Control == nil { - return fmt.Errorf("control missing") + return errors.New("control missing") } else if err := m.Control.CheckValid(); err != nil { return err } case "internal": if m.Internal == nil { - return fmt.Errorf("internal missing") + return errors.New("internal missing") } else if err := m.Internal.CheckValid(); err != nil { return err } case "transient": if m.TransientData == nil { - return fmt.Errorf("transient missing") + return errors.New("transient missing") } else if err := m.TransientData.CheckValid(); err != nil { return err } @@ -195,7 +231,11 @@ type ServerMessage struct { Dialout *DialoutInternalClientMessage `json:"dialout,omitempty"` } -func (r *ServerMessage) CloseAfterSend(session Session) bool { +type RoomAware interface { + IsInRoom(id string) bool +} + +func (r *ServerMessage) CloseAfterSend(session RoomAware) bool { if r.Type == "bye" { return true } @@ -204,10 +244,8 @@ func (r *ServerMessage) CloseAfterSend(session Session) bool { if evt := r.Event; evt != nil && evt.Target == "roomlist" && evt.Type == "disinvite" { // Only close session / connection if the disinvite was for the room // the session is currently in. - if session != nil && evt.Disinvite != nil { - if room := session.GetRoom(); room != nil && evt.Disinvite.RoomId == room.Id() { - return true - } + if session != nil && evt.Disinvite != nil && session.IsInRoom(evt.Disinvite.RoomId) { + return true } } } @@ -216,12 +254,13 @@ func (r *ServerMessage) CloseAfterSend(session Session) bool { } func (r *ServerMessage) IsChatRefresh() bool { - if r.Type != "message" || r.Message == nil || len(r.Message.Data) == 0 { + if r.Type != "event" || r.Event == nil || + r.Event.Type != "message" || r.Event.Message == nil || len(r.Event.Message.Data) == 0 { return false } - var data MessageServerMessageData - if err := json.Unmarshal(r.Message.Data, &data); err != nil { + data, err := r.Event.Message.GetData() + if data == nil || err != nil { return false } @@ -260,7 +299,7 @@ func NewError(code string, message string) *Error { return NewErrorDetail(code, message, nil) } -func NewErrorDetail(code string, message string, details interface{}) *Error { +func NewErrorDetail(code string, message string, details any) *Error { var rawDetails json.RawMessage if details != nil { var err error @@ -282,9 +321,9 @@ func (e *Error) Error() string { } type WelcomeServerMessage struct { - Version string `json:"version"` - Features []string `json:"features,omitempty"` - Country string `json:"country,omitempty"` + Version string `json:"version"` + Features []string `json:"features,omitempty"` + Country geoip.Country `json:"country,omitempty"` } func NewWelcomeServerMessage(version string, feature ...string) *WelcomeServerMessage { @@ -293,27 +332,19 @@ func NewWelcomeServerMessage(version string, feature ...string) *WelcomeServerMe Features: feature, } if len(feature) > 0 { - sort.Strings(message.Features) + slices.Sort(message.Features) } return message } func (m *WelcomeServerMessage) AddFeature(feature ...string) { - newFeatures := make([]string, len(m.Features)) - copy(newFeatures, m.Features) + newFeatures := slices.Clone(m.Features) for _, feat := range feature { - found := false - for _, f := range newFeatures { - if f == feat { - found = true - break - } - } - if !found { + if !slices.Contains(newFeatures, feat) { newFeatures = append(newFeatures, feat) } } - sort.Strings(newFeatures) + slices.Sort(newFeatures) m.Features = newFeatures } @@ -321,9 +352,9 @@ func (m *WelcomeServerMessage) RemoveFeature(feature ...string) { newFeatures := make([]string, len(m.Features)) copy(newFeatures, m.Features) for _, feat := range feature { - idx := sort.SearchStrings(newFeatures, feat) - if idx < len(newFeatures) && newFeatures[idx] == feat { - newFeatures = append(newFeatures[:idx], newFeatures[idx+1:]...) + idx, found := slices.BinarySearch(newFeatures, feat) + if found { + newFeatures = slices.Delete(newFeatures, idx, idx+1) } } m.Features = newFeatures @@ -340,44 +371,41 @@ func (m *WelcomeServerMessage) HasFeature(feature string) bool { return false } +type ClientType string + const ( - HelloClientTypeClient = "client" - HelloClientTypeInternal = "internal" - HelloClientTypeFederation = "federation" + HelloClientTypeClient = ClientType("client") + HelloClientTypeInternal = ClientType("internal") + HelloClientTypeFederation = ClientType("federation") - HelloClientTypeVirtual = "virtual" + HelloClientTypeVirtual = ClientType("virtual") ) -func hasStandardPort(u *url.URL) bool { - switch u.Scheme { - case "http": - return u.Port() == "80" - case "https": - return u.Port() == "443" - default: - return false - } -} - type ClientTypeInternalAuthParams struct { Random string `json:"random"` Token string `json:"token"` - Backend string `json:"backend"` - parsedBackend *url.URL + Backend string `json:"backend"` + ParsedBackend *url.URL `json:"-"` } func (p *ClientTypeInternalAuthParams) CheckValid() error { if p.Backend == "" { - return fmt.Errorf("backend missing") - } else if u, err := url.Parse(p.Backend); err != nil { + return errors.New("backend missing") + } + + if p.Backend[len(p.Backend)-1] != '/' { + p.Backend += "/" + } + if u, err := url.Parse(p.Backend); err != nil { return err } else { - if strings.Contains(u.Host, ":") && hasStandardPort(u) { - u.Host = u.Hostname() + var changed bool + if u, changed = internal.CanonicalizeUrl(u); changed { + p.Backend = u.String() } - p.parsedBackend = u + p.ParsedBackend = u } return nil } @@ -388,7 +416,7 @@ type HelloV2AuthParams struct { func (p *HelloV2AuthParams) CheckValid() error { if p.Token == "" { - return fmt.Errorf("token missing") + return errors.New("token missing") } return nil } @@ -415,7 +443,7 @@ type FederationAuthParams struct { func (p *FederationAuthParams) CheckValid() error { if p.Token == "" { - return fmt.Errorf("token missing") + return errors.New("token missing") } return nil } @@ -433,16 +461,16 @@ func (c *FederationTokenClaims) GetUserData() json.RawMessage { type HelloClientMessageAuth struct { // The client type that is connecting. Leave empty to use the default // "HelloClientTypeClient" - Type string `json:"type,omitempty"` + Type ClientType `json:"type,omitempty"` Params json.RawMessage `json:"params"` - Url string `json:"url"` - parsedUrl *url.URL + Url string `json:"url"` + ParsedUrl *url.URL `json:"-"` - internalParams ClientTypeInternalAuthParams - helloV2Params HelloV2AuthParams - federationParams FederationAuthParams + InternalParams ClientTypeInternalAuthParams `json:"-"` + HelloV2Params HelloV2AuthParams `json:"-"` + FederationParams FederationAuthParams `json:"-"` } // Type "hello" @@ -450,7 +478,7 @@ type HelloClientMessageAuth struct { type HelloClientMessage struct { Version string `json:"version"` - ResumeId string `json:"resumeid"` + ResumeId PrivateSessionId `json:"resumeid"` Features []string `json:"features,omitempty"` @@ -464,7 +492,7 @@ func (m *HelloClientMessage) CheckValid() error { } if m.ResumeId == "" { if m.Auth == nil || len(m.Auth.Params) == 0 { - return fmt.Errorf("params missing") + return errors.New("params missing") } if m.Auth.Type == "" { m.Auth.Type = HelloClientTypeClient @@ -474,15 +502,25 @@ func (m *HelloClientMessage) CheckValid() error { fallthrough case HelloClientTypeFederation: if m.Auth.Url == "" { - return fmt.Errorf("url missing") - } else if u, err := url.ParseRequestURI(m.Auth.Url); err != nil { + return errors.New("url missing") + } + + if m.Auth.Url[len(m.Auth.Url)-1] != '/' { + m.Auth.Url += "/" + } + if pos := strings.Index(m.Auth.Url, "ocs/v2.php/apps/spreed/"); pos != -1 { + m.Auth.Url = m.Auth.Url[:pos] + } + + if u, err := url.ParseRequestURI(m.Auth.Url); err != nil { return err } else { - if strings.Contains(u.Host, ":") && hasStandardPort(u) { - u.Host = u.Hostname() + var changed bool + if u, changed = internal.CanonicalizeUrl(u); changed { + m.Auth.Url = u.String() } - m.Auth.parsedUrl = u + m.Auth.ParsedUrl = u } switch m.Version { @@ -491,27 +529,27 @@ func (m *HelloClientMessage) CheckValid() error { case HelloVersionV2: switch m.Auth.Type { case HelloClientTypeClient: - if err := json.Unmarshal(m.Auth.Params, &m.Auth.helloV2Params); err != nil { + if err := json.Unmarshal(m.Auth.Params, &m.Auth.HelloV2Params); err != nil { return err - } else if err := m.Auth.helloV2Params.CheckValid(); err != nil { + } else if err := m.Auth.HelloV2Params.CheckValid(); err != nil { return err } case HelloClientTypeFederation: - if err := json.Unmarshal(m.Auth.Params, &m.Auth.federationParams); err != nil { + if err := json.Unmarshal(m.Auth.Params, &m.Auth.FederationParams); err != nil { return err - } else if err := m.Auth.federationParams.CheckValid(); err != nil { + } else if err := m.Auth.FederationParams.CheckValid(); err != nil { return err } } } case HelloClientTypeInternal: - if err := json.Unmarshal(m.Auth.Params, &m.Auth.internalParams); err != nil { + if err := json.Unmarshal(m.Auth.Params, &m.Auth.InternalParams); err != nil { return err - } else if err := m.Auth.internalParams.CheckValid(); err != nil { + } else if err := m.Auth.InternalParams.CheckValid(); err != nil { return err } default: - return fmt.Errorf("unsupported auth type") + return errors.New("unsupported auth type") } } return nil @@ -533,11 +571,15 @@ const ( ServerFeatureRecipientCall = "recipient-call" ServerFeatureJoinFeatures = "join-features" ServerFeatureOfferCodecs = "offer-codecs" + ServerFeatureServerInfo = "serverinfo" + ServerFeatureChatRelay = "chat-relay" + ServerFeatureTransientSessionData = "transient-sessiondata" // Features to send to internal clients only. ServerFeatureInternalVirtualSessions = "virtual-sessions" // Possible client features from the "hello" request. + ClientFeatureChatRelay = "chat-relay" ClientFeatureInternalInCall = "internal-incall" ClientFeatureStartDialout = "start-dialout" ) @@ -555,6 +597,9 @@ var ( ServerFeatureRecipientCall, ServerFeatureJoinFeatures, ServerFeatureOfferCodecs, + ServerFeatureServerInfo, + ServerFeatureChatRelay, + ServerFeatureTransientSessionData, } DefaultFeaturesInternal = []string{ ServerFeatureInternalVirtualSessions, @@ -568,6 +613,9 @@ var ( ServerFeatureRecipientCall, ServerFeatureJoinFeatures, ServerFeatureOfferCodecs, + ServerFeatureServerInfo, + ServerFeatureChatRelay, + ServerFeatureTransientSessionData, } DefaultWelcomeFeatures = []string{ ServerFeatureAudioVideoPermissions, @@ -582,15 +630,18 @@ var ( ServerFeatureRecipientCall, ServerFeatureJoinFeatures, ServerFeatureOfferCodecs, + ServerFeatureServerInfo, + ServerFeatureChatRelay, + ServerFeatureTransientSessionData, } ) type HelloServerMessage struct { Version string `json:"version"` - SessionId string `json:"sessionid"` - ResumeId string `json:"resumeid"` - UserId string `json:"userid"` + SessionId PublicSessionId `json:"sessionid"` + ResumeId PrivateSessionId `json:"resumeid"` + UserId string `json:"userid"` // TODO: Remove once all clients have switched to the "welcome" message. Server *WelcomeServerMessage `json:"server,omitempty"` @@ -613,8 +664,8 @@ type ByeServerMessage struct { // Type "room" type RoomClientMessage struct { - RoomId string `json:"roomid"` - SessionId string `json:"sessionid,omitempty"` + RoomId string `json:"roomid"` + SessionId RoomSessionId `json:"sessionid,omitempty"` Federation *RoomFederationMessage `json:"federation,omitempty"` } @@ -631,11 +682,11 @@ func (m *RoomClientMessage) CheckValid() error { } type RoomFederationMessage struct { - SignalingUrl string `json:"signaling"` - parsedSignalingUrl *url.URL + SignalingUrl string `json:"signaling"` + ParsedSignalingUrl *url.URL `json:"-"` - NextcloudUrl string `json:"url"` - parsedNextcloudUrl *url.URL + NextcloudUrl string `json:"url"` + ParsedNextcloudUrl *url.URL `json:"-"` RoomId string `json:"roomid,omitempty"` Token string `json:"token"` @@ -652,14 +703,14 @@ func (m *RoomFederationMessage) CheckValid() error { if u, err := url.Parse(m.SignalingUrl); err != nil { return fmt.Errorf("invalid signaling url: %w", err) } else { - m.parsedSignalingUrl = u + m.ParsedSignalingUrl = u } if m.NextcloudUrl == "" { return errors.New("nextcloud url missing") } else if u, err := url.Parse(m.NextcloudUrl); err != nil { return fmt.Errorf("invalid nextcloud url: %w", err) } else { - m.parsedNextcloudUrl = u + m.ParsedNextcloudUrl = u } if m.Token == "" { return errors.New("token missing") @@ -671,6 +722,12 @@ func (m *RoomFederationMessage) CheckValid() error { type RoomServerMessage struct { RoomId string `json:"roomid"` Properties json.RawMessage `json:"properties,omitempty"` + Bandwidth *RoomBandwidth `json:"bandwidth,omitempty"` +} + +type RoomBandwidth struct { + MaxStreamBitrate Bandwidth `json:"maxstreambitrate"` + MaxScreenBitrate Bandwidth `json:"maxscreenbitrate"` } type RoomErrorDetails struct { @@ -689,8 +746,8 @@ const ( type MessageClientMessageRecipient struct { Type string `json:"type"` - SessionId string `json:"sessionid,omitempty"` - UserId string `json:"userid,omitempty"` + SessionId PublicSessionId `json:"sessionid,omitempty"` + UserId string `json:"userid,omitempty"` } type MessageClientMessage struct { @@ -700,56 +757,186 @@ type MessageClientMessage struct { } type MessageClientMessageData struct { - Type string `json:"type"` - Sid string `json:"sid"` - RoomType string `json:"roomType"` - Payload map[string]interface{} `json:"payload"` + json.Marshaler + json.Unmarshaler + + Type string `json:"type"` + Sid string `json:"sid"` + RoomType string `json:"roomType"` + Payload StringMap `json:"payload"` // Only supported if Type == "offer" - Bitrate int `json:"bitrate,omitempty"` - AudioCodec string `json:"audiocodec,omitempty"` - VideoCodec string `json:"videocodec,omitempty"` - VP9Profile string `json:"vp9profile,omitempty"` - H264Profile string `json:"h264profile,omitempty"` + Bitrate Bandwidth `json:"bitrate,omitempty"` + AudioCodec string `json:"audiocodec,omitempty"` + VideoCodec string `json:"videocodec,omitempty"` + VP9Profile string `json:"vp9profile,omitempty"` + H264Profile string `json:"h264profile,omitempty"` - offerSdp *sdp.SessionDescription // Only set if Type == "offer" - answerSdp *sdp.SessionDescription // Only set if Type == "answer" + OfferSdp *sdp.SessionDescription `json:"-"` // Only set if Type == "offer" + AnswerSdp *sdp.SessionDescription `json:"-"` // Only set if Type == "answer" + Candidate ice.Candidate `json:"-"` // Only set if Type == "candidate" +} + +func (m *MessageClientMessageData) String() string { + data, err := json.Marshal(m) + if err != nil { + return fmt.Sprintf("Could not serialize %#v: %s", m, err) + } + return string(data) +} + +func ParseSDP(s string) (*sdp.SessionDescription, error) { + var sdp sdp.SessionDescription + if err := sdp.UnmarshalString(s); err != nil { + return nil, NewErrorDetail("invalid_sdp", "Error parsing SDP from payload.", StringMap{ + "error": err.Error(), + }) + } + + for _, m := range sdp.MediaDescriptions { + for idx, a := range m.Attributes { + if !a.IsICECandidate() { + continue + } + + if _, err := ice.UnmarshalCandidate(a.Value); err != nil { + return nil, NewErrorDetail("invalid_sdp", "Error parsing candidate from media description.", StringMap{ + "media": m.MediaName.Media, + "idx": idx, + "error": err.Error(), + }) + } + } + } + + return &sdp, nil +} + +var ( + emptyCandidate = &ice.CandidateHost{} +) + +// TODO: Use shared method from "mcu_common.go". +func isValidStreamType(s string) bool { + switch s { + case "audio": + fallthrough + case "video": + fallthrough + case "screen": + return true + default: + return false + } } func (m *MessageClientMessageData) CheckValid() error { - if m.RoomType != "" && !IsValidStreamType(m.RoomType) { + if m.RoomType != "" && !isValidStreamType(m.RoomType) { return fmt.Errorf("invalid room type: %s", m.RoomType) } - if m.Type == "offer" || m.Type == "answer" { - sdpValue, found := m.Payload["sdp"] - if !found { - return ErrNoSdp - } - sdpText, ok := sdpValue.(string) + switch m.Type { + case "": + return errors.New("type missing") + case "offer", "answer": + sdpText, ok := GetStringMapEntry[string](m.Payload, "sdp") if !ok { return ErrInvalidSdp } - var sdp sdp.SessionDescription - if err := sdp.Unmarshal([]byte(sdpText)); err != nil { - return NewErrorDetail("invalid_sdp", "Error parsing SDP from payload.", map[string]interface{}{ - "error": err.Error(), - }) + sdp, err := ParseSDP(sdpText) + if err != nil { + return err } switch m.Type { case "offer": - m.offerSdp = &sdp + m.OfferSdp = sdp case "answer": - m.answerSdp = &sdp + m.AnswerSdp = sdp + } + case "candidate": + candValue, found := m.Payload["candidate"] + if !found { + return ErrNoCandidate + } + candItem, ok := ConvertStringMap(candValue) + if !ok { + return ErrInvalidCandidate + } + candText, ok := GetStringMapEntry[string](candItem, "candidate") + if !ok { + return ErrInvalidCandidate + } + + if candText == "" { + m.Candidate = emptyCandidate + } else { + cand, err := ice.UnmarshalCandidate(candText) + if err != nil { + return NewErrorDetail("invalid_candidate", "Error parsing candidate from payload.", StringMap{ + "error": err.Error(), + }) + } + m.Candidate = cand } } return nil } +func FilterCandidate(c ice.Candidate, allowed *container.IPList, blocked *container.IPList) bool { + switch c { + case nil: + return true + case emptyCandidate: + return false + } + + ip := net.ParseIP(c.Address()) + if len(ip) == 0 || ip.IsUnspecified() { + return true + } + + // Whitelist has preference. + if allowed != nil && allowed.Contains(ip) { + return false + } + + // Check if address is blocked manually. + if blocked != nil && blocked.Contains(ip) { + return true + } + + return false +} + +func FilterSDPCandidates(s *sdp.SessionDescription, allowed *container.IPList, blocked *container.IPList) bool { + modified := false + for _, m := range s.MediaDescriptions { + m.Attributes = slices.DeleteFunc(m.Attributes, func(a sdp.Attribute) bool { + if !a.IsICECandidate() { + return false + } + + if a.Value == "" { + return false + } + + c, err := ice.UnmarshalCandidate(a.Value) + if err != nil || FilterCandidate(c, allowed, blocked) { + modified = true + return true + } + + return false + }) + } + + return modified +} + func (m *MessageClientMessage) CheckValid() error { if len(m.Data) == 0 { - return fmt.Errorf("message empty") + return errors.New("message empty") } switch m.Recipient.Type { case RecipientTypeRoom: @@ -758,11 +945,11 @@ func (m *MessageClientMessage) CheckValid() error { // No additional checks required. case RecipientTypeSession: if m.Recipient.SessionId == "" { - return fmt.Errorf("session id missing") + return errors.New("session id missing") } case RecipientTypeUser: if m.Recipient.UserId == "" { - return fmt.Errorf("user id missing") + return errors.New("user id missing") } default: return fmt.Errorf("unsupported recipient type %v", m.Recipient.Type) @@ -773,18 +960,12 @@ func (m *MessageClientMessage) CheckValid() error { type MessageServerMessageSender struct { Type string `json:"type"` - SessionId string `json:"sessionid,omitempty"` - UserId string `json:"userid,omitempty"` -} - -type MessageServerMessageDataChat struct { - Refresh bool `json:"refresh"` + SessionId PublicSessionId `json:"sessionid,omitempty"` + UserId string `json:"userid,omitempty"` } type MessageServerMessageData struct { Type string `json:"type"` - - Chat *MessageServerMessageDataChat `json:"chat,omitempty"` } type MessageServerMessage struct { @@ -814,17 +995,17 @@ type ControlServerMessage struct { // Type "internal" type CommonSessionInternalClientMessage struct { - SessionId string `json:"sessionid"` + SessionId PublicSessionId `json:"sessionid"` RoomId string `json:"roomid"` } func (m *CommonSessionInternalClientMessage) CheckValid() error { if m.SessionId == "" { - return fmt.Errorf("sessionid missing") + return errors.New("sessionid missing") } if m.RoomId == "" { - return fmt.Errorf("roomid missing") + return errors.New("roomid missing") } return nil } @@ -944,31 +1125,31 @@ func (m *InternalClientMessage) CheckValid() error { return errors.New("type missing") case "addsession": if m.AddSession == nil { - return fmt.Errorf("addsession missing") + return errors.New("addsession missing") } else if err := m.AddSession.CheckValid(); err != nil { return err } case "updatesession": if m.UpdateSession == nil { - return fmt.Errorf("updatesession missing") + return errors.New("updatesession missing") } else if err := m.UpdateSession.CheckValid(); err != nil { return err } case "removesession": if m.RemoveSession == nil { - return fmt.Errorf("removesession missing") + return errors.New("removesession missing") } else if err := m.RemoveSession.CheckValid(); err != nil { return err } case "incall": if m.InCall == nil { - return fmt.Errorf("incall missing") + return errors.New("incall missing") } else if err := m.InCall.CheckValid(); err != nil { return err } case "dialout": if m.Dialout == nil { - return fmt.Errorf("dialout missing") + return errors.New("dialout missing") } else if err := m.Dialout.CheckValid(); err != nil { return err } @@ -976,11 +1157,18 @@ func (m *InternalClientMessage) CheckValid() error { return nil } +type InternalServerDialoutRequestContents struct { + // E.164 number to dial (e.g. "+1234567890") + Number string `json:"number"` + + Options json.RawMessage `json:"options,omitempty"` +} + type InternalServerDialoutRequest struct { RoomId string `json:"roomid"` Backend string `json:"backend"` - Request *BackendRoomDialoutRequest `json:"request"` + Request *InternalServerDialoutRequestContents `json:"request"` } type InternalServerMessage struct { @@ -995,9 +1183,9 @@ type RoomEventServerMessage struct { RoomId string `json:"roomid"` Properties json.RawMessage `json:"properties,omitempty"` // TODO(jojo): Change "InCall" to "int" when #914 has landed in NC Talk. - InCall json.RawMessage `json:"incall,omitempty"` - Changed []map[string]interface{} `json:"changed,omitempty"` - Users []map[string]interface{} `json:"users,omitempty"` + InCall json.RawMessage `json:"incall,omitempty"` + Changed []StringMap `json:"changed,omitempty"` + Users []StringMap `json:"users,omitempty"` All bool `json:"all,omitempty"` } @@ -1021,21 +1209,22 @@ type RoomDisinviteEventServerMessage struct { Reason string `json:"reason"` } -type RoomEventMessage struct { - RoomId string `json:"roomid"` - Data json.RawMessage `json:"data,omitempty"` -} - -type RoomFlagsServerMessage struct { - RoomId string `json:"roomid"` - SessionId string `json:"sessionid"` - Flags uint32 `json:"flags"` -} - -type ChatComment map[string]interface{} +type ChatComment StringMap type RoomEventMessageDataChat struct { - Comment *ChatComment `json:"comment,omitempty"` + // Refresh will be included if the client does not support the "chat-relay" feature. + Refresh bool `json:"refresh,omitempty"` + + // Comment will be included if the client supports the "chat-relay" feature. + Comment json.RawMessage `json:"comment,omitempty"` + // Comments will be included if the client supports the "chat-relay" feature. + Comments []json.RawMessage `json:"comments,omitempty"` +} + +func (m *RoomEventMessageDataChat) HasComment() bool { + return len(m.Comment) > 0 || slices.ContainsFunc(m.Comments, func(comment json.RawMessage) bool { + return len(comment) > 0 + }) } type RoomEventMessageData struct { @@ -1044,16 +1233,41 @@ type RoomEventMessageData struct { Chat *RoomEventMessageDataChat `json:"chat,omitempty"` } +type RoomEventMessage struct { + RoomId string `json:"roomid"` + Data json.RawMessage `json:"data,omitempty"` +} + +func (m *RoomEventMessage) GetData() (*RoomEventMessageData, error) { + if len(m.Data) == 0 { + return nil, nil + } + + // TODO: Cache parsed result. + var data RoomEventMessageData + if err := json.Unmarshal(m.Data, &data); err != nil { + return nil, err + } + + return &data, nil +} + +type RoomFlagsServerMessage struct { + RoomId string `json:"roomid"` + SessionId PublicSessionId `json:"sessionid"` + Flags uint32 `json:"flags"` +} + type EventServerMessage struct { Target string `json:"target"` Type string `json:"type"` // Used for target "room" - Join []*EventServerMessageSessionEntry `json:"join,omitempty"` - Leave []string `json:"leave,omitempty"` - Change []*EventServerMessageSessionEntry `json:"change,omitempty"` - SwitchTo *EventServerMessageSwitchTo `json:"switchto,omitempty"` - Resumed *bool `json:"resumed,omitempty"` + Join []EventServerMessageSessionEntry `json:"join,omitempty"` + Leave []PublicSessionId `json:"leave,omitempty"` + Change []EventServerMessageSessionEntry `json:"change,omitempty"` + SwitchTo *EventServerMessageSwitchTo `json:"switchto,omitempty"` + Resumed *bool `json:"resumed,omitempty"` // Used for target "roomlist" / "participants" Invite *RoomEventServerMessage `json:"invite,omitempty"` @@ -1074,16 +1288,16 @@ func (m *EventServerMessage) String() string { } type EventServerMessageSessionEntry struct { - SessionId string `json:"sessionid"` + SessionId PublicSessionId `json:"sessionid"` UserId string `json:"userid"` Features []string `json:"features,omitempty"` User json.RawMessage `json:"user,omitempty"` - RoomSessionId string `json:"roomsessionid,omitempty"` + RoomSessionId RoomSessionId `json:"roomsessionid,omitempty"` Federated bool `json:"federated,omitempty"` } -func (e *EventServerMessageSessionEntry) Clone() *EventServerMessageSessionEntry { - return &EventServerMessageSessionEntry{ +func (e EventServerMessageSessionEntry) Clone() EventServerMessageSessionEntry { + return EventServerMessageSessionEntry{ SessionId: e.SessionId, UserId: e.UserId, Features: e.Features, @@ -1101,12 +1315,12 @@ type EventServerMessageSwitchTo struct { // MCU-related types type AnswerOfferMessage struct { - To string `json:"to"` - From string `json:"from"` - Type string `json:"type"` - RoomType string `json:"roomType"` - Payload map[string]interface{} `json:"payload"` - Sid string `json:"sid,omitempty"` + To PublicSessionId `json:"to"` + From PublicSessionId `json:"from"` + Type string `json:"type"` + RoomType string `json:"roomType"` + Payload StringMap `json:"payload"` + Sid string `json:"sid,omitempty"` } // Type "transient" @@ -1121,14 +1335,16 @@ type TransientDataClientMessage struct { func (m *TransientDataClientMessage) CheckValid() error { switch m.Type { + case "": + return errors.New("type missing") case "set": if m.Key == "" { - return fmt.Errorf("key missing") + return errors.New("key missing") } // A "nil" value is allowed and will remove the key. case "remove": if m.Key == "" { - return fmt.Errorf("key missing") + return errors.New("key missing") } } return nil @@ -1137,8 +1353,8 @@ func (m *TransientDataClientMessage) CheckValid() error { type TransientDataServerMessage struct { Type string `json:"type"` - Key string `json:"key,omitempty"` - OldValue interface{} `json:"oldvalue,omitempty"` - Value interface{} `json:"value,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` + Key string `json:"key,omitempty"` + OldValue any `json:"oldvalue,omitempty"` + Value any `json:"value,omitempty"` + Data StringMap `json:"data,omitempty"` } diff --git a/api_signaling_easyjson.go b/api/signaling_easyjson.go similarity index 64% rename from api_signaling_easyjson.go rename to api/signaling_easyjson.go index 86ee338..877c6e8 100644 --- a/api_signaling_easyjson.go +++ b/api/signaling_easyjson.go @@ -1,6 +1,6 @@ // Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. -package signaling +package api import ( json "encoding/json" @@ -8,6 +8,7 @@ import ( easyjson "github.com/mailru/easyjson" jlexer "github.com/mailru/easyjson/jlexer" jwriter "github.com/mailru/easyjson/jwriter" + geoip "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" time "time" ) @@ -19,7 +20,7 @@ var ( _ easyjson.Marshaler ) -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexer.Lexer, out *WelcomeServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api(in *jlexer.Lexer, out *WelcomeServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -32,14 +33,13 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "version": - out.Version = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } case "features": if in.IsNull() { in.Skip() @@ -57,14 +57,22 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe } for !in.IsDelim(']') { var v1 string - v1 = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + v1 = string(in.String()) + } out.Features = append(out.Features, v1) in.WantComma() } in.Delim(']') } case "country": - out.Country = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Country = geoip.Country(in.String()) + } default: in.SkipRecursive() } @@ -75,7 +83,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling(out *jwriter.Writer, in WelcomeServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api(out *jwriter.Writer, in WelcomeServerMessage) { out.RawByte('{') first := true _ = first @@ -109,27 +117,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling(out *jwri // MarshalJSON supports json.Marshaler interface func (v WelcomeServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v WelcomeServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *WelcomeServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *WelcomeServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlexer.Lexer, out *UpdateSessionInternalClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api1(in *jlexer.Lexer, out *UpdateSessionInternalClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -142,11 +150,6 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "flags": if in.IsNull() { @@ -156,7 +159,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex if out.Flags == nil { out.Flags = new(uint32) } - *out.Flags = uint32(in.Uint32()) + if in.IsNull() { + in.Skip() + } else { + *out.Flags = uint32(in.Uint32()) + } } case "incall": if in.IsNull() { @@ -166,12 +173,24 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex if out.InCall == nil { out.InCall = new(int) } - *out.InCall = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + *out.InCall = int(in.Int()) + } } case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = PublicSessionId(in.String()) + } case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } default: in.SkipRecursive() } @@ -182,7 +201,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling1(out *jwriter.Writer, in UpdateSessionInternalClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api1(out *jwriter.Writer, in UpdateSessionInternalClientMessage) { out.RawByte('{') first := true _ = first @@ -223,27 +242,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling1(out *jwr // MarshalJSON supports json.Marshaler interface func (v UpdateSessionInternalClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling1(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api1(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v UpdateSessionInternalClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling1(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api1(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *UpdateSessionInternalClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling1(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api1(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *UpdateSessionInternalClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling1(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api1(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlexer.Lexer, out *TransientDataServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api2(in *jlexer.Lexer, out *TransientDataServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -256,16 +275,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "key": - out.Key = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Key = string(in.String()) + } case "oldvalue": if m, ok := out.OldValue.(easyjson.Unmarshaler); ok { m.UnmarshalEasyJSON(in) @@ -288,7 +310,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlex } else { in.Delim('{') if !in.IsDelim('}') { - out.Data = make(map[string]interface{}) + out.Data = make(StringMap) } else { out.Data = nil } @@ -318,7 +340,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlex in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling2(out *jwriter.Writer, in TransientDataServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api2(out *jwriter.Writer, in TransientDataServerMessage) { out.RawByte('{') first := true _ = first @@ -385,27 +407,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling2(out *jwr // MarshalJSON supports json.Marshaler interface func (v TransientDataServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling2(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api2(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v TransientDataServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling2(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api2(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *TransientDataServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling2(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api2(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *TransientDataServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling2(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api2(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling3(in *jlexer.Lexer, out *TransientDataClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api3(in *jlexer.Lexer, out *TransientDataClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -418,22 +440,33 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling3(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "key": - out.Key = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Key = string(in.String()) + } case "value": - if data := in.Raw(); in.Ok() { - in.AddError((out.Value).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Value).UnmarshalJSON(data)) + } } case "ttl": - out.TTL = time.Duration(in.Int64()) + if in.IsNull() { + in.Skip() + } else { + out.TTL = time.Duration(in.Int64()) + } default: in.SkipRecursive() } @@ -444,7 +477,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling3(in *jlex in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling3(out *jwriter.Writer, in TransientDataClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api3(out *jwriter.Writer, in TransientDataClientMessage) { out.RawByte('{') first := true _ = first @@ -474,27 +507,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling3(out *jwr // MarshalJSON supports json.Marshaler interface func (v TransientDataClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling3(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api3(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v TransientDataClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling3(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api3(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *TransientDataClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling3(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api3(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *TransientDataClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling3(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api3(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlexer.Lexer, out *ServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api4(in *jlexer.Lexer, out *ServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -507,16 +540,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "id": - out.Id = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Id = string(in.String()) + } case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "error": if in.IsNull() { in.Skip() @@ -525,7 +561,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.Error == nil { out.Error = new(Error) } - (*out.Error).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Error).UnmarshalEasyJSON(in) + } } case "welcome": if in.IsNull() { @@ -535,7 +575,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.Welcome == nil { out.Welcome = new(WelcomeServerMessage) } - (*out.Welcome).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Welcome).UnmarshalEasyJSON(in) + } } case "hello": if in.IsNull() { @@ -545,7 +589,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.Hello == nil { out.Hello = new(HelloServerMessage) } - (*out.Hello).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Hello).UnmarshalEasyJSON(in) + } } case "bye": if in.IsNull() { @@ -555,7 +603,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.Bye == nil { out.Bye = new(ByeServerMessage) } - (*out.Bye).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Bye).UnmarshalEasyJSON(in) + } } case "room": if in.IsNull() { @@ -565,7 +617,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.Room == nil { out.Room = new(RoomServerMessage) } - (*out.Room).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Room).UnmarshalEasyJSON(in) + } } case "message": if in.IsNull() { @@ -575,7 +631,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.Message == nil { out.Message = new(MessageServerMessage) } - (*out.Message).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Message).UnmarshalEasyJSON(in) + } } case "control": if in.IsNull() { @@ -585,7 +645,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.Control == nil { out.Control = new(ControlServerMessage) } - (*out.Control).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Control).UnmarshalEasyJSON(in) + } } case "event": if in.IsNull() { @@ -595,7 +659,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.Event == nil { out.Event = new(EventServerMessage) } - (*out.Event).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Event).UnmarshalEasyJSON(in) + } } case "transient": if in.IsNull() { @@ -605,7 +673,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.TransientData == nil { out.TransientData = new(TransientDataServerMessage) } - (*out.TransientData).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.TransientData).UnmarshalEasyJSON(in) + } } case "internal": if in.IsNull() { @@ -615,7 +687,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.Internal == nil { out.Internal = new(InternalServerMessage) } - (*out.Internal).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Internal).UnmarshalEasyJSON(in) + } } case "dialout": if in.IsNull() { @@ -625,7 +701,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex if out.Dialout == nil { out.Dialout = new(DialoutInternalClientMessage) } - (*out.Dialout).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Dialout).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -637,7 +717,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling4(out *jwriter.Writer, in ServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api4(out *jwriter.Writer, in ServerMessage) { out.RawByte('{') first := true _ = first @@ -718,27 +798,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling4(out *jwr // MarshalJSON supports json.Marshaler interface func (v ServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling4(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api4(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v ServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling4(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api4(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *ServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api4(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *ServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling4(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api4(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling5(in *jlexer.Lexer, out *RoomServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api5(in *jlexer.Lexer, out *RoomServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -751,17 +831,34 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling5(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } case "properties": - if data := in.Raw(); in.Ok() { - in.AddError((out.Properties).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Properties).UnmarshalJSON(data)) + } + } + case "bandwidth": + if in.IsNull() { + in.Skip() + out.Bandwidth = nil + } else { + if out.Bandwidth == nil { + out.Bandwidth = new(RoomBandwidth) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Bandwidth).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -773,7 +870,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling5(in *jlex in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling5(out *jwriter.Writer, in RoomServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api5(out *jwriter.Writer, in RoomServerMessage) { out.RawByte('{') first := true _ = first @@ -787,33 +884,38 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling5(out *jwr out.RawString(prefix) out.Raw((in.Properties).MarshalJSON()) } + if in.Bandwidth != nil { + const prefix string = ",\"bandwidth\":" + out.RawString(prefix) + (*in.Bandwidth).MarshalEasyJSON(out) + } out.RawByte('}') } // MarshalJSON supports json.Marshaler interface func (v RoomServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling5(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api5(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RoomServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling5(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api5(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RoomServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling5(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api5(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RoomServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling5(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api5(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling6(in *jlexer.Lexer, out *RoomFlagsServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api6(in *jlexer.Lexer, out *RoomFlagsServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -826,18 +928,25 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling6(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = PublicSessionId(in.String()) + } case "flags": - out.Flags = uint32(in.Uint32()) + if in.IsNull() { + in.Skip() + } else { + out.Flags = uint32(in.Uint32()) + } default: in.SkipRecursive() } @@ -848,7 +957,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling6(in *jlex in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling6(out *jwriter.Writer, in RoomFlagsServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api6(out *jwriter.Writer, in RoomFlagsServerMessage) { out.RawByte('{') first := true _ = first @@ -873,27 +982,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling6(out *jwr // MarshalJSON supports json.Marshaler interface func (v RoomFlagsServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling6(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api6(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RoomFlagsServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling6(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api6(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RoomFlagsServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling6(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api6(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RoomFlagsServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling6(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api6(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling7(in *jlexer.Lexer, out *RoomFederationMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api7(in *jlexer.Lexer, out *RoomFederationMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -906,20 +1015,31 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling7(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "signaling": - out.SignalingUrl = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SignalingUrl = string(in.String()) + } case "url": - out.NextcloudUrl = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.NextcloudUrl = string(in.String()) + } case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } case "token": - out.Token = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Token = string(in.String()) + } default: in.SkipRecursive() } @@ -930,7 +1050,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling7(in *jlex in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling7(out *jwriter.Writer, in RoomFederationMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api7(out *jwriter.Writer, in RoomFederationMessage) { out.RawByte('{') first := true _ = first @@ -960,27 +1080,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling7(out *jwr // MarshalJSON supports json.Marshaler interface func (v RoomFederationMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling7(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api7(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RoomFederationMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling7(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api7(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RoomFederationMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling7(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api7(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RoomFederationMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling7(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api7(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlexer.Lexer, out *RoomEventServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api8(in *jlexer.Lexer, out *RoomEventServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -993,21 +1113,28 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } case "properties": - if data := in.Raw(); in.Ok() { - in.AddError((out.Properties).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Properties).UnmarshalJSON(data)) + } } case "incall": - if data := in.Raw(); in.Ok() { - in.AddError((out.InCall).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.InCall).UnmarshalJSON(data)) + } } case "changed": if in.IsNull() { @@ -1017,21 +1144,21 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlex in.Delim('[') if out.Changed == nil { if !in.IsDelim(']') { - out.Changed = make([]map[string]interface{}, 0, 8) + out.Changed = make([]StringMap, 0, 8) } else { - out.Changed = []map[string]interface{}{} + out.Changed = []StringMap{} } } else { out.Changed = (out.Changed)[:0] } for !in.IsDelim(']') { - var v6 map[string]interface{} + var v6 StringMap if in.IsNull() { in.Skip() } else { in.Delim('{') if !in.IsDelim('}') { - v6 = make(map[string]interface{}) + v6 = make(StringMap) } else { v6 = nil } @@ -1064,21 +1191,21 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlex in.Delim('[') if out.Users == nil { if !in.IsDelim(']') { - out.Users = make([]map[string]interface{}, 0, 8) + out.Users = make([]StringMap, 0, 8) } else { - out.Users = []map[string]interface{}{} + out.Users = []StringMap{} } } else { out.Users = (out.Users)[:0] } for !in.IsDelim(']') { - var v8 map[string]interface{} + var v8 StringMap if in.IsNull() { in.Skip() } else { in.Delim('{') if !in.IsDelim('}') { - v8 = make(map[string]interface{}) + v8 = make(StringMap) } else { v8 = nil } @@ -1104,7 +1231,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlex in.Delim(']') } case "all": - out.All = bool(in.Bool()) + if in.IsNull() { + in.Skip() + } else { + out.All = bool(in.Bool()) + } default: in.SkipRecursive() } @@ -1115,7 +1246,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlex in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling8(out *jwriter.Writer, in RoomEventServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api8(out *jwriter.Writer, in RoomEventServerMessage) { out.RawByte('{') first := true _ = first @@ -1217,27 +1348,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling8(out *jwr // MarshalJSON supports json.Marshaler interface func (v RoomEventServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling8(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api8(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RoomEventServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling8(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api8(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RoomEventServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling8(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api8(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RoomEventServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling8(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api8(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling9(in *jlexer.Lexer, out *RoomEventMessageDataChat) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api9(in *jlexer.Lexer, out *RoomEventMessageDataChat) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1250,45 +1381,49 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling9(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { + case "refresh": + if in.IsNull() { + in.Skip() + } else { + out.Refresh = bool(in.Bool()) + } case "comment": if in.IsNull() { in.Skip() - out.Comment = nil } else { - if out.Comment == nil { - out.Comment = new(ChatComment) + if data := in.Raw(); in.Ok() { + in.AddError((out.Comment).UnmarshalJSON(data)) } - if in.IsNull() { - in.Skip() - } else { - in.Delim('{') - if !in.IsDelim('}') { - *out.Comment = make(ChatComment) + } + case "comments": + if in.IsNull() { + in.Skip() + out.Comments = nil + } else { + in.Delim('[') + if out.Comments == nil { + if !in.IsDelim(']') { + out.Comments = make([]json.RawMessage, 0, 2) } else { - *out.Comment = nil + out.Comments = []json.RawMessage{} } - for !in.IsDelim('}') { - key := string(in.String()) - in.WantColon() - var v16 interface{} - if m, ok := v16.(easyjson.Unmarshaler); ok { - m.UnmarshalEasyJSON(in) - } else if m, ok := v16.(json.Unmarshaler); ok { - _ = m.UnmarshalJSON(in.Raw()) - } else { - v16 = in.Interface() - } - (*out.Comment)[key] = v16 - in.WantComma() - } - in.Delim('}') + } else { + out.Comments = (out.Comments)[:0] } + for !in.IsDelim(']') { + var v16 json.RawMessage + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((v16).UnmarshalJSON(data)) + } + } + out.Comments = append(out.Comments, v16) + in.WantComma() + } + in.Delim(']') } default: in.SkipRecursive() @@ -1300,36 +1435,43 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling9(in *jlex in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling9(out *jwriter.Writer, in RoomEventMessageDataChat) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api9(out *jwriter.Writer, in RoomEventMessageDataChat) { out.RawByte('{') first := true _ = first - if in.Comment != nil { - const prefix string = ",\"comment\":" + if in.Refresh { + const prefix string = ",\"refresh\":" first = false out.RawString(prefix[1:]) - if *in.Comment == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { - out.RawString(`null`) + out.Bool(bool(in.Refresh)) + } + if len(in.Comment) != 0 { + const prefix string = ",\"comment\":" + if first { + first = false + out.RawString(prefix[1:]) } else { - out.RawByte('{') - v17First := true - for v17Name, v17Value := range *in.Comment { - if v17First { - v17First = false - } else { + out.RawString(prefix) + } + out.Raw((in.Comment).MarshalJSON()) + } + if len(in.Comments) != 0 { + const prefix string = ",\"comments\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + { + out.RawByte('[') + for v17, v18 := range in.Comments { + if v17 > 0 { out.RawByte(',') } - out.String(string(v17Name)) - out.RawByte(':') - if m, ok := v17Value.(easyjson.Marshaler); ok { - m.MarshalEasyJSON(out) - } else if m, ok := v17Value.(json.Marshaler); ok { - out.Raw(m.MarshalJSON()) - } else { - out.Raw(json.Marshal(v17Value)) - } + out.Raw((v18).MarshalJSON()) } - out.RawByte('}') + out.RawByte(']') } } out.RawByte('}') @@ -1338,27 +1480,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling9(out *jwr // MarshalJSON supports json.Marshaler interface func (v RoomEventMessageDataChat) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling9(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api9(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RoomEventMessageDataChat) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling9(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api9(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RoomEventMessageDataChat) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling9(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api9(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RoomEventMessageDataChat) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling9(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api9(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling10(in *jlexer.Lexer, out *RoomEventMessageData) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api10(in *jlexer.Lexer, out *RoomEventMessageData) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1371,14 +1513,13 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling10(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "chat": if in.IsNull() { in.Skip() @@ -1387,7 +1528,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling10(in *jle if out.Chat == nil { out.Chat = new(RoomEventMessageDataChat) } - (*out.Chat).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Chat).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -1399,7 +1544,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling10(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling10(out *jwriter.Writer, in RoomEventMessageData) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api10(out *jwriter.Writer, in RoomEventMessageData) { out.RawByte('{') first := true _ = first @@ -1419,27 +1564,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling10(out *jw // MarshalJSON supports json.Marshaler interface func (v RoomEventMessageData) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling10(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api10(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RoomEventMessageData) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling10(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api10(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RoomEventMessageData) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling10(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api10(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RoomEventMessageData) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling10(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api10(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling11(in *jlexer.Lexer, out *RoomEventMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api11(in *jlexer.Lexer, out *RoomEventMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1452,17 +1597,20 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling11(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } case "data": - if data := in.Raw(); in.Ok() { - in.AddError((out.Data).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Data).UnmarshalJSON(data)) + } } default: in.SkipRecursive() @@ -1474,7 +1622,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling11(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling11(out *jwriter.Writer, in RoomEventMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api11(out *jwriter.Writer, in RoomEventMessage) { out.RawByte('{') first := true _ = first @@ -1494,27 +1642,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling11(out *jw // MarshalJSON supports json.Marshaler interface func (v RoomEventMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling11(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api11(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RoomEventMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling11(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api11(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RoomEventMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling11(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api11(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RoomEventMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling11(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api11(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling12(in *jlexer.Lexer, out *RoomErrorDetails) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api12(in *jlexer.Lexer, out *RoomErrorDetails) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1527,11 +1675,6 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling12(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "room": if in.IsNull() { @@ -1541,7 +1684,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling12(in *jle if out.Room == nil { out.Room = new(RoomServerMessage) } - (*out.Room).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Room).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -1553,7 +1700,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling12(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling12(out *jwriter.Writer, in RoomErrorDetails) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api12(out *jwriter.Writer, in RoomErrorDetails) { out.RawByte('{') first := true _ = first @@ -1572,27 +1719,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling12(out *jw // MarshalJSON supports json.Marshaler interface func (v RoomErrorDetails) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling12(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api12(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RoomErrorDetails) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling12(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api12(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RoomErrorDetails) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling12(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api12(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RoomErrorDetails) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling12(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api12(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling13(in *jlexer.Lexer, out *RoomDisinviteEventServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api13(in *jlexer.Lexer, out *RoomDisinviteEventServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1605,23 +1752,34 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling13(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "reason": - out.Reason = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Reason = string(in.String()) + } case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } case "properties": - if data := in.Raw(); in.Ok() { - in.AddError((out.Properties).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Properties).UnmarshalJSON(data)) + } } case "incall": - if data := in.Raw(); in.Ok() { - in.AddError((out.InCall).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.InCall).UnmarshalJSON(data)) + } } case "changed": if in.IsNull() { @@ -1631,41 +1789,41 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling13(in *jle in.Delim('[') if out.Changed == nil { if !in.IsDelim(']') { - out.Changed = make([]map[string]interface{}, 0, 8) + out.Changed = make([]StringMap, 0, 8) } else { - out.Changed = []map[string]interface{}{} + out.Changed = []StringMap{} } } else { out.Changed = (out.Changed)[:0] } for !in.IsDelim(']') { - var v18 map[string]interface{} + var v19 StringMap if in.IsNull() { in.Skip() } else { in.Delim('{') if !in.IsDelim('}') { - v18 = make(map[string]interface{}) + v19 = make(StringMap) } else { - v18 = nil + v19 = nil } for !in.IsDelim('}') { key := string(in.String()) in.WantColon() - var v19 interface{} - if m, ok := v19.(easyjson.Unmarshaler); ok { + var v20 interface{} + if m, ok := v20.(easyjson.Unmarshaler); ok { m.UnmarshalEasyJSON(in) - } else if m, ok := v19.(json.Unmarshaler); ok { + } else if m, ok := v20.(json.Unmarshaler); ok { _ = m.UnmarshalJSON(in.Raw()) } else { - v19 = in.Interface() + v20 = in.Interface() } - (v18)[key] = v19 + (v19)[key] = v20 in.WantComma() } in.Delim('}') } - out.Changed = append(out.Changed, v18) + out.Changed = append(out.Changed, v19) in.WantComma() } in.Delim(']') @@ -1678,47 +1836,51 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling13(in *jle in.Delim('[') if out.Users == nil { if !in.IsDelim(']') { - out.Users = make([]map[string]interface{}, 0, 8) + out.Users = make([]StringMap, 0, 8) } else { - out.Users = []map[string]interface{}{} + out.Users = []StringMap{} } } else { out.Users = (out.Users)[:0] } for !in.IsDelim(']') { - var v20 map[string]interface{} + var v21 StringMap if in.IsNull() { in.Skip() } else { in.Delim('{') if !in.IsDelim('}') { - v20 = make(map[string]interface{}) + v21 = make(StringMap) } else { - v20 = nil + v21 = nil } for !in.IsDelim('}') { key := string(in.String()) in.WantColon() - var v21 interface{} - if m, ok := v21.(easyjson.Unmarshaler); ok { + var v22 interface{} + if m, ok := v22.(easyjson.Unmarshaler); ok { m.UnmarshalEasyJSON(in) - } else if m, ok := v21.(json.Unmarshaler); ok { + } else if m, ok := v22.(json.Unmarshaler); ok { _ = m.UnmarshalJSON(in.Raw()) } else { - v21 = in.Interface() + v22 = in.Interface() } - (v20)[key] = v21 + (v21)[key] = v22 in.WantComma() } in.Delim('}') } - out.Users = append(out.Users, v20) + out.Users = append(out.Users, v21) in.WantComma() } in.Delim(']') } case "all": - out.All = bool(in.Bool()) + if in.IsNull() { + in.Skip() + } else { + out.All = bool(in.Bool()) + } default: in.SkipRecursive() } @@ -1729,7 +1891,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling13(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling13(out *jwriter.Writer, in RoomDisinviteEventServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api13(out *jwriter.Writer, in RoomDisinviteEventServerMessage) { out.RawByte('{') first := true _ = first @@ -1758,29 +1920,29 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling13(out *jw out.RawString(prefix) { out.RawByte('[') - for v22, v23 := range in.Changed { - if v22 > 0 { + for v23, v24 := range in.Changed { + if v23 > 0 { out.RawByte(',') } - if v23 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { + if v24 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { out.RawString(`null`) } else { out.RawByte('{') - v24First := true - for v24Name, v24Value := range v23 { - if v24First { - v24First = false + v25First := true + for v25Name, v25Value := range v24 { + if v25First { + v25First = false } else { out.RawByte(',') } - out.String(string(v24Name)) + out.String(string(v25Name)) out.RawByte(':') - if m, ok := v24Value.(easyjson.Marshaler); ok { + if m, ok := v25Value.(easyjson.Marshaler); ok { m.MarshalEasyJSON(out) - } else if m, ok := v24Value.(json.Marshaler); ok { + } else if m, ok := v25Value.(json.Marshaler); ok { out.Raw(m.MarshalJSON()) } else { - out.Raw(json.Marshal(v24Value)) + out.Raw(json.Marshal(v25Value)) } } out.RawByte('}') @@ -1794,29 +1956,29 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling13(out *jw out.RawString(prefix) { out.RawByte('[') - for v25, v26 := range in.Users { - if v25 > 0 { + for v26, v27 := range in.Users { + if v26 > 0 { out.RawByte(',') } - if v26 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { + if v27 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { out.RawString(`null`) } else { out.RawByte('{') - v27First := true - for v27Name, v27Value := range v26 { - if v27First { - v27First = false + v28First := true + for v28Name, v28Value := range v27 { + if v28First { + v28First = false } else { out.RawByte(',') } - out.String(string(v27Name)) + out.String(string(v28Name)) out.RawByte(':') - if m, ok := v27Value.(easyjson.Marshaler); ok { + if m, ok := v28Value.(easyjson.Marshaler); ok { m.MarshalEasyJSON(out) - } else if m, ok := v27Value.(json.Marshaler); ok { + } else if m, ok := v28Value.(json.Marshaler); ok { out.Raw(m.MarshalJSON()) } else { - out.Raw(json.Marshal(v27Value)) + out.Raw(json.Marshal(v28Value)) } } out.RawByte('}') @@ -1836,27 +1998,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling13(out *jw // MarshalJSON supports json.Marshaler interface func (v RoomDisinviteEventServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling13(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api13(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RoomDisinviteEventServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling13(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api13(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RoomDisinviteEventServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling13(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api13(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RoomDisinviteEventServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling13(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api13(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling14(in *jlexer.Lexer, out *RoomClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api14(in *jlexer.Lexer, out *RoomClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1869,16 +2031,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling14(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = RoomSessionId(in.String()) + } case "federation": if in.IsNull() { in.Skip() @@ -1887,7 +2052,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling14(in *jle if out.Federation == nil { out.Federation = new(RoomFederationMessage) } - (*out.Federation).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Federation).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -1899,7 +2068,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling14(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling14(out *jwriter.Writer, in RoomClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api14(out *jwriter.Writer, in RoomClientMessage) { out.RawByte('{') first := true _ = first @@ -1924,27 +2093,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling14(out *jw // MarshalJSON supports json.Marshaler interface func (v RoomClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling14(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api14(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RoomClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling14(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api14(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RoomClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling14(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api14(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RoomClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling14(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api14(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling15(in *jlexer.Lexer, out *RemoveSessionInternalClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api15(in *jlexer.Lexer, out *RoomBandwidth) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1957,18 +2126,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling15(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { - case "userid": - out.UserId = string(in.String()) - case "sessionid": - out.SessionId = string(in.String()) - case "roomid": - out.RoomId = string(in.String()) + case "maxstreambitrate": + if in.IsNull() { + in.Skip() + } else { + out.MaxStreamBitrate = Bandwidth(in.Uint64()) + } + case "maxscreenbitrate": + if in.IsNull() { + in.Skip() + } else { + out.MaxScreenBitrate = Bandwidth(in.Uint64()) + } default: in.SkipRecursive() } @@ -1979,7 +2149,89 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling15(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling15(out *jwriter.Writer, in RemoveSessionInternalClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api15(out *jwriter.Writer, in RoomBandwidth) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"maxstreambitrate\":" + out.RawString(prefix[1:]) + out.Uint64(uint64(in.MaxStreamBitrate)) + } + { + const prefix string = ",\"maxscreenbitrate\":" + out.RawString(prefix) + out.Uint64(uint64(in.MaxScreenBitrate)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v RoomBandwidth) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api15(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v RoomBandwidth) MarshalEasyJSON(w *jwriter.Writer) { + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api15(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *RoomBandwidth) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api15(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *RoomBandwidth) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api15(l, v) +} +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api16(in *jlexer.Lexer, out *RemoveSessionInternalClientMessage) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "userid": + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } + case "sessionid": + if in.IsNull() { + in.Skip() + } else { + out.SessionId = PublicSessionId(in.String()) + } + case "roomid": + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api16(out *jwriter.Writer, in RemoveSessionInternalClientMessage) { out.RawByte('{') first := true _ = first @@ -2010,27 +2262,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling15(out *jw // MarshalJSON supports json.Marshaler interface func (v RemoveSessionInternalClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling15(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api16(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v RemoveSessionInternalClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling15(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api16(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *RemoveSessionInternalClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling15(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api16(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *RemoveSessionInternalClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling15(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api16(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling16(in *jlexer.Lexer, out *MessageServerMessageSender) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api17(in *jlexer.Lexer, out *MessageServerMessageSender) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -2043,18 +2295,25 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling16(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = PublicSessionId(in.String()) + } case "userid": - out.UserId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } default: in.SkipRecursive() } @@ -2065,7 +2324,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling16(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling16(out *jwriter.Writer, in MessageServerMessageSender) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api17(out *jwriter.Writer, in MessageServerMessageSender) { out.RawByte('{') first := true _ = first @@ -2090,27 +2349,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling16(out *jw // MarshalJSON supports json.Marshaler interface func (v MessageServerMessageSender) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling16(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api17(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v MessageServerMessageSender) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling16(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api17(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *MessageServerMessageSender) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling16(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api17(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *MessageServerMessageSender) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling16(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api17(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling17(in *jlexer.Lexer, out *MessageServerMessageDataChat) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api18(in *jlexer.Lexer, out *MessageServerMessageData) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -2123,89 +2382,12 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling17(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "refresh": - out.Refresh = bool(in.Bool()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling17(out *jwriter.Writer, in MessageServerMessageDataChat) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"refresh\":" - out.RawString(prefix[1:]) - out.Bool(bool(in.Refresh)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v MessageServerMessageDataChat) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling17(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v MessageServerMessageDataChat) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling17(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *MessageServerMessageDataChat) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling17(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *MessageServerMessageDataChat) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling17(l, v) -} -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling18(in *jlexer.Lexer, out *MessageServerMessageData) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) - case "chat": if in.IsNull() { in.Skip() - out.Chat = nil } else { - if out.Chat == nil { - out.Chat = new(MessageServerMessageDataChat) - } - (*out.Chat).UnmarshalEasyJSON(in) + out.Type = string(in.String()) } default: in.SkipRecursive() @@ -2217,7 +2399,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling18(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling18(out *jwriter.Writer, in MessageServerMessageData) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api18(out *jwriter.Writer, in MessageServerMessageData) { out.RawByte('{') first := true _ = first @@ -2226,38 +2408,33 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling18(out *jw out.RawString(prefix[1:]) out.String(string(in.Type)) } - if in.Chat != nil { - const prefix string = ",\"chat\":" - out.RawString(prefix) - (*in.Chat).MarshalEasyJSON(out) - } out.RawByte('}') } // MarshalJSON supports json.Marshaler interface func (v MessageServerMessageData) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling18(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api18(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v MessageServerMessageData) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling18(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api18(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *MessageServerMessageData) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling18(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api18(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *MessageServerMessageData) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling18(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api18(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling19(in *jlexer.Lexer, out *MessageServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api19(in *jlexer.Lexer, out *MessageServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -2270,11 +2447,6 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling19(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "sender": if in.IsNull() { @@ -2284,7 +2456,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling19(in *jle if out.Sender == nil { out.Sender = new(MessageServerMessageSender) } - (*out.Sender).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Sender).UnmarshalEasyJSON(in) + } } case "recipient": if in.IsNull() { @@ -2294,11 +2470,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling19(in *jle if out.Recipient == nil { out.Recipient = new(MessageClientMessageRecipient) } - (*out.Recipient).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Recipient).UnmarshalEasyJSON(in) + } } case "data": - if data := in.Raw(); in.Ok() { - in.AddError((out.Data).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Data).UnmarshalJSON(data)) + } } default: in.SkipRecursive() @@ -2310,7 +2494,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling19(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling19(out *jwriter.Writer, in MessageServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api19(out *jwriter.Writer, in MessageServerMessage) { out.RawByte('{') first := true _ = first @@ -2339,27 +2523,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling19(out *jw // MarshalJSON supports json.Marshaler interface func (v MessageServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling19(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api19(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v MessageServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling19(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api19(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *MessageServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling19(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api19(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *MessageServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling19(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api19(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling20(in *jlexer.Lexer, out *MessageClientMessageRecipient) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api20(in *jlexer.Lexer, out *MessageClientMessageRecipient) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -2372,18 +2556,25 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling20(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = PublicSessionId(in.String()) + } case "userid": - out.UserId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } default: in.SkipRecursive() } @@ -2394,7 +2585,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling20(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling20(out *jwriter.Writer, in MessageClientMessageRecipient) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api20(out *jwriter.Writer, in MessageClientMessageRecipient) { out.RawByte('{') first := true _ = first @@ -2419,27 +2610,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling20(out *jw // MarshalJSON supports json.Marshaler interface func (v MessageClientMessageRecipient) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling20(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api20(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v MessageClientMessageRecipient) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling20(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api20(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *MessageClientMessageRecipient) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling20(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api20(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *MessageClientMessageRecipient) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling20(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api20(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling21(in *jlexer.Lexer, out *MessageClientMessageData) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api21(in *jlexer.Lexer, out *MessageClientMessageData) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -2452,50 +2643,77 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling21(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "sid": - out.Sid = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Sid = string(in.String()) + } case "roomType": - out.RoomType = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomType = string(in.String()) + } case "payload": if in.IsNull() { in.Skip() } else { in.Delim('{') - out.Payload = make(map[string]interface{}) + out.Payload = make(StringMap) for !in.IsDelim('}') { key := string(in.String()) in.WantColon() - var v28 interface{} - if m, ok := v28.(easyjson.Unmarshaler); ok { + var v29 interface{} + if m, ok := v29.(easyjson.Unmarshaler); ok { m.UnmarshalEasyJSON(in) - } else if m, ok := v28.(json.Unmarshaler); ok { + } else if m, ok := v29.(json.Unmarshaler); ok { _ = m.UnmarshalJSON(in.Raw()) } else { - v28 = in.Interface() + v29 = in.Interface() } - (out.Payload)[key] = v28 + (out.Payload)[key] = v29 in.WantComma() } in.Delim('}') } case "bitrate": - out.Bitrate = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.Bitrate = Bandwidth(in.Uint64()) + } case "audiocodec": - out.AudioCodec = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.AudioCodec = string(in.String()) + } case "videocodec": - out.VideoCodec = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.VideoCodec = string(in.String()) + } case "vp9profile": - out.VP9Profile = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.VP9Profile = string(in.String()) + } case "h264profile": - out.H264Profile = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.H264Profile = string(in.String()) + } default: in.SkipRecursive() } @@ -2506,7 +2724,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling21(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling21(out *jwriter.Writer, in MessageClientMessageData) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api21(out *jwriter.Writer, in MessageClientMessageData) { out.RawByte('{') first := true _ = first @@ -2532,21 +2750,21 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling21(out *jw out.RawString(`null`) } else { out.RawByte('{') - v29First := true - for v29Name, v29Value := range in.Payload { - if v29First { - v29First = false + v30First := true + for v30Name, v30Value := range in.Payload { + if v30First { + v30First = false } else { out.RawByte(',') } - out.String(string(v29Name)) + out.String(string(v30Name)) out.RawByte(':') - if m, ok := v29Value.(easyjson.Marshaler); ok { + if m, ok := v30Value.(easyjson.Marshaler); ok { m.MarshalEasyJSON(out) - } else if m, ok := v29Value.(json.Marshaler); ok { + } else if m, ok := v30Value.(json.Marshaler); ok { out.Raw(m.MarshalJSON()) } else { - out.Raw(json.Marshal(v29Value)) + out.Raw(json.Marshal(v30Value)) } } out.RawByte('}') @@ -2555,7 +2773,7 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling21(out *jw if in.Bitrate != 0 { const prefix string = ",\"bitrate\":" out.RawString(prefix) - out.Int(int(in.Bitrate)) + out.Uint64(uint64(in.Bitrate)) } if in.AudioCodec != "" { const prefix string = ",\"audiocodec\":" @@ -2583,27 +2801,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling21(out *jw // MarshalJSON supports json.Marshaler interface func (v MessageClientMessageData) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling21(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api21(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v MessageClientMessageData) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling21(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api21(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *MessageClientMessageData) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling21(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api21(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *MessageClientMessageData) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling21(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api21(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling22(in *jlexer.Lexer, out *MessageClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api22(in *jlexer.Lexer, out *MessageClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -2616,17 +2834,20 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling22(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "recipient": - (out.Recipient).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (out.Recipient).UnmarshalEasyJSON(in) + } case "data": - if data := in.Raw(); in.Ok() { - in.AddError((out.Data).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Data).UnmarshalJSON(data)) + } } default: in.SkipRecursive() @@ -2638,7 +2859,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling22(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling22(out *jwriter.Writer, in MessageClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api22(out *jwriter.Writer, in MessageClientMessage) { out.RawByte('{') first := true _ = first @@ -2658,27 +2879,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling22(out *jw // MarshalJSON supports json.Marshaler interface func (v MessageClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling22(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api22(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v MessageClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling22(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api22(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *MessageClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling22(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api22(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *MessageClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling22(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api22(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling23(in *jlexer.Lexer, out *InternalServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api23(in *jlexer.Lexer, out *InternalServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -2691,14 +2912,13 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling23(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "dialout": if in.IsNull() { in.Skip() @@ -2707,7 +2927,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling23(in *jle if out.Dialout == nil { out.Dialout = new(InternalServerDialoutRequest) } - (*out.Dialout).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Dialout).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -2719,7 +2943,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling23(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling23(out *jwriter.Writer, in InternalServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api23(out *jwriter.Writer, in InternalServerMessage) { out.RawByte('{') first := true _ = first @@ -2739,27 +2963,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling23(out *jw // MarshalJSON supports json.Marshaler interface func (v InternalServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling23(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api23(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v InternalServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling23(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api23(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *InternalServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling23(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api23(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *InternalServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling23(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api23(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling24(in *jlexer.Lexer, out *InternalServerDialoutRequest) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api24(in *jlexer.Lexer, out *InternalServerDialoutRequestContents) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -2772,25 +2996,20 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling24(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { - case "roomid": - out.RoomId = string(in.String()) - case "backend": - out.Backend = string(in.String()) - case "request": + case "number": if in.IsNull() { in.Skip() - out.Request = nil } else { - if out.Request == nil { - out.Request = new(BackendRoomDialoutRequest) + out.Number = string(in.String()) + } + case "options": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Options).UnmarshalJSON(data)) } - (*out.Request).UnmarshalEasyJSON(in) } default: in.SkipRecursive() @@ -2802,7 +3021,97 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling24(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling24(out *jwriter.Writer, in InternalServerDialoutRequest) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api24(out *jwriter.Writer, in InternalServerDialoutRequestContents) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"number\":" + out.RawString(prefix[1:]) + out.String(string(in.Number)) + } + if len(in.Options) != 0 { + const prefix string = ",\"options\":" + out.RawString(prefix) + out.Raw((in.Options).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v InternalServerDialoutRequestContents) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api24(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v InternalServerDialoutRequestContents) MarshalEasyJSON(w *jwriter.Writer) { + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api24(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *InternalServerDialoutRequestContents) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api24(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *InternalServerDialoutRequestContents) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api24(l, v) +} +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api25(in *jlexer.Lexer, out *InternalServerDialoutRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "roomid": + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } + case "backend": + if in.IsNull() { + in.Skip() + } else { + out.Backend = string(in.String()) + } + case "request": + if in.IsNull() { + in.Skip() + out.Request = nil + } else { + if out.Request == nil { + out.Request = new(InternalServerDialoutRequestContents) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Request).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api25(out *jwriter.Writer, in InternalServerDialoutRequest) { out.RawByte('{') first := true _ = first @@ -2831,27 +3140,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling24(out *jw // MarshalJSON supports json.Marshaler interface func (v InternalServerDialoutRequest) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling24(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api25(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v InternalServerDialoutRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling24(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api25(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *InternalServerDialoutRequest) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling24(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api25(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *InternalServerDialoutRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling24(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api25(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling25(in *jlexer.Lexer, out *InternalClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api26(in *jlexer.Lexer, out *InternalClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -2864,14 +3173,13 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling25(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "addsession": if in.IsNull() { in.Skip() @@ -2880,7 +3188,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling25(in *jle if out.AddSession == nil { out.AddSession = new(AddSessionInternalClientMessage) } - (*out.AddSession).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.AddSession).UnmarshalEasyJSON(in) + } } case "updatesession": if in.IsNull() { @@ -2890,7 +3202,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling25(in *jle if out.UpdateSession == nil { out.UpdateSession = new(UpdateSessionInternalClientMessage) } - (*out.UpdateSession).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.UpdateSession).UnmarshalEasyJSON(in) + } } case "removesession": if in.IsNull() { @@ -2900,7 +3216,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling25(in *jle if out.RemoveSession == nil { out.RemoveSession = new(RemoveSessionInternalClientMessage) } - (*out.RemoveSession).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.RemoveSession).UnmarshalEasyJSON(in) + } } case "incall": if in.IsNull() { @@ -2910,7 +3230,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling25(in *jle if out.InCall == nil { out.InCall = new(InCallInternalClientMessage) } - (*out.InCall).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.InCall).UnmarshalEasyJSON(in) + } } case "dialout": if in.IsNull() { @@ -2920,7 +3244,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling25(in *jle if out.Dialout == nil { out.Dialout = new(DialoutInternalClientMessage) } - (*out.Dialout).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Dialout).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -2932,7 +3260,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling25(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling25(out *jwriter.Writer, in InternalClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api26(out *jwriter.Writer, in InternalClientMessage) { out.RawByte('{') first := true _ = first @@ -2972,27 +3300,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling25(out *jw // MarshalJSON supports json.Marshaler interface func (v InternalClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling25(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api26(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v InternalClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling25(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api26(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *InternalClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling25(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api26(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *InternalClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling25(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api26(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling26(in *jlexer.Lexer, out *InCallInternalClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api27(in *jlexer.Lexer, out *InCallInternalClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -3005,14 +3333,13 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling26(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "incall": - out.InCall = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.InCall = int(in.Int()) + } default: in.SkipRecursive() } @@ -3023,7 +3350,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling26(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling26(out *jwriter.Writer, in InCallInternalClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api27(out *jwriter.Writer, in InCallInternalClientMessage) { out.RawByte('{') first := true _ = first @@ -3038,27 +3365,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling26(out *jw // MarshalJSON supports json.Marshaler interface func (v InCallInternalClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling26(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api27(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v InCallInternalClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling26(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api27(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *InCallInternalClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling26(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api27(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *InCallInternalClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling26(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api27(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling27(in *jlexer.Lexer, out *HelloV2TokenClaims) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api28(in *jlexer.Lexer, out *HelloV2TokenClaims) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -3071,23 +3398,34 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling27(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "userdata": - if data := in.Raw(); in.Ok() { - in.AddError((out.UserData).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.UserData).UnmarshalJSON(data)) + } } case "iss": - out.Issuer = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Issuer = string(in.String()) + } case "sub": - out.Subject = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Subject = string(in.String()) + } case "aud": - if data := in.Raw(); in.Ok() { - in.AddError((out.Audience).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Audience).UnmarshalJSON(data)) + } } case "exp": if in.IsNull() { @@ -3097,8 +3435,12 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling27(in *jle if out.ExpiresAt == nil { out.ExpiresAt = new(_v5.NumericDate) } - if data := in.Raw(); in.Ok() { - in.AddError((*out.ExpiresAt).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.ExpiresAt).UnmarshalJSON(data)) + } } } case "nbf": @@ -3109,8 +3451,12 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling27(in *jle if out.NotBefore == nil { out.NotBefore = new(_v5.NumericDate) } - if data := in.Raw(); in.Ok() { - in.AddError((*out.NotBefore).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.NotBefore).UnmarshalJSON(data)) + } } } case "iat": @@ -3121,12 +3467,20 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling27(in *jle if out.IssuedAt == nil { out.IssuedAt = new(_v5.NumericDate) } - if data := in.Raw(); in.Ok() { - in.AddError((*out.IssuedAt).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.IssuedAt).UnmarshalJSON(data)) + } } } case "jti": - out.ID = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ID = string(in.String()) + } default: in.SkipRecursive() } @@ -3137,7 +3491,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling27(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling27(out *jwriter.Writer, in HelloV2TokenClaims) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api28(out *jwriter.Writer, in HelloV2TokenClaims) { out.RawByte('{') first := true _ = first @@ -3223,27 +3577,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling27(out *jw // MarshalJSON supports json.Marshaler interface func (v HelloV2TokenClaims) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling27(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api28(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v HelloV2TokenClaims) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling27(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api28(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *HelloV2TokenClaims) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling27(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api28(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *HelloV2TokenClaims) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling27(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api28(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling28(in *jlexer.Lexer, out *HelloV2AuthParams) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api29(in *jlexer.Lexer, out *HelloV2AuthParams) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -3256,14 +3610,13 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling28(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "token": - out.Token = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Token = string(in.String()) + } default: in.SkipRecursive() } @@ -3274,7 +3627,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling28(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling28(out *jwriter.Writer, in HelloV2AuthParams) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api29(out *jwriter.Writer, in HelloV2AuthParams) { out.RawByte('{') first := true _ = first @@ -3289,27 +3642,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling28(out *jw // MarshalJSON supports json.Marshaler interface func (v HelloV2AuthParams) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling28(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api29(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v HelloV2AuthParams) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling28(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api29(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *HelloV2AuthParams) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling28(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api29(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *HelloV2AuthParams) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling28(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api29(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling29(in *jlexer.Lexer, out *HelloServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api30(in *jlexer.Lexer, out *HelloServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -3322,20 +3675,31 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling29(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "version": - out.Version = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = PublicSessionId(in.String()) + } case "resumeid": - out.ResumeId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ResumeId = PrivateSessionId(in.String()) + } case "userid": - out.UserId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } case "server": if in.IsNull() { in.Skip() @@ -3344,7 +3708,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling29(in *jle if out.Server == nil { out.Server = new(WelcomeServerMessage) } - (*out.Server).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Server).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -3356,7 +3724,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling29(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling29(out *jwriter.Writer, in HelloServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api30(out *jwriter.Writer, in HelloServerMessage) { out.RawByte('{') first := true _ = first @@ -3391,27 +3759,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling29(out *jw // MarshalJSON supports json.Marshaler interface func (v HelloServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling29(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api30(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v HelloServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling29(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api30(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *HelloServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling29(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api30(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *HelloServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling29(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api30(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling30(in *jlexer.Lexer, out *HelloClientMessageAuth) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api31(in *jlexer.Lexer, out *HelloClientMessageAuth) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -3424,20 +3792,27 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling30(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = ClientType(in.String()) + } case "params": - if data := in.Raw(); in.Ok() { - in.AddError((out.Params).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Params).UnmarshalJSON(data)) + } } case "url": - out.Url = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Url = string(in.String()) + } default: in.SkipRecursive() } @@ -3448,7 +3823,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling30(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling30(out *jwriter.Writer, in HelloClientMessageAuth) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api31(out *jwriter.Writer, in HelloClientMessageAuth) { out.RawByte('{') first := true _ = first @@ -3479,27 +3854,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling30(out *jw // MarshalJSON supports json.Marshaler interface func (v HelloClientMessageAuth) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling30(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api31(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v HelloClientMessageAuth) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling30(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api31(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *HelloClientMessageAuth) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling30(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api31(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *HelloClientMessageAuth) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling30(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api31(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling31(in *jlexer.Lexer, out *HelloClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api32(in *jlexer.Lexer, out *HelloClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -3512,16 +3887,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling31(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "version": - out.Version = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } case "resumeid": - out.ResumeId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ResumeId = PrivateSessionId(in.String()) + } case "features": if in.IsNull() { in.Skip() @@ -3538,9 +3916,13 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling31(in *jle out.Features = (out.Features)[:0] } for !in.IsDelim(']') { - var v30 string - v30 = string(in.String()) - out.Features = append(out.Features, v30) + var v31 string + if in.IsNull() { + in.Skip() + } else { + v31 = string(in.String()) + } + out.Features = append(out.Features, v31) in.WantComma() } in.Delim(']') @@ -3553,7 +3935,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling31(in *jle if out.Auth == nil { out.Auth = new(HelloClientMessageAuth) } - (*out.Auth).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Auth).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -3565,7 +3951,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling31(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling31(out *jwriter.Writer, in HelloClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api32(out *jwriter.Writer, in HelloClientMessage) { out.RawByte('{') first := true _ = first @@ -3584,11 +3970,11 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling31(out *jw out.RawString(prefix) { out.RawByte('[') - for v31, v32 := range in.Features { - if v31 > 0 { + for v32, v33 := range in.Features { + if v32 > 0 { out.RawByte(',') } - out.String(string(v32)) + out.String(string(v33)) } out.RawByte(']') } @@ -3604,27 +3990,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling31(out *jw // MarshalJSON supports json.Marshaler interface func (v HelloClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling31(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api32(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v HelloClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling31(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api32(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *HelloClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling31(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api32(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *HelloClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling31(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api32(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling32(in *jlexer.Lexer, out *FederationTokenClaims) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api33(in *jlexer.Lexer, out *FederationTokenClaims) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -3637,23 +4023,34 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling32(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "userdata": - if data := in.Raw(); in.Ok() { - in.AddError((out.UserData).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.UserData).UnmarshalJSON(data)) + } } case "iss": - out.Issuer = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Issuer = string(in.String()) + } case "sub": - out.Subject = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Subject = string(in.String()) + } case "aud": - if data := in.Raw(); in.Ok() { - in.AddError((out.Audience).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Audience).UnmarshalJSON(data)) + } } case "exp": if in.IsNull() { @@ -3663,8 +4060,12 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling32(in *jle if out.ExpiresAt == nil { out.ExpiresAt = new(_v5.NumericDate) } - if data := in.Raw(); in.Ok() { - in.AddError((*out.ExpiresAt).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.ExpiresAt).UnmarshalJSON(data)) + } } } case "nbf": @@ -3675,8 +4076,12 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling32(in *jle if out.NotBefore == nil { out.NotBefore = new(_v5.NumericDate) } - if data := in.Raw(); in.Ok() { - in.AddError((*out.NotBefore).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.NotBefore).UnmarshalJSON(data)) + } } } case "iat": @@ -3687,12 +4092,20 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling32(in *jle if out.IssuedAt == nil { out.IssuedAt = new(_v5.NumericDate) } - if data := in.Raw(); in.Ok() { - in.AddError((*out.IssuedAt).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.IssuedAt).UnmarshalJSON(data)) + } } } case "jti": - out.ID = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ID = string(in.String()) + } default: in.SkipRecursive() } @@ -3703,7 +4116,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling32(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling32(out *jwriter.Writer, in FederationTokenClaims) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api33(out *jwriter.Writer, in FederationTokenClaims) { out.RawByte('{') first := true _ = first @@ -3789,27 +4202,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling32(out *jw // MarshalJSON supports json.Marshaler interface func (v FederationTokenClaims) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling32(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api33(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v FederationTokenClaims) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling32(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api33(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *FederationTokenClaims) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling32(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api33(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *FederationTokenClaims) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling32(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api33(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling33(in *jlexer.Lexer, out *FederationAuthParams) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api34(in *jlexer.Lexer, out *FederationAuthParams) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -3822,14 +4235,13 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling33(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "token": - out.Token = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Token = string(in.String()) + } default: in.SkipRecursive() } @@ -3840,7 +4252,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling33(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling33(out *jwriter.Writer, in FederationAuthParams) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api34(out *jwriter.Writer, in FederationAuthParams) { out.RawByte('{') first := true _ = first @@ -3855,27 +4267,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling33(out *jw // MarshalJSON supports json.Marshaler interface func (v FederationAuthParams) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling33(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api34(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v FederationAuthParams) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling33(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api34(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *FederationAuthParams) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling33(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api34(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *FederationAuthParams) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling33(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api34(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling34(in *jlexer.Lexer, out *EventServerMessageSwitchTo) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api35(in *jlexer.Lexer, out *EventServerMessageSwitchTo) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -3888,17 +4300,20 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling34(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } case "details": - if data := in.Raw(); in.Ok() { - in.AddError((out.Details).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Details).UnmarshalJSON(data)) + } } default: in.SkipRecursive() @@ -3910,7 +4325,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling34(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling34(out *jwriter.Writer, in EventServerMessageSwitchTo) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api35(out *jwriter.Writer, in EventServerMessageSwitchTo) { out.RawByte('{') first := true _ = first @@ -3930,27 +4345,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling34(out *jw // MarshalJSON supports json.Marshaler interface func (v EventServerMessageSwitchTo) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling34(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api35(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v EventServerMessageSwitchTo) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling34(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api35(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *EventServerMessageSwitchTo) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling34(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api35(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *EventServerMessageSwitchTo) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling34(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api35(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling35(in *jlexer.Lexer, out *EventServerMessageSessionEntry) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api36(in *jlexer.Lexer, out *EventServerMessageSessionEntry) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -3963,16 +4378,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling35(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = PublicSessionId(in.String()) + } case "userid": - out.UserId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } case "features": if in.IsNull() { in.Skip() @@ -3989,21 +4407,37 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling35(in *jle out.Features = (out.Features)[:0] } for !in.IsDelim(']') { - var v33 string - v33 = string(in.String()) - out.Features = append(out.Features, v33) + var v34 string + if in.IsNull() { + in.Skip() + } else { + v34 = string(in.String()) + } + out.Features = append(out.Features, v34) in.WantComma() } in.Delim(']') } case "user": - if data := in.Raw(); in.Ok() { - in.AddError((out.User).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.User).UnmarshalJSON(data)) + } } case "roomsessionid": - out.RoomSessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomSessionId = RoomSessionId(in.String()) + } case "federated": - out.Federated = bool(in.Bool()) + if in.IsNull() { + in.Skip() + } else { + out.Federated = bool(in.Bool()) + } default: in.SkipRecursive() } @@ -4014,7 +4448,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling35(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling35(out *jwriter.Writer, in EventServerMessageSessionEntry) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api36(out *jwriter.Writer, in EventServerMessageSessionEntry) { out.RawByte('{') first := true _ = first @@ -4033,11 +4467,11 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling35(out *jw out.RawString(prefix) { out.RawByte('[') - for v34, v35 := range in.Features { - if v34 > 0 { + for v35, v36 := range in.Features { + if v35 > 0 { out.RawByte(',') } - out.String(string(v35)) + out.String(string(v36)) } out.RawByte(']') } @@ -4063,27 +4497,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling35(out *jw // MarshalJSON supports json.Marshaler interface func (v EventServerMessageSessionEntry) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling35(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api36(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v EventServerMessageSessionEntry) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling35(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api36(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *EventServerMessageSessionEntry) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling35(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api36(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *EventServerMessageSessionEntry) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling35(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api36(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jlexer.Lexer, out *EventServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api37(in *jlexer.Lexer, out *EventServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -4096,16 +4530,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "target": - out.Target = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Target = string(in.String()) + } case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "join": if in.IsNull() { in.Skip() @@ -4114,25 +4551,21 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle in.Delim('[') if out.Join == nil { if !in.IsDelim(']') { - out.Join = make([]*EventServerMessageSessionEntry, 0, 8) + out.Join = make([]EventServerMessageSessionEntry, 0, 0) } else { - out.Join = []*EventServerMessageSessionEntry{} + out.Join = []EventServerMessageSessionEntry{} } } else { out.Join = (out.Join)[:0] } for !in.IsDelim(']') { - var v36 *EventServerMessageSessionEntry + var v37 EventServerMessageSessionEntry if in.IsNull() { in.Skip() - v36 = nil } else { - if v36 == nil { - v36 = new(EventServerMessageSessionEntry) - } - (*v36).UnmarshalEasyJSON(in) + (v37).UnmarshalEasyJSON(in) } - out.Join = append(out.Join, v36) + out.Join = append(out.Join, v37) in.WantComma() } in.Delim(']') @@ -4145,17 +4578,21 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle in.Delim('[') if out.Leave == nil { if !in.IsDelim(']') { - out.Leave = make([]string, 0, 4) + out.Leave = make([]PublicSessionId, 0, 4) } else { - out.Leave = []string{} + out.Leave = []PublicSessionId{} } } else { out.Leave = (out.Leave)[:0] } for !in.IsDelim(']') { - var v37 string - v37 = string(in.String()) - out.Leave = append(out.Leave, v37) + var v38 PublicSessionId + if in.IsNull() { + in.Skip() + } else { + v38 = PublicSessionId(in.String()) + } + out.Leave = append(out.Leave, v38) in.WantComma() } in.Delim(']') @@ -4168,25 +4605,21 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle in.Delim('[') if out.Change == nil { if !in.IsDelim(']') { - out.Change = make([]*EventServerMessageSessionEntry, 0, 8) + out.Change = make([]EventServerMessageSessionEntry, 0, 0) } else { - out.Change = []*EventServerMessageSessionEntry{} + out.Change = []EventServerMessageSessionEntry{} } } else { out.Change = (out.Change)[:0] } for !in.IsDelim(']') { - var v38 *EventServerMessageSessionEntry + var v39 EventServerMessageSessionEntry if in.IsNull() { in.Skip() - v38 = nil } else { - if v38 == nil { - v38 = new(EventServerMessageSessionEntry) - } - (*v38).UnmarshalEasyJSON(in) + (v39).UnmarshalEasyJSON(in) } - out.Change = append(out.Change, v38) + out.Change = append(out.Change, v39) in.WantComma() } in.Delim(']') @@ -4199,7 +4632,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle if out.SwitchTo == nil { out.SwitchTo = new(EventServerMessageSwitchTo) } - (*out.SwitchTo).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.SwitchTo).UnmarshalEasyJSON(in) + } } case "resumed": if in.IsNull() { @@ -4209,7 +4646,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle if out.Resumed == nil { out.Resumed = new(bool) } - *out.Resumed = bool(in.Bool()) + if in.IsNull() { + in.Skip() + } else { + *out.Resumed = bool(in.Bool()) + } } case "invite": if in.IsNull() { @@ -4219,7 +4660,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle if out.Invite == nil { out.Invite = new(RoomEventServerMessage) } - (*out.Invite).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Invite).UnmarshalEasyJSON(in) + } } case "disinvite": if in.IsNull() { @@ -4229,7 +4674,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle if out.Disinvite == nil { out.Disinvite = new(RoomDisinviteEventServerMessage) } - (*out.Disinvite).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Disinvite).UnmarshalEasyJSON(in) + } } case "update": if in.IsNull() { @@ -4239,7 +4688,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle if out.Update == nil { out.Update = new(RoomEventServerMessage) } - (*out.Update).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Update).UnmarshalEasyJSON(in) + } } case "flags": if in.IsNull() { @@ -4249,7 +4702,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle if out.Flags == nil { out.Flags = new(RoomFlagsServerMessage) } - (*out.Flags).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Flags).UnmarshalEasyJSON(in) + } } case "message": if in.IsNull() { @@ -4259,7 +4716,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle if out.Message == nil { out.Message = new(RoomEventMessage) } - (*out.Message).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Message).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -4271,7 +4732,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling36(out *jwriter.Writer, in EventServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api37(out *jwriter.Writer, in EventServerMessage) { out.RawByte('{') first := true _ = first @@ -4290,15 +4751,11 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling36(out *jw out.RawString(prefix) { out.RawByte('[') - for v39, v40 := range in.Join { - if v39 > 0 { + for v40, v41 := range in.Join { + if v40 > 0 { out.RawByte(',') } - if v40 == nil { - out.RawString("null") - } else { - (*v40).MarshalEasyJSON(out) - } + (v41).MarshalEasyJSON(out) } out.RawByte(']') } @@ -4308,11 +4765,11 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling36(out *jw out.RawString(prefix) { out.RawByte('[') - for v41, v42 := range in.Leave { - if v41 > 0 { + for v42, v43 := range in.Leave { + if v42 > 0 { out.RawByte(',') } - out.String(string(v42)) + out.String(string(v43)) } out.RawByte(']') } @@ -4322,15 +4779,11 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling36(out *jw out.RawString(prefix) { out.RawByte('[') - for v43, v44 := range in.Change { - if v43 > 0 { + for v44, v45 := range in.Change { + if v44 > 0 { out.RawByte(',') } - if v44 == nil { - out.RawString("null") - } else { - (*v44).MarshalEasyJSON(out) - } + (v45).MarshalEasyJSON(out) } out.RawByte(']') } @@ -4376,27 +4829,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling36(out *jw // MarshalJSON supports json.Marshaler interface func (v EventServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling36(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api37(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v EventServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling36(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api37(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *EventServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api37(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *EventServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling36(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api37(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling37(in *jlexer.Lexer, out *Error) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api38(in *jlexer.Lexer, out *Error) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -4409,19 +4862,26 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling37(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "code": - out.Code = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Code = string(in.String()) + } case "message": - out.Message = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Message = string(in.String()) + } case "details": - if data := in.Raw(); in.Ok() { - in.AddError((out.Details).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Details).UnmarshalJSON(data)) + } } default: in.SkipRecursive() @@ -4433,7 +4893,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling37(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling37(out *jwriter.Writer, in Error) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api38(out *jwriter.Writer, in Error) { out.RawByte('{') first := true _ = first @@ -4458,27 +4918,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling37(out *jw // MarshalJSON supports json.Marshaler interface func (v Error) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling37(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api38(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v Error) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling37(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api38(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *Error) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling37(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api38(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *Error) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling37(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api38(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling38(in *jlexer.Lexer, out *DialoutStatusInternalClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api39(in *jlexer.Lexer, out *DialoutStatusInternalClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -4491,22 +4951,37 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling38(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "callid": - out.CallId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.CallId = string(in.String()) + } case "status": - out.Status = DialoutStatus(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Status = DialoutStatus(in.String()) + } case "cause": - out.Cause = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Cause = string(in.String()) + } case "code": - out.Code = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.Code = int(in.Int()) + } case "message": - out.Message = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Message = string(in.String()) + } default: in.SkipRecursive() } @@ -4517,7 +4992,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling38(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling38(out *jwriter.Writer, in DialoutStatusInternalClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api39(out *jwriter.Writer, in DialoutStatusInternalClientMessage) { out.RawByte('{') first := true _ = first @@ -4552,27 +5027,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling38(out *jw // MarshalJSON supports json.Marshaler interface func (v DialoutStatusInternalClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling38(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api39(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v DialoutStatusInternalClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling38(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api39(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *DialoutStatusInternalClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling38(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api39(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *DialoutStatusInternalClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling38(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api39(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling39(in *jlexer.Lexer, out *DialoutInternalClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api40(in *jlexer.Lexer, out *DialoutInternalClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -4585,16 +5060,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling39(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } case "error": if in.IsNull() { in.Skip() @@ -4603,7 +5081,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling39(in *jle if out.Error == nil { out.Error = new(Error) } - (*out.Error).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Error).UnmarshalEasyJSON(in) + } } case "status": if in.IsNull() { @@ -4613,7 +5095,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling39(in *jle if out.Status == nil { out.Status = new(DialoutStatusInternalClientMessage) } - (*out.Status).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Status).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -4625,7 +5111,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling39(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling39(out *jwriter.Writer, in DialoutInternalClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api40(out *jwriter.Writer, in DialoutInternalClientMessage) { out.RawByte('{') first := true _ = first @@ -4655,27 +5141,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling39(out *jw // MarshalJSON supports json.Marshaler interface func (v DialoutInternalClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling39(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api40(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v DialoutInternalClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling39(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api40(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *DialoutInternalClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling39(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api40(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *DialoutInternalClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling39(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api40(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling40(in *jlexer.Lexer, out *ControlServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api41(in *jlexer.Lexer, out *ControlServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -4688,11 +5174,6 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling40(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "sender": if in.IsNull() { @@ -4702,7 +5183,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling40(in *jle if out.Sender == nil { out.Sender = new(MessageServerMessageSender) } - (*out.Sender).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Sender).UnmarshalEasyJSON(in) + } } case "recipient": if in.IsNull() { @@ -4712,11 +5197,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling40(in *jle if out.Recipient == nil { out.Recipient = new(MessageClientMessageRecipient) } - (*out.Recipient).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Recipient).UnmarshalEasyJSON(in) + } } case "data": - if data := in.Raw(); in.Ok() { - in.AddError((out.Data).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Data).UnmarshalJSON(data)) + } } default: in.SkipRecursive() @@ -4728,7 +5221,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling40(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling40(out *jwriter.Writer, in ControlServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api41(out *jwriter.Writer, in ControlServerMessage) { out.RawByte('{') first := true _ = first @@ -4757,27 +5250,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling40(out *jw // MarshalJSON supports json.Marshaler interface func (v ControlServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling40(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api41(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v ControlServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling40(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api41(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *ControlServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling40(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api41(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *ControlServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling40(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api41(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling41(in *jlexer.Lexer, out *ControlClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api42(in *jlexer.Lexer, out *ControlClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -4790,17 +5283,20 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling41(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "recipient": - (out.Recipient).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (out.Recipient).UnmarshalEasyJSON(in) + } case "data": - if data := in.Raw(); in.Ok() { - in.AddError((out.Data).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Data).UnmarshalJSON(data)) + } } default: in.SkipRecursive() @@ -4812,7 +5308,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling41(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling41(out *jwriter.Writer, in ControlClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api42(out *jwriter.Writer, in ControlClientMessage) { out.RawByte('{') first := true _ = first @@ -4832,27 +5328,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling41(out *jw // MarshalJSON supports json.Marshaler interface func (v ControlClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling41(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api42(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v ControlClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling41(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api42(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *ControlClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling41(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api42(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *ControlClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling41(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api42(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling42(in *jlexer.Lexer, out *CommonSessionInternalClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api43(in *jlexer.Lexer, out *CommonSessionInternalClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -4865,16 +5361,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling42(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = PublicSessionId(in.String()) + } case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } default: in.SkipRecursive() } @@ -4885,7 +5384,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling42(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling42(out *jwriter.Writer, in CommonSessionInternalClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api43(out *jwriter.Writer, in CommonSessionInternalClientMessage) { out.RawByte('{') first := true _ = first @@ -4905,27 +5404,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling42(out *jw // MarshalJSON supports json.Marshaler interface func (v CommonSessionInternalClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling42(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api43(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v CommonSessionInternalClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling42(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api43(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *CommonSessionInternalClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling42(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api43(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *CommonSessionInternalClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling42(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api43(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling43(in *jlexer.Lexer, out *ClientTypeInternalAuthParams) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api44(in *jlexer.Lexer, out *ClientTypeInternalAuthParams) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -4938,18 +5437,25 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling43(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "random": - out.Random = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Random = string(in.String()) + } case "token": - out.Token = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Token = string(in.String()) + } case "backend": - out.Backend = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Backend = string(in.String()) + } default: in.SkipRecursive() } @@ -4960,7 +5466,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling43(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling43(out *jwriter.Writer, in ClientTypeInternalAuthParams) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api44(out *jwriter.Writer, in ClientTypeInternalAuthParams) { out.RawByte('{') first := true _ = first @@ -4985,27 +5491,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling43(out *jw // MarshalJSON supports json.Marshaler interface func (v ClientTypeInternalAuthParams) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling43(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api44(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v ClientTypeInternalAuthParams) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling43(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api44(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *ClientTypeInternalAuthParams) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling43(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api44(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *ClientTypeInternalAuthParams) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling43(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api44(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(in *jlexer.Lexer, out *ClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api45(in *jlexer.Lexer, out *ClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -5018,16 +5524,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "id": - out.Id = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Id = string(in.String()) + } case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "hello": if in.IsNull() { in.Skip() @@ -5036,7 +5545,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(in *jle if out.Hello == nil { out.Hello = new(HelloClientMessage) } - (*out.Hello).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Hello).UnmarshalEasyJSON(in) + } } case "bye": if in.IsNull() { @@ -5046,7 +5559,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(in *jle if out.Bye == nil { out.Bye = new(ByeClientMessage) } - (*out.Bye).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Bye).UnmarshalEasyJSON(in) + } } case "room": if in.IsNull() { @@ -5056,7 +5573,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(in *jle if out.Room == nil { out.Room = new(RoomClientMessage) } - (*out.Room).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Room).UnmarshalEasyJSON(in) + } } case "message": if in.IsNull() { @@ -5066,7 +5587,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(in *jle if out.Message == nil { out.Message = new(MessageClientMessage) } - (*out.Message).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Message).UnmarshalEasyJSON(in) + } } case "control": if in.IsNull() { @@ -5076,7 +5601,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(in *jle if out.Control == nil { out.Control = new(ControlClientMessage) } - (*out.Control).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Control).UnmarshalEasyJSON(in) + } } case "internal": if in.IsNull() { @@ -5086,7 +5615,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(in *jle if out.Internal == nil { out.Internal = new(InternalClientMessage) } - (*out.Internal).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Internal).UnmarshalEasyJSON(in) + } } case "transient": if in.IsNull() { @@ -5096,7 +5629,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(in *jle if out.TransientData == nil { out.TransientData = new(TransientDataClientMessage) } - (*out.TransientData).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.TransientData).UnmarshalEasyJSON(in) + } } default: in.SkipRecursive() @@ -5108,7 +5645,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling44(out *jwriter.Writer, in ClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api45(out *jwriter.Writer, in ClientMessage) { out.RawByte('{') first := true _ = first @@ -5169,27 +5706,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling44(out *jw // MarshalJSON supports json.Marshaler interface func (v ClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling44(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api45(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v ClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling44(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api45(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *ClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api45(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *ClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling44(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api45(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling45(in *jlexer.Lexer, out *ByeServerMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api46(in *jlexer.Lexer, out *ByeServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -5202,14 +5739,13 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling45(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "reason": - out.Reason = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Reason = string(in.String()) + } default: in.SkipRecursive() } @@ -5220,7 +5756,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling45(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling45(out *jwriter.Writer, in ByeServerMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api46(out *jwriter.Writer, in ByeServerMessage) { out.RawByte('{') first := true _ = first @@ -5235,27 +5771,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling45(out *jw // MarshalJSON supports json.Marshaler interface func (v ByeServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling45(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api46(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v ByeServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling45(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api46(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *ByeServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling45(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api46(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *ByeServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling45(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api46(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling46(in *jlexer.Lexer, out *ByeClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api47(in *jlexer.Lexer, out *ByeClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -5268,11 +5804,6 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling46(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { default: in.SkipRecursive() @@ -5284,7 +5815,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling46(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling46(out *jwriter.Writer, in ByeClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api47(out *jwriter.Writer, in ByeClientMessage) { out.RawByte('{') first := true _ = first @@ -5294,27 +5825,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling46(out *jw // MarshalJSON supports json.Marshaler interface func (v ByeClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling46(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api47(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v ByeClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling46(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api47(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *ByeClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling46(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api47(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *ByeClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling46(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api47(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling47(in *jlexer.Lexer, out *AnswerOfferMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api48(in *jlexer.Lexer, out *AnswerOfferMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -5327,44 +5858,59 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling47(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "to": - out.To = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.To = PublicSessionId(in.String()) + } case "from": - out.From = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.From = PublicSessionId(in.String()) + } case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "roomType": - out.RoomType = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomType = string(in.String()) + } case "payload": if in.IsNull() { in.Skip() } else { in.Delim('{') - out.Payload = make(map[string]interface{}) + out.Payload = make(StringMap) for !in.IsDelim('}') { key := string(in.String()) in.WantColon() - var v45 interface{} - if m, ok := v45.(easyjson.Unmarshaler); ok { + var v46 interface{} + if m, ok := v46.(easyjson.Unmarshaler); ok { m.UnmarshalEasyJSON(in) - } else if m, ok := v45.(json.Unmarshaler); ok { + } else if m, ok := v46.(json.Unmarshaler); ok { _ = m.UnmarshalJSON(in.Raw()) } else { - v45 = in.Interface() + v46 = in.Interface() } - (out.Payload)[key] = v45 + (out.Payload)[key] = v46 in.WantComma() } in.Delim('}') } case "sid": - out.Sid = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Sid = string(in.String()) + } default: in.SkipRecursive() } @@ -5375,7 +5921,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling47(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling47(out *jwriter.Writer, in AnswerOfferMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api48(out *jwriter.Writer, in AnswerOfferMessage) { out.RawByte('{') first := true _ = first @@ -5406,21 +5952,21 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling47(out *jw out.RawString(`null`) } else { out.RawByte('{') - v46First := true - for v46Name, v46Value := range in.Payload { - if v46First { - v46First = false + v47First := true + for v47Name, v47Value := range in.Payload { + if v47First { + v47First = false } else { out.RawByte(',') } - out.String(string(v46Name)) + out.String(string(v47Name)) out.RawByte(':') - if m, ok := v46Value.(easyjson.Marshaler); ok { + if m, ok := v47Value.(easyjson.Marshaler); ok { m.MarshalEasyJSON(out) - } else if m, ok := v46Value.(json.Marshaler); ok { + } else if m, ok := v47Value.(json.Marshaler); ok { out.Raw(m.MarshalJSON()) } else { - out.Raw(json.Marshal(v46Value)) + out.Raw(json.Marshal(v47Value)) } } out.RawByte('}') @@ -5437,27 +5983,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling47(out *jw // MarshalJSON supports json.Marshaler interface func (v AnswerOfferMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling47(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api48(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v AnswerOfferMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling47(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api48(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *AnswerOfferMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling47(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api48(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *AnswerOfferMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling47(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api48(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling48(in *jlexer.Lexer, out *AddSessionOptions) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api49(in *jlexer.Lexer, out *AddSessionOptions) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -5470,16 +6016,19 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling48(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "actorId": - out.ActorId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ActorId = string(in.String()) + } case "actorType": - out.ActorType = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ActorType = string(in.String()) + } default: in.SkipRecursive() } @@ -5490,7 +6039,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling48(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling48(out *jwriter.Writer, in AddSessionOptions) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api49(out *jwriter.Writer, in AddSessionOptions) { out.RawByte('{') first := true _ = first @@ -5516,27 +6065,27 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling48(out *jw // MarshalJSON supports json.Marshaler interface func (v AddSessionOptions) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling48(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api49(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v AddSessionOptions) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling48(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api49(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *AddSessionOptions) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling48(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api49(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *AddSessionOptions) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling48(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api49(l, v) } -func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling49(in *jlexer.Lexer, out *AddSessionInternalClientMessage) { +func easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api50(in *jlexer.Lexer, out *AddSessionInternalClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -5549,20 +6098,27 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling49(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "userid": - out.UserId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } case "user": - if data := in.Raw(); in.Ok() { - in.AddError((out.User).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.User).UnmarshalJSON(data)) + } } case "flags": - out.Flags = uint32(in.Uint32()) + if in.IsNull() { + in.Skip() + } else { + out.Flags = uint32(in.Uint32()) + } case "incall": if in.IsNull() { in.Skip() @@ -5571,7 +6127,11 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling49(in *jle if out.InCall == nil { out.InCall = new(int) } - *out.InCall = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + *out.InCall = int(in.Int()) + } } case "options": if in.IsNull() { @@ -5581,12 +6141,24 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling49(in *jle if out.Options == nil { out.Options = new(AddSessionOptions) } - (*out.Options).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.Options).UnmarshalEasyJSON(in) + } } case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = PublicSessionId(in.String()) + } case "roomid": - out.RoomId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } default: in.SkipRecursive() } @@ -5597,7 +6169,7 @@ func easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling49(in *jle in.Consumed() } } -func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling49(out *jwriter.Writer, in AddSessionInternalClientMessage) { +func easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api50(out *jwriter.Writer, in AddSessionInternalClientMessage) { out.RawByte('{') first := true _ = first @@ -5668,23 +6240,23 @@ func easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling49(out *jw // MarshalJSON supports json.Marshaler interface func (v AddSessionInternalClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling49(&w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api50(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v AddSessionInternalClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson29f189fbEncodeGithubComStrukturagNextcloudSpreedSignaling49(w, v) + easyjson6128dd2EncodeGithubComStrukturagNextcloudSpreedSignalingV2Api50(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *AddSessionInternalClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling49(&r, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api50(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *AddSessionInternalClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson29f189fbDecodeGithubComStrukturagNextcloudSpreedSignaling49(l, v) + easyjson6128dd2DecodeGithubComStrukturagNextcloudSpreedSignalingV2Api50(l, v) } diff --git a/api/signaling_test.go b/api/signaling_test.go new file mode 100644 index 0000000..b03faf4 --- /dev/null +++ b/api/signaling_test.go @@ -0,0 +1,1081 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package api + +import ( + "encoding/json" + "errors" + "slices" + "strings" + "testing" + + "github.com/pion/ice/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/container" + "github.com/strukturag/nextcloud-spreed-signaling/v2/mock" +) + +func TestRoomSessionIds(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + + var s1 RoomSessionId = "foo" + assert.False(s1.IsFederated()) + assert.EqualValues("foo", s1.WithoutFederation()) + + var s2 RoomSessionId = "federated|bar" + assert.True(s2.IsFederated()) + assert.EqualValues("bar", s2.WithoutFederation()) +} + +type testCheckValid interface { + CheckValid() error +} + +func wrapMessage(messageType string, msg testCheckValid) *ClientMessage { + wrapped := &ClientMessage{ + Type: messageType, + } + switch messageType { + case "hello": + wrapped.Hello = msg.(*HelloClientMessage) + case "message": + wrapped.Message = msg.(*MessageClientMessage) + case "bye": + wrapped.Bye = msg.(*ByeClientMessage) + case "room": + wrapped.Room = msg.(*RoomClientMessage) + case "control": + wrapped.Control = msg.(*ControlClientMessage) + case "internal": + wrapped.Internal = msg.(*InternalClientMessage) + case "transient": + wrapped.TransientData = msg.(*TransientDataClientMessage) + default: + return nil + } + return wrapped +} + +func testMessages(t *testing.T, messageType string, valid_messages []testCheckValid, invalid_messages []testCheckValid) { + t.Helper() + assert := assert.New(t) + for _, msg := range valid_messages { + assert.NoError(msg.CheckValid(), "Message %+v should be valid", msg) + + if messageType != "" { + // If the inner message is valid, it should also be valid in a wrapped + // ClientMessage. + if wrapped := wrapMessage(messageType, msg); assert.NotNil(wrapped, "Unknown message type: %s", messageType) { + assert.NoError(wrapped.CheckValid(), "Message %+v should be valid", wrapped) + } + } + } + for _, msg := range invalid_messages { + assert.Error(msg.CheckValid(), "Message %+v should not be valid", msg) + + if messageType != "" { + // If the inner message is invalid, it should also be invalid in a + // wrapped ClientMessage. + if wrapped := wrapMessage(messageType, msg); assert.NotNil(wrapped, "Unknown message type: %s", messageType) { + assert.Error(wrapped.CheckValid(), "Message %+v should not be valid", wrapped) + } + } + } +} + +func TestClientMessage(t *testing.T) { + t.Parallel() + assert := assert.New(t) + // The message needs a type. + msg := ClientMessage{} + assert.Error(msg.CheckValid()) +} + +func TestHelloClientMessage(t *testing.T) { + t.Parallel() + internalAuthParams := []byte("{\"backend\":\"https://domain.invalid\"}") + tokenAuthParams := []byte("{\"token\":\"invalid-token\"}") + valid_messages := []testCheckValid{ + // Hello version 1 + &HelloClientMessage{ + Version: HelloVersionV1, + Auth: &HelloClientMessageAuth{ + Params: json.RawMessage("{}"), + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV1, + Auth: &HelloClientMessageAuth{ + Type: "client", + Params: json.RawMessage("{}"), + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV1, + Auth: &HelloClientMessageAuth{ + Type: "internal", + Params: internalAuthParams, + }, + }, + &HelloClientMessage{ + Version: HelloVersionV1, + ResumeId: "the-resume-id", + }, + // Hello version 2 + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Params: tokenAuthParams, + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Type: "client", + Params: tokenAuthParams, + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV2, + ResumeId: "the-resume-id", + }, + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Type: "federation", + Params: tokenAuthParams, + Url: "https://domain.invalid", + }, + }, + } + invalid_messages := []testCheckValid{ + // Hello version 1 + &HelloClientMessage{}, + &HelloClientMessage{Version: "0.0"}, + &HelloClientMessage{Version: HelloVersionV1}, + &HelloClientMessage{ + Version: HelloVersionV1, + Auth: &HelloClientMessageAuth{ + Params: json.RawMessage("{}"), + Type: "invalid-type", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV1, + Auth: &HelloClientMessageAuth{ + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV1, + Auth: &HelloClientMessageAuth{ + Params: json.RawMessage("{}"), + }, + }, + &HelloClientMessage{ + Version: HelloVersionV1, + Auth: &HelloClientMessageAuth{ + Params: json.RawMessage("{}"), + Url: "invalid-url", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV1, + Auth: &HelloClientMessageAuth{ + Type: "internal", + Params: json.RawMessage("{}"), + }, + }, + &HelloClientMessage{ + Version: HelloVersionV1, + Auth: &HelloClientMessageAuth{ + Type: "internal", + Params: json.RawMessage("xyz"), // Invalid JSON. + }, + }, + // Hello version 2 + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Params: tokenAuthParams, + }, + }, + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Params: tokenAuthParams, + Url: "invalid-url", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Params: internalAuthParams, + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Params: json.RawMessage("xyz"), // Invalid JSON. + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Type: HelloClientTypeFederation, + Params: json.RawMessage("xyz"), // Invalid JSON. + }, + }, + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Type: HelloClientTypeFederation, + Params: json.RawMessage("{}"), + Url: "https://domain.invalid", + }, + }, + &HelloClientMessage{ + Version: HelloVersionV2, + Auth: &HelloClientMessageAuth{ + Type: HelloClientTypeFederation, + Params: tokenAuthParams, + }, + }, + } + + testMessages(t, "hello", valid_messages, invalid_messages) + + // A "hello" message must be present + msg := ClientMessage{ + Type: "hello", + } + assert := assert.New(t) + assert.Error(msg.CheckValid()) +} + +func TestMessageClientMessage(t *testing.T) { + t.Parallel() + valid_messages := []testCheckValid{ + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + SessionId: "the-session-id", + }, + Data: json.RawMessage("{}"), + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + UserId: "the-user-id", + }, + Data: json.RawMessage("{}"), + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "room", + }, + Data: json.RawMessage("{}"), + }, + } + invalid_messages := []testCheckValid{ + &MessageClientMessage{}, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + SessionId: "the-session-id", + }, + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + }, + Data: json.RawMessage("{}"), + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + UserId: "the-user-id", + }, + Data: json.RawMessage("{}"), + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + }, + Data: json.RawMessage("{}"), + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + UserId: "the-user-id", + }, + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + SessionId: "the-user-id", + }, + Data: json.RawMessage("{}"), + }, + &MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "unknown-type", + }, + Data: json.RawMessage("{}"), + }, + } + testMessages(t, "message", valid_messages, invalid_messages) + + // A "message" message must be present + msg := ClientMessage{ + Type: "message", + } + assert := assert.New(t) + assert.Error(msg.CheckValid()) +} + +func TestControlClientMessage(t *testing.T) { + t.Parallel() + valid_messages := []testCheckValid{ + &ControlClientMessage{ + MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + SessionId: "the-session-id", + }, + Data: json.RawMessage("{}"), + }, + }, + &ControlClientMessage{ + MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + UserId: "the-user-id", + }, + Data: json.RawMessage("{}"), + }, + }, + &ControlClientMessage{ + MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "room", + }, + Data: json.RawMessage("{}"), + }, + }, + } + invalid_messages := []testCheckValid{ + &ControlClientMessage{ + MessageClientMessage{}, + }, + &ControlClientMessage{ + MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + SessionId: "the-session-id", + }, + }, + }, + &ControlClientMessage{ + MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + }, + Data: json.RawMessage("{}"), + }, + }, + &ControlClientMessage{ + MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "session", + UserId: "the-user-id", + }, + Data: json.RawMessage("{}"), + }, + }, + &ControlClientMessage{ + MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + }, + Data: json.RawMessage("{}"), + }, + }, + &ControlClientMessage{ + MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + UserId: "the-user-id", + }, + }, + }, + &ControlClientMessage{ + MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "user", + SessionId: "the-user-id", + }, + Data: json.RawMessage("{}"), + }, + }, + &ControlClientMessage{ + MessageClientMessage{ + Recipient: MessageClientMessageRecipient{ + Type: "unknown-type", + }, + Data: json.RawMessage("{}"), + }, + }, + } + testMessages(t, "control", valid_messages, invalid_messages) + + // But a "control" message must be present + msg := ClientMessage{ + Type: "control", + } + assert := assert.New(t) + assert.Error(msg.CheckValid()) +} + +func TestMessageClientMessageData(t *testing.T) { + t.Parallel() + valid_messages := []testCheckValid{ + &MessageClientMessageData{ + Type: "invalid", + RoomType: "video", + }, + &MessageClientMessageData{ + Type: "offer", + RoomType: "video", + Payload: StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + }, + &MessageClientMessageData{ + Type: "answer", + RoomType: "video", + Payload: StringMap{ + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + }, + &MessageClientMessageData{ + Type: "candidate", + RoomType: "video", + Payload: StringMap{ + "candidate": StringMap{ + "candidate": "", + }, + }, + }, + &MessageClientMessageData{ + Type: "candidate", + RoomType: "video", + Payload: StringMap{ + "candidate": StringMap{ + "candidate": "candidate:0 1 UDP 2122194687 192.0.2.4 61665 typ host", + }, + }, + }, + } + invalid_messages := []testCheckValid{ + &MessageClientMessageData{}, + &MessageClientMessageData{ + RoomType: "invalid", + }, + &MessageClientMessageData{ + Type: "offer", + RoomType: "video", + }, + &MessageClientMessageData{ + Type: "offer", + RoomType: "video", + Payload: StringMap{ + "sdp": 1234, + }, + }, + &MessageClientMessageData{ + Type: "offer", + RoomType: "video", + Payload: StringMap{ + "sdp": "invalid-sdp", + }, + }, + &MessageClientMessageData{ + Type: "answer", + RoomType: "video", + }, + &MessageClientMessageData{ + Type: "answer", + RoomType: "video", + Payload: StringMap{ + "sdp": 1234, + }, + }, + &MessageClientMessageData{ + Type: "answer", + RoomType: "video", + Payload: StringMap{ + "sdp": "invalid-sdp", + }, + }, + &MessageClientMessageData{ + Type: "candidate", + RoomType: "video", + }, + &MessageClientMessageData{ + Type: "candidate", + RoomType: "video", + Payload: StringMap{ + "candidate": "invalid-candidate", + }, + }, + &MessageClientMessageData{ + Type: "candidate", + RoomType: "video", + Payload: StringMap{ + "candidate": StringMap{ + "candidate": 12345, + }, + }, + }, + &MessageClientMessageData{ + Type: "candidate", + RoomType: "video", + Payload: StringMap{ + "candidate": StringMap{ + "candidate": ":", + }, + }, + }, + } + + testMessages(t, "", valid_messages, invalid_messages) +} + +func TestByeClientMessage(t *testing.T) { + t.Parallel() + // Any "bye" message is valid. + valid_messages := []testCheckValid{ + &ByeClientMessage{}, + } + invalid_messages := []testCheckValid{} + + testMessages(t, "bye", valid_messages, invalid_messages) + + // The "bye" message is optional. + msg := ClientMessage{ + Type: "bye", + } + assert := assert.New(t) + assert.NoError(msg.CheckValid()) +} + +func TestRoomClientMessage(t *testing.T) { + t.Parallel() + // Any regular "room" message is valid. + valid_messages := []testCheckValid{ + &RoomClientMessage{}, + &RoomClientMessage{ + Federation: &RoomFederationMessage{ + SignalingUrl: "http://signaling.domain.invalid/", + NextcloudUrl: "http://nextcloud.domain.invalid", + Token: "the token", + }, + }, + } + invalid_messages := []testCheckValid{ + &RoomClientMessage{ + Federation: &RoomFederationMessage{}, + }, + &RoomClientMessage{ + Federation: &RoomFederationMessage{ + SignalingUrl: ":", + }, + }, + &RoomClientMessage{ + Federation: &RoomFederationMessage{ + SignalingUrl: "http://signaling.domain.invalid", + }, + }, + &RoomClientMessage{ + Federation: &RoomFederationMessage{ + SignalingUrl: "http://signaling.domain.invalid/", + NextcloudUrl: ":", + }, + }, + &RoomClientMessage{ + Federation: &RoomFederationMessage{ + SignalingUrl: "http://signaling.domain.invalid/", + NextcloudUrl: "http://nextcloud.domain.invalid", + }, + }, + } + + testMessages(t, "room", valid_messages, invalid_messages) + + // But a "room" message must be present + msg := ClientMessage{ + Type: "room", + } + assert := assert.New(t) + assert.Error(msg.CheckValid()) +} + +func TestInternalClientMessage(t *testing.T) { + t.Parallel() + valid_messages := []testCheckValid{ + &InternalClientMessage{ + Type: "invalid", + }, + &InternalClientMessage{ + Type: "addsession", + AddSession: &AddSessionInternalClientMessage{ + CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ + SessionId: "session id", + RoomId: "room id", + }, + }, + }, + &InternalClientMessage{ + Type: "updatesession", + UpdateSession: &UpdateSessionInternalClientMessage{ + CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ + SessionId: "session id", + RoomId: "room id", + }, + }, + }, + &InternalClientMessage{ + Type: "removesession", + RemoveSession: &RemoveSessionInternalClientMessage{ + CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ + SessionId: "session id", + RoomId: "room id", + }, + }, + }, + &InternalClientMessage{ + Type: "incall", + InCall: &InCallInternalClientMessage{}, + }, + &InternalClientMessage{ + Type: "dialout", + Dialout: &DialoutInternalClientMessage{ + Type: "invalid", + }, + }, + &InternalClientMessage{ + Type: "dialout", + Dialout: &DialoutInternalClientMessage{ + Type: "error", + Error: &Error{}, + }, + }, + &InternalClientMessage{ + Type: "dialout", + Dialout: &DialoutInternalClientMessage{ + Type: "status", + Status: &DialoutStatusInternalClientMessage{}, + }, + }, + } + invalid_messages := []testCheckValid{ + &InternalClientMessage{}, + &InternalClientMessage{ + Type: "addsession", + }, + &InternalClientMessage{ + Type: "addsession", + AddSession: &AddSessionInternalClientMessage{}, + }, + &InternalClientMessage{ + Type: "addsession", + AddSession: &AddSessionInternalClientMessage{ + CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ + SessionId: "session id", + }, + }, + }, + &InternalClientMessage{ + Type: "updatesession", + }, + &InternalClientMessage{ + Type: "updatesession", + UpdateSession: &UpdateSessionInternalClientMessage{}, + }, + &InternalClientMessage{ + Type: "updatesession", + UpdateSession: &UpdateSessionInternalClientMessage{ + CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ + SessionId: "session id", + }, + }, + }, + &InternalClientMessage{ + Type: "removesession", + }, + &InternalClientMessage{ + Type: "removesession", + RemoveSession: &RemoveSessionInternalClientMessage{}, + }, + &InternalClientMessage{ + Type: "removesession", + RemoveSession: &RemoveSessionInternalClientMessage{ + CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ + SessionId: "session id", + }, + }, + }, + &InternalClientMessage{ + Type: "incall", + }, + &InternalClientMessage{ + Type: "dialout", + }, + &InternalClientMessage{ + Type: "dialout", + Dialout: &DialoutInternalClientMessage{}, + }, + &InternalClientMessage{ + Type: "dialout", + Dialout: &DialoutInternalClientMessage{ + Type: "error", + }, + }, + &InternalClientMessage{ + Type: "dialout", + Dialout: &DialoutInternalClientMessage{ + Type: "status", + }, + }, + } + + testMessages(t, "internal", valid_messages, invalid_messages) + + // But a "internal" message must be present + msg := ClientMessage{ + Type: "internal", + } + assert := assert.New(t) + assert.Error(msg.CheckValid()) +} + +func TestTransientDataClientMessage(t *testing.T) { + t.Parallel() + valid_messages := []testCheckValid{ + &TransientDataClientMessage{ + Type: "set", + Key: "foo", + }, + &TransientDataClientMessage{ + Type: "remove", + Key: "foo", + }, + } + invalid_messages := []testCheckValid{ + &TransientDataClientMessage{}, + &TransientDataClientMessage{ + Type: "set", + }, + &TransientDataClientMessage{ + Type: "remove", + }, + } + + testMessages(t, "transient", valid_messages, invalid_messages) + + // But a "transient" message must be present + msg := ClientMessage{ + Type: "transient", + } + assert := assert.New(t) + assert.Error(msg.CheckValid()) +} + +func TestErrorMessages(t *testing.T) { + t.Parallel() + assert := assert.New(t) + id := "request-id" + msg := ClientMessage{ + Id: id, + } + err1 := msg.NewErrorServerMessage(&Error{}) + assert.Equal(id, err1.Id, "%+v", err1) + assert.Equal("error", err1.Type, "%+v", err1) + assert.NotNil(err1.Error, "%+v", err1) + + err2 := msg.NewWrappedErrorServerMessage(errors.New("test-error")) + assert.Equal(id, err2.Id, "%+v", err2) + assert.Equal("error", err2.Type, "%+v", err2) + if assert.NotNil(err2.Error, "%+v", err2) { + assert.Equal("internal_error", err2.Error.Code, "%+v", err2) + assert.Equal("test-error", err2.Error.Message, "%+v", err2) + } + // Test "error" interface + assert.Equal("test-error", err2.Error.Error(), "%+v", err2) +} + +func TestIsChatRefresh(t *testing.T) { + t.Parallel() + var msg ServerMessage + data_true := []byte("{\"type\":\"chat\",\"chat\":{\"refresh\":true}}") + msg = ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Type: "message", + Message: &RoomEventMessage{ + RoomId: "foo", + Data: data_true, + }, + }, + } + assert.True(t, msg.IsChatRefresh()) + + data_false := []byte("{\"type\":\"chat\",\"chat\":{\"refresh\":false}}") + msg = ServerMessage{ + Type: "event", + Event: &EventServerMessage{ + Type: "message", + Message: &RoomEventMessage{ + RoomId: "foo", + Data: data_false, + }, + }, + } + assert.False(t, msg.IsChatRefresh()) +} + +func assertEqualStrings(t *testing.T, expected, result []string) { + t.Helper() + + if expected == nil { + expected = make([]string, 0) + } else { + slices.Sort(expected) + } + if result == nil { + result = make([]string, 0) + } else { + slices.Sort(result) + } + + assert.Equal(t, expected, result) +} + +func Test_Welcome_AddRemoveFeature(t *testing.T) { + t.Parallel() + assert := assert.New(t) + var msg WelcomeServerMessage + assertEqualStrings(t, []string{}, msg.Features) + + msg.AddFeature("one", "two", "one") + assertEqualStrings(t, []string{"one", "two"}, msg.Features) + assert.True(slices.IsSorted(msg.Features), "features should be sorted, got %+v", msg.Features) + + msg.AddFeature("three") + assertEqualStrings(t, []string{"one", "two", "three"}, msg.Features) + assert.True(slices.IsSorted(msg.Features), "features should be sorted, got %+v", msg.Features) + + msg.RemoveFeature("three", "one") + assertEqualStrings(t, []string{"two"}, msg.Features) +} + +func TestFilterCandidates(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + testcases := []struct { + candidate string + allowed string + blocked string + expectFiltered bool + }{ + // IPs can be filtered. + { + "candidate:1696121226 1 udp 2122129151 10.1.2.3 12345 typ host generation 0 ufrag YOs/ network-id 1", + "", + "10.0.0.0/8", + true, + }, + { + "candidate:1696121226 1 udp 2122129151 1.2.3.4 12345 typ host generation 0 ufrag YOs/ network-id 1", + "", + "10.0.0.0/8", + false, + }, + // IPs can be allowed. + { + "candidate:1696121226 1 udp 2122129151 10.1.2.3 12345 typ host generation 0 ufrag YOs/ network-id 1", + "10.1.0.0/16", + "10.0.0.0/8", + false, + }, + // IPs can be blocked. + { + "candidate:1696121226 1 udp 2122129151 1.2.3.4 12345 typ host generation 0 ufrag YOs/ network-id 1", + "", + "1.2.0.0/16", + true, + }, + } + + for idx, tc := range testcases { + candidate, err := ice.UnmarshalCandidate(tc.candidate) + if !assert.NoError(err, "parsing candidate %s failed in testcase %d", tc.candidate, idx) { + continue + } + + var allowed *container.IPList + if tc.allowed != "" { + allowed, err = container.ParseIPList(tc.allowed) + if !assert.NoError(err, "parsing allowed list %s failed in testcase %d", tc.allowed, idx) { + continue + } + } + + var blocked *container.IPList + if tc.blocked != "" { + blocked, err = container.ParseIPList(tc.blocked) + if !assert.NoError(err, "parsing blocked list %s failed in testcase %d", tc.blocked, idx) { + continue + } + } + + filtered := FilterCandidate(candidate, allowed, blocked) + assert.Equal(tc.expectFiltered, filtered, "failed in testcase %d", idx) + } +} + +func TestFilterSDPCandidates(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + s, err := ParseSDP(mock.MockSdpOfferAudioOnly) + require.NoError(err) + if encoded, err := s.Marshal(); assert.NoError(err) { + assert.Equal(mock.MockSdpOfferAudioOnly, strings.ReplaceAll(string(encoded), "\r\n", "\n")) + } + + expectedBefore := map[string]int{ + "audio": 4, + } + for _, m := range s.MediaDescriptions { + count := 0 + for _, a := range m.Attributes { + if a.IsICECandidate() { + count++ + } + } + + assert.Equal(expectedBefore[m.MediaName.Media], count, "invalid number of candidates for media description %s", m.MediaName.Media) + } + + blocked, err := container.ParseIPList("192.0.0.0/24, 192.168.0.0/16") + require.NoError(err) + + expectedAfter := map[string]int{ + "audio": 2, + } + if filtered := FilterSDPCandidates(s, nil, blocked); assert.True(filtered, "should have filtered") { + for _, m := range s.MediaDescriptions { + count := 0 + for _, a := range m.Attributes { + if a.IsICECandidate() { + count++ + } + } + + assert.Equal(expectedAfter[m.MediaName.Media], count, "invalid number of candidates for media description %s", m.MediaName.Media) + } + } + + if encoded, err := s.Marshal(); assert.NoError(err) { + assert.NotEqual(mock.MockSdpOfferAudioOnly, strings.ReplaceAll(string(encoded), "\r\n", "\n")) + assert.Equal(mock.MockSdpOfferAudioOnlyNoFilter, strings.ReplaceAll(string(encoded), "\r\n", "\n")) + } +} + +func TestNoFilterSDPCandidates(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + s, err := ParseSDP(mock.MockSdpOfferAudioOnlyNoFilter) + require.NoError(err) + if encoded, err := s.Marshal(); assert.NoError(err) { + assert.Equal(mock.MockSdpOfferAudioOnlyNoFilter, strings.ReplaceAll(string(encoded), "\r\n", "\n")) + } + + expectedBefore := map[string]int{ + "audio": 2, + } + for _, m := range s.MediaDescriptions { + count := 0 + for _, a := range m.Attributes { + if a.IsICECandidate() { + count++ + } + } + + assert.Equal(expectedBefore[m.MediaName.Media], count, "invalid number of candidates for media description %s", m.MediaName.Media) + } + + blocked, err := container.ParseIPList("192.0.0.0/24, 192.168.0.0/16") + require.NoError(err) + + expectedAfter := map[string]int{ + "audio": 2, + } + if filtered := FilterSDPCandidates(s, nil, blocked); assert.False(filtered, "should not have filtered") { + for _, m := range s.MediaDescriptions { + count := 0 + for _, a := range m.Attributes { + if a.IsICECandidate() { + count++ + } + } + + assert.Equal(expectedAfter[m.MediaName.Media], count, "invalid number of candidates for media description %s", m.MediaName.Media) + } + } + + if encoded, err := s.Marshal(); assert.NoError(err) { + assert.Equal(mock.MockSdpOfferAudioOnlyNoFilter, strings.ReplaceAll(string(encoded), "\r\n", "\n")) + } +} diff --git a/api/stringmap.go b/api/stringmap.go new file mode 100644 index 0000000..de26597 --- /dev/null +++ b/api/stringmap.go @@ -0,0 +1,78 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package api + +// StringMap maps string keys to arbitrary values. +type StringMap map[string]any + +func (m StringMap) GetStringMap(key string) (StringMap, bool) { + v, found := m[key] + if !found { + return nil, false + } + + return ConvertStringMap(v) +} + +func ConvertStringMap(ob any) (StringMap, bool) { + if ob == nil { + return nil, true + } + + switch ob := ob.(type) { + case map[string]any: + return StringMap(ob), true + case StringMap: + return ob, true + default: + return nil, false + } +} + +// GetStringMapEntry returns an entry from a string map in a given type. +func GetStringMapEntry[T any](m StringMap, key string) (s T, ok bool) { + var defaultValue T + v, found := m[key] + if !found { + return defaultValue, false + } + + s, ok = v.(T) + return +} + +func GetStringMapString[T ~string](m StringMap, key string) (T, bool) { + var defaultValue T + v, found := m[key] + if !found { + return defaultValue, false + } + + switch v := v.(type) { + case string: + return T(v), true + case T: + return v, true + default: + return defaultValue, false + } +} diff --git a/api/stringmap_test.go b/api/stringmap_test.go new file mode 100644 index 0000000..f54e3db --- /dev/null +++ b/api/stringmap_test.go @@ -0,0 +1,118 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConvertStringMap(t *testing.T) { + t.Parallel() + assert := assert.New(t) + d := map[string]any{ + "foo": "bar", + "bar": 2, + } + + m, ok := ConvertStringMap(d) + if assert.True(ok) { + assert.EqualValues(d, m) + } + + if m, ok := ConvertStringMap(nil); assert.True(ok) { + assert.Nil(m) + } + + _, ok = ConvertStringMap("foo") + assert.False(ok) + + _, ok = ConvertStringMap(1) + assert.False(ok) + + _, ok = ConvertStringMap(map[int]any{ + 1: "foo", + }) + assert.False(ok) +} + +func TestGetStringMapString(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + type StringMapTestString string + + var ok bool + m := StringMap{ + "foo": "bar", + "bar": StringMapTestString("baz"), + "baz": 1234, + } + if v, ok := GetStringMapString[string](m, "foo"); assert.True(ok) { + assert.Equal("bar", v) + } + if v, ok := GetStringMapString[StringMapTestString](m, "foo"); assert.True(ok) { + assert.Equal(StringMapTestString("bar"), v) + } + v, ok := GetStringMapString[string](m, "bar") + assert.False(ok, "should not find object, got %+v", v) + + if v, ok := GetStringMapString[StringMapTestString](m, "bar"); assert.True(ok) { + assert.Equal(StringMapTestString("baz"), v) + } + + _, ok = GetStringMapString[string](m, "baz") + assert.False(ok) + _, ok = GetStringMapString[StringMapTestString](m, "baz") + assert.False(ok) + _, ok = GetStringMapString[string](m, "invalid") + assert.False(ok) + _, ok = GetStringMapString[StringMapTestString](m, "invalid") + assert.False(ok) +} + +func TestGetStringMapStringMap(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + m := StringMap{ + "foo": map[string]any{ + "bar": 1, + }, + "bar": StringMap{ + "baz": 2, + }, + } + if v, ok := m.GetStringMap("foo"); assert.True(ok) { + assert.EqualValues(map[string]any{ + "bar": 1, + }, v) + } + if v, ok := m.GetStringMap("bar"); assert.True(ok) { + assert.EqualValues(map[string]any{ + "baz": 2, + }, v) + } + v, ok := m.GetStringMap("baz") + assert.False(ok, "expected missing entry, got %+v", v) +} diff --git a/api/transient_data.go b/api/transient_data.go new file mode 100644 index 0000000..6cc55c2 --- /dev/null +++ b/api/transient_data.go @@ -0,0 +1,405 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package api + +import ( + "encoding/json" + "fmt" + "reflect" + "sync" + "time" +) + +const ( + TransientSessionDataPrefix = "sd:" +) + +type TransientListener interface { + SendMessage(message *ServerMessage) bool +} + +type TransientDataEntry struct { + Value any `json:"value"` + Expires time.Time `json:"expires,omitzero"` +} + +func NewTransientDataEntry(value any, ttl time.Duration) *TransientDataEntry { + entry := &TransientDataEntry{ + Value: value, + } + if ttl > 0 { + entry.Expires = time.Now().Add(ttl) + } + return entry +} + +func NewTransientDataEntryWithExpires(value any, expires time.Time) *TransientDataEntry { + entry := &TransientDataEntry{ + Value: value, + Expires: expires, + } + return entry +} + +func (e *TransientDataEntry) clone() *TransientDataEntry { + result := *e + return &result +} + +func (e *TransientDataEntry) update(value any, ttl time.Duration) { + e.Value = value + if ttl > 0 { + e.Expires = time.Now().Add(ttl) + } else { + e.Expires = time.Time{} + } +} + +type TransientDataEntries map[string]*TransientDataEntry + +func (e TransientDataEntries) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("Could not serialize %#v: %s", e, err) + } + + return string(data) +} + +type TransientData struct { + mu sync.Mutex + // +checklocks:mu + data TransientDataEntries + // +checklocks:mu + listeners map[TransientListener]bool + // +checklocks:mu + timers map[string]*time.Timer +} + +// NewTransientData creates a new transient data container. +func NewTransientData() *TransientData { + return &TransientData{} +} + +// +checklocks:t.mu +func (t *TransientData) sendMessageToListener(listener TransientListener, message *ServerMessage) { + t.mu.Unlock() + defer t.mu.Lock() + + listener.SendMessage(message) +} + +// +checklocks:t.mu +func (t *TransientData) notifySet(key string, prev, value any) { + msg := &ServerMessage{ + Type: "transient", + TransientData: &TransientDataServerMessage{ + Type: "set", + Key: key, + Value: value, + OldValue: prev, + }, + } + for listener := range t.listeners { + t.sendMessageToListener(listener, msg) + } +} + +// +checklocks:t.mu +func (t *TransientData) notifyDeleted(key string, prev *TransientDataEntry) { + msg := &ServerMessage{ + Type: "transient", + TransientData: &TransientDataServerMessage{ + Type: "remove", + Key: key, + }, + } + if prev != nil { + msg.TransientData.OldValue = prev.Value + } + for listener := range t.listeners { + t.sendMessageToListener(listener, msg) + } +} + +// AddListener adds a new listener to be notified about changes. +func (t *TransientData) AddListener(listener TransientListener) { + t.mu.Lock() + defer t.mu.Unlock() + + if t.listeners == nil { + t.listeners = make(map[TransientListener]bool) + } + t.listeners[listener] = true + if len(t.data) > 0 { + data := make(StringMap, len(t.data)) + for k, v := range t.data { + data[k] = v.Value + } + msg := &ServerMessage{ + Type: "transient", + TransientData: &TransientDataServerMessage{ + Type: "initial", + Data: data, + }, + } + t.sendMessageToListener(listener, msg) + } +} + +// RemoveListener removes a previously registered listener. +func (t *TransientData) RemoveListener(listener TransientListener) { + t.mu.Lock() + defer t.mu.Unlock() + + delete(t.listeners, listener) +} + +// +checklocks:t.mu +func (t *TransientData) updateTTL(key string, value any, ttl time.Duration) { + if ttl <= 0 { + if old, found := t.timers[key]; found { + old.Stop() + delete(t.timers, key) + } + } else { + t.removeAfterTTL(key, value, ttl) + } +} + +// +checklocks:t.mu +func (t *TransientData) removeAfterTTL(key string, value any, ttl time.Duration) { + if old, found := t.timers[key]; found { + old.Stop() + } + + if ttl <= 0 { + delete(t.timers, key) + return + } + + timer := time.AfterFunc(ttl, func() { + t.mu.Lock() + defer t.mu.Unlock() + + t.compareAndRemove(key, value) + }) + if t.timers == nil { + t.timers = make(map[string]*time.Timer) + } + t.timers[key] = timer +} + +// +checklocks:t.mu +func (t *TransientData) doSet(key string, value any, prev *TransientDataEntry, ttl time.Duration) { + if t.data == nil { + t.data = make(TransientDataEntries) + } + var oldValue any + if prev == nil { + entry := NewTransientDataEntry(value, ttl) + t.data[key] = entry + } else { + oldValue = prev.Value + prev.update(value, ttl) + } + t.notifySet(key, oldValue, value) + t.removeAfterTTL(key, value, ttl) +} + +// Set sets a new value for the given key and notifies listeners +// if the value has been changed. +func (t *TransientData) Set(key string, value any) bool { + return t.SetTTL(key, value, 0) +} + +// SetTTL sets a new value for the given key with a time-to-live and notifies +// listeners if the value has been changed. +func (t *TransientData) SetTTL(key string, value any, ttl time.Duration) bool { + if value == nil { + return t.Remove(key) + } + + t.mu.Lock() + defer t.mu.Unlock() + + prev, found := t.data[key] + if found && reflect.DeepEqual(prev.Value, value) { + t.updateTTL(key, value, ttl) + return false + } + + t.doSet(key, value, prev, ttl) + return true +} + +// CompareAndSet sets a new value for the given key only for a given old value +// and notifies listeners if the value has been changed. +func (t *TransientData) CompareAndSet(key string, old, value any) bool { + return t.CompareAndSetTTL(key, old, value, 0) +} + +// CompareAndSetTTL sets a new value for the given key with a time-to-live, +// only for a given old value and notifies listeners if the value has been +// changed. +func (t *TransientData) CompareAndSetTTL(key string, old, value any, ttl time.Duration) bool { + if value == nil { + return t.CompareAndRemove(key, old) + } + + t.mu.Lock() + defer t.mu.Unlock() + + prev, found := t.data[key] + if old != nil && (!found || !reflect.DeepEqual(prev.Value, old)) { + return false + } else if old == nil && found { + return false + } + + t.doSet(key, value, prev, ttl) + return true +} + +// +checklocks:t.mu +func (t *TransientData) doRemove(key string, prev *TransientDataEntry) { + delete(t.data, key) + if old, found := t.timers[key]; found { + old.Stop() + delete(t.timers, key) + } + t.notifyDeleted(key, prev) +} + +// Remove deletes the value with the given key and notifies listeners +// if the key was removed. +func (t *TransientData) Remove(key string) bool { + t.mu.Lock() + defer t.mu.Unlock() + + prev, found := t.data[key] + if !found { + return false + } + + t.doRemove(key, prev) + return true +} + +// CompareAndRemove deletes the value with the given key if it has a given value +// and notifies listeners if the key was removed. +func (t *TransientData) CompareAndRemove(key string, old any) bool { + t.mu.Lock() + defer t.mu.Unlock() + + return t.compareAndRemove(key, old) +} + +// +checklocks:t.mu +func (t *TransientData) compareAndRemove(key string, old any) bool { + prev, found := t.data[key] + if !found || !reflect.DeepEqual(prev.Value, old) { + return false + } + + t.doRemove(key, prev) + return true +} + +// GetData returns a copy of the internal data. +func (t *TransientData) GetData() StringMap { + t.mu.Lock() + defer t.mu.Unlock() + + if len(t.data) == 0 { + return nil + } + + result := make(StringMap, len(t.data)) + for k, entry := range t.data { + result[k] = entry.Value + } + return result +} + +// GetEntries returns a copy of the internal data entries. +func (t *TransientData) GetEntries() TransientDataEntries { + t.mu.Lock() + defer t.mu.Unlock() + + if len(t.data) == 0 { + return nil + } + + result := make(TransientDataEntries, len(t.data)) + for k, e := range t.data { + result[k] = e.clone() + } + return result +} + +// SetInitial sets the initial data and notifies listeners. +func (t *TransientData) SetInitial(data TransientDataEntries) { + if len(data) == 0 { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + if t.data == nil { + t.data = make(TransientDataEntries) + } + + now := time.Now() + msgData := make(StringMap, len(data)) + for k, v := range data { + if _, found := t.data[k]; found { + // Entry already present (i.e. was set by regular event). + continue + } + + if e := v.Expires; !e.IsZero() { + if now.After(e) { + // Already expired + continue + } + + t.removeAfterTTL(k, v.Value, e.Sub(now)) + } + msgData[k] = v.Value + t.data[k] = v + } + if len(msgData) == 0 { + return + } + msg := &ServerMessage{ + Type: "transient", + TransientData: &TransientDataServerMessage{ + Type: "initial", + Data: msgData, + }, + } + for listener := range t.listeners { + t.sendMessageToListener(listener, msg) + } +} diff --git a/api/transient_data_test.go b/api/transient_data_test.go new file mode 100644 index 0000000..ae3dd62 --- /dev/null +++ b/api/transient_data_test.go @@ -0,0 +1,234 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package api + +import ( + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" +) + +func Test_TransientData(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + data := NewTransientData() + assert.False(data.Set("foo", nil)) + assert.True(data.Set("foo", "bar")) + assert.False(data.Set("foo", "bar")) + assert.True(data.Set("foo", "baz")) + assert.False(data.CompareAndSet("foo", "bar", "lala")) + assert.True(data.CompareAndSet("foo", "baz", "lala")) + assert.False(data.CompareAndSet("test", nil, nil)) + assert.True(data.CompareAndSet("test", nil, "123")) + assert.False(data.CompareAndSet("test", nil, "456")) + assert.False(data.CompareAndRemove("test", "1234")) + assert.True(data.CompareAndRemove("test", "123")) + assert.False(data.Remove("lala")) + assert.True(data.Remove("foo")) + + assert.True(data.SetTTL("test", "1234", time.Millisecond)) + assert.Equal("1234", data.GetData()["test"]) + // Data is removed after the TTL + start := time.Now() + time.Sleep(time.Millisecond) + synctest.Wait() + assert.Equal(time.Millisecond, time.Since(start)) + assert.Nil(data.GetData()["test"]) + + assert.True(data.SetTTL("test", "1234", time.Millisecond)) + assert.Equal("1234", data.GetData()["test"]) + assert.True(data.SetTTL("test", "2345", 3*time.Millisecond)) + assert.Equal("2345", data.GetData()["test"]) + start = time.Now() + // Data is removed after the TTL only if the value still matches + time.Sleep(2 * time.Millisecond) + synctest.Wait() + assert.Equal("2345", data.GetData()["test"]) + // Data is removed after the (second) TTL + time.Sleep(time.Millisecond) + synctest.Wait() + assert.Equal(3*time.Millisecond, time.Since(start)) + assert.Nil(data.GetData()["test"]) + + // Setting existing key will update the TTL + assert.True(data.SetTTL("test", "1234", time.Millisecond)) + assert.False(data.SetTTL("test", "1234", 3*time.Millisecond)) + start = time.Now() + // Data still exists after the first TTL + time.Sleep(2 * time.Millisecond) + synctest.Wait() + assert.Equal("1234", data.GetData()["test"]) + // Data is removed after the (updated) TTL + time.Sleep(time.Millisecond) + synctest.Wait() + assert.Equal(3*time.Millisecond, time.Since(start)) + assert.Nil(data.GetData()["test"]) + }) +} + +type MockTransientListener struct { + mu sync.Mutex + sending chan struct{} + done chan struct{} + + // +checklocks:mu + data *TransientData +} + +func (l *MockTransientListener) SendMessage(message *ServerMessage) bool { + close(l.sending) + + time.Sleep(10 * time.Millisecond) + + l.mu.Lock() + defer l.mu.Unlock() + defer close(l.done) + + time.Sleep(10 * time.Millisecond) + + return true +} + +func (l *MockTransientListener) Close() { + l.mu.Lock() + defer l.mu.Unlock() + + l.data.RemoveListener(l) +} + +func Test_TransientDataDeadlock(t *testing.T) { + t.Parallel() + data := NewTransientData() + + listener := &MockTransientListener{ + sending: make(chan struct{}), + done: make(chan struct{}), + + data: data, + } + data.AddListener(listener) + + go func() { + <-listener.sending + listener.Close() + }() + + data.Set("foo", "bar") + <-listener.done +} + +type initialDataListener struct { + t *testing.T + + expected StringMap + sent atomic.Int32 +} + +func (l *initialDataListener) SendMessage(message *ServerMessage) bool { + switch l.sent.Add(1) { + case 1: + if assert.Equal(l.t, "transient", message.Type) && + assert.NotNil(l.t, message.TransientData) && + assert.Equal(l.t, "initial", message.TransientData.Type) { + assert.Equal(l.t, l.expected, message.TransientData.Data) + } + case 2: + if assert.Equal(l.t, "transient", message.Type) && + assert.NotNil(l.t, message.TransientData) && + assert.Equal(l.t, "remove", message.TransientData.Type) { + assert.Equal(l.t, "foo", message.TransientData.Key) + assert.Equal(l.t, "bar", message.TransientData.OldValue) + } + default: + assert.Fail(l.t, "unexpected message", "received %+v", message) + } + return true +} + +func Test_TransientDataNotifyInitial(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + data := NewTransientData() + assert.True(data.Set("foo", "bar")) + + listener := &initialDataListener{ + t: t, + expected: StringMap{ + "foo": "bar", + }, + } + data.AddListener(listener) + assert.EqualValues(1, listener.sent.Load()) +} + +func Test_TransientDataSetInitial(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + + now := time.Now() + data := NewTransientData() + listener1 := &initialDataListener{ + t: t, + expected: StringMap{ + "foo": "bar", + "bar": 1234, + }, + } + data.AddListener(listener1) + assert.EqualValues(0, listener1.sent.Load()) + + data.SetInitial(TransientDataEntries{ + "foo": NewTransientDataEntryWithExpires("bar", now.Add(time.Minute)), + "bar": NewTransientDataEntry(1234, 0), + "expired": NewTransientDataEntryWithExpires(1234, now.Add(-time.Second)), + }) + + entries := data.GetEntries() + assert.Equal(TransientDataEntries{ + "foo": NewTransientDataEntryWithExpires("bar", now.Add(time.Minute)), + "bar": NewTransientDataEntry(1234, 0), + }, entries) + + listener2 := &initialDataListener{ + t: t, + expected: StringMap{ + "foo": "bar", + "bar": 1234, + }, + } + data.AddListener(listener2) + assert.EqualValues(1, listener1.sent.Load()) + assert.EqualValues(1, listener2.sent.Load()) + + time.Sleep(time.Minute) + synctest.Wait() + assert.EqualValues(2, listener1.sent.Load()) + assert.EqualValues(2, listener2.sent.Load()) + }) +} diff --git a/api_backend_easyjson.go b/api_backend_easyjson.go deleted file mode 100644 index 95338f0..0000000 --- a/api_backend_easyjson.go +++ /dev/null @@ -1,3554 +0,0 @@ -// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. - -package signaling - -import ( - json "encoding/json" - easyjson "github.com/mailru/easyjson" - jlexer "github.com/mailru/easyjson/jlexer" - jwriter "github.com/mailru/easyjson/jwriter" - time "time" -) - -// suppress unused package warning -var ( - _ *json.RawMessage - _ *jlexer.Lexer - _ *jwriter.Writer - _ easyjson.Marshaler -) - -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexer.Lexer, out *TurnCredentials) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "username": - out.Username = string(in.String()) - case "password": - out.Password = string(in.String()) - case "ttl": - out.TTL = int64(in.Int64()) - case "uris": - if in.IsNull() { - in.Skip() - out.URIs = nil - } else { - in.Delim('[') - if out.URIs == nil { - if !in.IsDelim(']') { - out.URIs = make([]string, 0, 4) - } else { - out.URIs = []string{} - } - } else { - out.URIs = (out.URIs)[:0] - } - for !in.IsDelim(']') { - var v1 string - v1 = string(in.String()) - out.URIs = append(out.URIs, v1) - in.WantComma() - } - in.Delim(']') - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling(out *jwriter.Writer, in TurnCredentials) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"username\":" - out.RawString(prefix[1:]) - out.String(string(in.Username)) - } - { - const prefix string = ",\"password\":" - out.RawString(prefix) - out.String(string(in.Password)) - } - { - const prefix string = ",\"ttl\":" - out.RawString(prefix) - out.Int64(int64(in.TTL)) - } - { - const prefix string = ",\"uris\":" - out.RawString(prefix) - if in.URIs == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { - out.RawString("null") - } else { - out.RawByte('[') - for v2, v3 := range in.URIs { - if v2 > 0 { - out.RawByte(',') - } - out.String(string(v3)) - } - out.RawByte(']') - } - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v TurnCredentials) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v TurnCredentials) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *TurnCredentials) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *TurnCredentials) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlexer.Lexer, out *RoomSessionData) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "userid": - out.UserId = string(in.String()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling1(out *jwriter.Writer, in RoomSessionData) { - out.RawByte('{') - first := true - _ = first - if in.UserId != "" { - const prefix string = ",\"userid\":" - first = false - out.RawString(prefix[1:]) - out.String(string(in.UserId)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v RoomSessionData) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling1(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v RoomSessionData) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling1(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *RoomSessionData) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling1(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *RoomSessionData) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling1(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlexer.Lexer, out *OcsResponse) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "ocs": - if in.IsNull() { - in.Skip() - out.Ocs = nil - } else { - if out.Ocs == nil { - out.Ocs = new(OcsBody) - } - (*out.Ocs).UnmarshalEasyJSON(in) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling2(out *jwriter.Writer, in OcsResponse) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"ocs\":" - out.RawString(prefix[1:]) - if in.Ocs == nil { - out.RawString("null") - } else { - (*in.Ocs).MarshalEasyJSON(out) - } - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v OcsResponse) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling2(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v OcsResponse) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling2(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *OcsResponse) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling2(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *OcsResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling2(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling3(in *jlexer.Lexer, out *OcsMeta) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "status": - out.Status = string(in.String()) - case "statuscode": - out.StatusCode = int(in.Int()) - case "message": - out.Message = string(in.String()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling3(out *jwriter.Writer, in OcsMeta) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"status\":" - out.RawString(prefix[1:]) - out.String(string(in.Status)) - } - { - const prefix string = ",\"statuscode\":" - out.RawString(prefix) - out.Int(int(in.StatusCode)) - } - { - const prefix string = ",\"message\":" - out.RawString(prefix) - out.String(string(in.Message)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v OcsMeta) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling3(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v OcsMeta) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling3(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *OcsMeta) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling3(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *OcsMeta) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling3(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlexer.Lexer, out *OcsBody) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "meta": - (out.Meta).UnmarshalEasyJSON(in) - case "data": - if data := in.Raw(); in.Ok() { - in.AddError((out.Data).UnmarshalJSON(data)) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling4(out *jwriter.Writer, in OcsBody) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"meta\":" - out.RawString(prefix[1:]) - (in.Meta).MarshalEasyJSON(out) - } - { - const prefix string = ",\"data\":" - out.RawString(prefix) - out.Raw((in.Data).MarshalJSON()) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v OcsBody) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling4(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v OcsBody) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling4(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *OcsBody) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling4(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *OcsBody) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling4(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling5(in *jlexer.Lexer, out *BackendServerRoomResponse) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "type": - out.Type = string(in.String()) - case "dialout": - if in.IsNull() { - in.Skip() - out.Dialout = nil - } else { - if out.Dialout == nil { - out.Dialout = new(BackendRoomDialoutResponse) - } - (*out.Dialout).UnmarshalEasyJSON(in) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling5(out *jwriter.Writer, in BackendServerRoomResponse) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"type\":" - out.RawString(prefix[1:]) - out.String(string(in.Type)) - } - if in.Dialout != nil { - const prefix string = ",\"dialout\":" - out.RawString(prefix) - (*in.Dialout).MarshalEasyJSON(out) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendServerRoomResponse) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling5(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendServerRoomResponse) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling5(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendServerRoomResponse) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling5(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendServerRoomResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling5(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling6(in *jlexer.Lexer, out *BackendServerRoomRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "type": - out.Type = string(in.String()) - case "invite": - if in.IsNull() { - in.Skip() - out.Invite = nil - } else { - if out.Invite == nil { - out.Invite = new(BackendRoomInviteRequest) - } - (*out.Invite).UnmarshalEasyJSON(in) - } - case "disinvite": - if in.IsNull() { - in.Skip() - out.Disinvite = nil - } else { - if out.Disinvite == nil { - out.Disinvite = new(BackendRoomDisinviteRequest) - } - (*out.Disinvite).UnmarshalEasyJSON(in) - } - case "update": - if in.IsNull() { - in.Skip() - out.Update = nil - } else { - if out.Update == nil { - out.Update = new(BackendRoomUpdateRequest) - } - (*out.Update).UnmarshalEasyJSON(in) - } - case "delete": - if in.IsNull() { - in.Skip() - out.Delete = nil - } else { - if out.Delete == nil { - out.Delete = new(BackendRoomDeleteRequest) - } - (*out.Delete).UnmarshalEasyJSON(in) - } - case "incall": - if in.IsNull() { - in.Skip() - out.InCall = nil - } else { - if out.InCall == nil { - out.InCall = new(BackendRoomInCallRequest) - } - (*out.InCall).UnmarshalEasyJSON(in) - } - case "participants": - if in.IsNull() { - in.Skip() - out.Participants = nil - } else { - if out.Participants == nil { - out.Participants = new(BackendRoomParticipantsRequest) - } - (*out.Participants).UnmarshalEasyJSON(in) - } - case "message": - if in.IsNull() { - in.Skip() - out.Message = nil - } else { - if out.Message == nil { - out.Message = new(BackendRoomMessageRequest) - } - (*out.Message).UnmarshalEasyJSON(in) - } - case "switchto": - if in.IsNull() { - in.Skip() - out.SwitchTo = nil - } else { - if out.SwitchTo == nil { - out.SwitchTo = new(BackendRoomSwitchToMessageRequest) - } - (*out.SwitchTo).UnmarshalEasyJSON(in) - } - case "dialout": - if in.IsNull() { - in.Skip() - out.Dialout = nil - } else { - if out.Dialout == nil { - out.Dialout = new(BackendRoomDialoutRequest) - } - (*out.Dialout).UnmarshalEasyJSON(in) - } - case "transient": - if in.IsNull() { - in.Skip() - out.Transient = nil - } else { - if out.Transient == nil { - out.Transient = new(BackendRoomTransientRequest) - } - (*out.Transient).UnmarshalEasyJSON(in) - } - case "received": - out.ReceivedTime = int64(in.Int64()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling6(out *jwriter.Writer, in BackendServerRoomRequest) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"type\":" - out.RawString(prefix[1:]) - out.String(string(in.Type)) - } - if in.Invite != nil { - const prefix string = ",\"invite\":" - out.RawString(prefix) - (*in.Invite).MarshalEasyJSON(out) - } - if in.Disinvite != nil { - const prefix string = ",\"disinvite\":" - out.RawString(prefix) - (*in.Disinvite).MarshalEasyJSON(out) - } - if in.Update != nil { - const prefix string = ",\"update\":" - out.RawString(prefix) - (*in.Update).MarshalEasyJSON(out) - } - if in.Delete != nil { - const prefix string = ",\"delete\":" - out.RawString(prefix) - (*in.Delete).MarshalEasyJSON(out) - } - if in.InCall != nil { - const prefix string = ",\"incall\":" - out.RawString(prefix) - (*in.InCall).MarshalEasyJSON(out) - } - if in.Participants != nil { - const prefix string = ",\"participants\":" - out.RawString(prefix) - (*in.Participants).MarshalEasyJSON(out) - } - if in.Message != nil { - const prefix string = ",\"message\":" - out.RawString(prefix) - (*in.Message).MarshalEasyJSON(out) - } - if in.SwitchTo != nil { - const prefix string = ",\"switchto\":" - out.RawString(prefix) - (*in.SwitchTo).MarshalEasyJSON(out) - } - if in.Dialout != nil { - const prefix string = ",\"dialout\":" - out.RawString(prefix) - (*in.Dialout).MarshalEasyJSON(out) - } - if in.Transient != nil { - const prefix string = ",\"transient\":" - out.RawString(prefix) - (*in.Transient).MarshalEasyJSON(out) - } - if in.ReceivedTime != 0 { - const prefix string = ",\"received\":" - out.RawString(prefix) - out.Int64(int64(in.ReceivedTime)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendServerRoomRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling6(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendServerRoomRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling6(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendServerRoomRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling6(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendServerRoomRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling6(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling7(in *jlexer.Lexer, out *BackendRoomUpdateRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "userids": - if in.IsNull() { - in.Skip() - out.UserIds = nil - } else { - in.Delim('[') - if out.UserIds == nil { - if !in.IsDelim(']') { - out.UserIds = make([]string, 0, 4) - } else { - out.UserIds = []string{} - } - } else { - out.UserIds = (out.UserIds)[:0] - } - for !in.IsDelim(']') { - var v4 string - v4 = string(in.String()) - out.UserIds = append(out.UserIds, v4) - in.WantComma() - } - in.Delim(']') - } - case "properties": - if data := in.Raw(); in.Ok() { - in.AddError((out.Properties).UnmarshalJSON(data)) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling7(out *jwriter.Writer, in BackendRoomUpdateRequest) { - out.RawByte('{') - first := true - _ = first - if len(in.UserIds) != 0 { - const prefix string = ",\"userids\":" - first = false - out.RawString(prefix[1:]) - { - out.RawByte('[') - for v5, v6 := range in.UserIds { - if v5 > 0 { - out.RawByte(',') - } - out.String(string(v6)) - } - out.RawByte(']') - } - } - if len(in.Properties) != 0 { - const prefix string = ",\"properties\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.Raw((in.Properties).MarshalJSON()) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomUpdateRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling7(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomUpdateRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling7(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomUpdateRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling7(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomUpdateRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling7(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlexer.Lexer, out *BackendRoomTransientRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "action": - out.Action = TransientAction(in.String()) - case "key": - out.Key = string(in.String()) - case "value": - if m, ok := out.Value.(easyjson.Unmarshaler); ok { - m.UnmarshalEasyJSON(in) - } else if m, ok := out.Value.(json.Unmarshaler); ok { - _ = m.UnmarshalJSON(in.Raw()) - } else { - out.Value = in.Interface() - } - case "ttl": - out.TTL = time.Duration(in.Int64()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling8(out *jwriter.Writer, in BackendRoomTransientRequest) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"action\":" - out.RawString(prefix[1:]) - out.String(string(in.Action)) - } - { - const prefix string = ",\"key\":" - out.RawString(prefix) - out.String(string(in.Key)) - } - if in.Value != nil { - const prefix string = ",\"value\":" - out.RawString(prefix) - if m, ok := in.Value.(easyjson.Marshaler); ok { - m.MarshalEasyJSON(out) - } else if m, ok := in.Value.(json.Marshaler); ok { - out.Raw(m.MarshalJSON()) - } else { - out.Raw(json.Marshal(in.Value)) - } - } - if in.TTL != 0 { - const prefix string = ",\"ttl\":" - out.RawString(prefix) - out.Int64(int64(in.TTL)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomTransientRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling8(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomTransientRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling8(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomTransientRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling8(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomTransientRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling8(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling9(in *jlexer.Lexer, out *BackendRoomSwitchToMessageRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "roomid": - out.RoomId = string(in.String()) - case "sessions": - if data := in.Raw(); in.Ok() { - in.AddError((out.Sessions).UnmarshalJSON(data)) - } - case "sessionslist": - if in.IsNull() { - in.Skip() - out.SessionsList = nil - } else { - in.Delim('[') - if out.SessionsList == nil { - if !in.IsDelim(']') { - out.SessionsList = make(BackendRoomSwitchToSessionsList, 0, 4) - } else { - out.SessionsList = BackendRoomSwitchToSessionsList{} - } - } else { - out.SessionsList = (out.SessionsList)[:0] - } - for !in.IsDelim(']') { - var v7 string - v7 = string(in.String()) - out.SessionsList = append(out.SessionsList, v7) - in.WantComma() - } - in.Delim(']') - } - case "sessionsmap": - if in.IsNull() { - in.Skip() - } else { - in.Delim('{') - if !in.IsDelim('}') { - out.SessionsMap = make(BackendRoomSwitchToSessionsMap) - } else { - out.SessionsMap = nil - } - for !in.IsDelim('}') { - key := string(in.String()) - in.WantColon() - var v8 json.RawMessage - if data := in.Raw(); in.Ok() { - in.AddError((v8).UnmarshalJSON(data)) - } - (out.SessionsMap)[key] = v8 - in.WantComma() - } - in.Delim('}') - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling9(out *jwriter.Writer, in BackendRoomSwitchToMessageRequest) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"roomid\":" - out.RawString(prefix[1:]) - out.String(string(in.RoomId)) - } - if len(in.Sessions) != 0 { - const prefix string = ",\"sessions\":" - out.RawString(prefix) - out.Raw((in.Sessions).MarshalJSON()) - } - if len(in.SessionsList) != 0 { - const prefix string = ",\"sessionslist\":" - out.RawString(prefix) - { - out.RawByte('[') - for v9, v10 := range in.SessionsList { - if v9 > 0 { - out.RawByte(',') - } - out.String(string(v10)) - } - out.RawByte(']') - } - } - if len(in.SessionsMap) != 0 { - const prefix string = ",\"sessionsmap\":" - out.RawString(prefix) - { - out.RawByte('{') - v11First := true - for v11Name, v11Value := range in.SessionsMap { - if v11First { - v11First = false - } else { - out.RawByte(',') - } - out.String(string(v11Name)) - out.RawByte(':') - out.Raw((v11Value).MarshalJSON()) - } - out.RawByte('}') - } - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomSwitchToMessageRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling9(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomSwitchToMessageRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling9(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomSwitchToMessageRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling9(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomSwitchToMessageRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling9(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling10(in *jlexer.Lexer, out *BackendRoomParticipantsRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "changed": - if in.IsNull() { - in.Skip() - out.Changed = nil - } else { - in.Delim('[') - if out.Changed == nil { - if !in.IsDelim(']') { - out.Changed = make([]map[string]interface{}, 0, 8) - } else { - out.Changed = []map[string]interface{}{} - } - } else { - out.Changed = (out.Changed)[:0] - } - for !in.IsDelim(']') { - var v12 map[string]interface{} - if in.IsNull() { - in.Skip() - } else { - in.Delim('{') - if !in.IsDelim('}') { - v12 = make(map[string]interface{}) - } else { - v12 = nil - } - for !in.IsDelim('}') { - key := string(in.String()) - in.WantColon() - var v13 interface{} - if m, ok := v13.(easyjson.Unmarshaler); ok { - m.UnmarshalEasyJSON(in) - } else if m, ok := v13.(json.Unmarshaler); ok { - _ = m.UnmarshalJSON(in.Raw()) - } else { - v13 = in.Interface() - } - (v12)[key] = v13 - in.WantComma() - } - in.Delim('}') - } - out.Changed = append(out.Changed, v12) - in.WantComma() - } - in.Delim(']') - } - case "users": - if in.IsNull() { - in.Skip() - out.Users = nil - } else { - in.Delim('[') - if out.Users == nil { - if !in.IsDelim(']') { - out.Users = make([]map[string]interface{}, 0, 8) - } else { - out.Users = []map[string]interface{}{} - } - } else { - out.Users = (out.Users)[:0] - } - for !in.IsDelim(']') { - var v14 map[string]interface{} - if in.IsNull() { - in.Skip() - } else { - in.Delim('{') - if !in.IsDelim('}') { - v14 = make(map[string]interface{}) - } else { - v14 = nil - } - for !in.IsDelim('}') { - key := string(in.String()) - in.WantColon() - var v15 interface{} - if m, ok := v15.(easyjson.Unmarshaler); ok { - m.UnmarshalEasyJSON(in) - } else if m, ok := v15.(json.Unmarshaler); ok { - _ = m.UnmarshalJSON(in.Raw()) - } else { - v15 = in.Interface() - } - (v14)[key] = v15 - in.WantComma() - } - in.Delim('}') - } - out.Users = append(out.Users, v14) - in.WantComma() - } - in.Delim(']') - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling10(out *jwriter.Writer, in BackendRoomParticipantsRequest) { - out.RawByte('{') - first := true - _ = first - if len(in.Changed) != 0 { - const prefix string = ",\"changed\":" - first = false - out.RawString(prefix[1:]) - { - out.RawByte('[') - for v16, v17 := range in.Changed { - if v16 > 0 { - out.RawByte(',') - } - if v17 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { - out.RawString(`null`) - } else { - out.RawByte('{') - v18First := true - for v18Name, v18Value := range v17 { - if v18First { - v18First = false - } else { - out.RawByte(',') - } - out.String(string(v18Name)) - out.RawByte(':') - if m, ok := v18Value.(easyjson.Marshaler); ok { - m.MarshalEasyJSON(out) - } else if m, ok := v18Value.(json.Marshaler); ok { - out.Raw(m.MarshalJSON()) - } else { - out.Raw(json.Marshal(v18Value)) - } - } - out.RawByte('}') - } - } - out.RawByte(']') - } - } - if len(in.Users) != 0 { - const prefix string = ",\"users\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - { - out.RawByte('[') - for v19, v20 := range in.Users { - if v19 > 0 { - out.RawByte(',') - } - if v20 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { - out.RawString(`null`) - } else { - out.RawByte('{') - v21First := true - for v21Name, v21Value := range v20 { - if v21First { - v21First = false - } else { - out.RawByte(',') - } - out.String(string(v21Name)) - out.RawByte(':') - if m, ok := v21Value.(easyjson.Marshaler); ok { - m.MarshalEasyJSON(out) - } else if m, ok := v21Value.(json.Marshaler); ok { - out.Raw(m.MarshalJSON()) - } else { - out.Raw(json.Marshal(v21Value)) - } - } - out.RawByte('}') - } - } - out.RawByte(']') - } - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomParticipantsRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling10(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomParticipantsRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling10(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomParticipantsRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling10(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomParticipantsRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling10(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling11(in *jlexer.Lexer, out *BackendRoomMessageRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "data": - if data := in.Raw(); in.Ok() { - in.AddError((out.Data).UnmarshalJSON(data)) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling11(out *jwriter.Writer, in BackendRoomMessageRequest) { - out.RawByte('{') - first := true - _ = first - if len(in.Data) != 0 { - const prefix string = ",\"data\":" - first = false - out.RawString(prefix[1:]) - out.Raw((in.Data).MarshalJSON()) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomMessageRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling11(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomMessageRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling11(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomMessageRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling11(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomMessageRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling11(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling12(in *jlexer.Lexer, out *BackendRoomInviteRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "userids": - if in.IsNull() { - in.Skip() - out.UserIds = nil - } else { - in.Delim('[') - if out.UserIds == nil { - if !in.IsDelim(']') { - out.UserIds = make([]string, 0, 4) - } else { - out.UserIds = []string{} - } - } else { - out.UserIds = (out.UserIds)[:0] - } - for !in.IsDelim(']') { - var v22 string - v22 = string(in.String()) - out.UserIds = append(out.UserIds, v22) - in.WantComma() - } - in.Delim(']') - } - case "alluserids": - if in.IsNull() { - in.Skip() - out.AllUserIds = nil - } else { - in.Delim('[') - if out.AllUserIds == nil { - if !in.IsDelim(']') { - out.AllUserIds = make([]string, 0, 4) - } else { - out.AllUserIds = []string{} - } - } else { - out.AllUserIds = (out.AllUserIds)[:0] - } - for !in.IsDelim(']') { - var v23 string - v23 = string(in.String()) - out.AllUserIds = append(out.AllUserIds, v23) - in.WantComma() - } - in.Delim(']') - } - case "properties": - if data := in.Raw(); in.Ok() { - in.AddError((out.Properties).UnmarshalJSON(data)) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling12(out *jwriter.Writer, in BackendRoomInviteRequest) { - out.RawByte('{') - first := true - _ = first - if len(in.UserIds) != 0 { - const prefix string = ",\"userids\":" - first = false - out.RawString(prefix[1:]) - { - out.RawByte('[') - for v24, v25 := range in.UserIds { - if v24 > 0 { - out.RawByte(',') - } - out.String(string(v25)) - } - out.RawByte(']') - } - } - if len(in.AllUserIds) != 0 { - const prefix string = ",\"alluserids\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - { - out.RawByte('[') - for v26, v27 := range in.AllUserIds { - if v26 > 0 { - out.RawByte(',') - } - out.String(string(v27)) - } - out.RawByte(']') - } - } - if len(in.Properties) != 0 { - const prefix string = ",\"properties\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.Raw((in.Properties).MarshalJSON()) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomInviteRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling12(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomInviteRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling12(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomInviteRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling12(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomInviteRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling12(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling13(in *jlexer.Lexer, out *BackendRoomInCallRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "incall": - if data := in.Raw(); in.Ok() { - in.AddError((out.InCall).UnmarshalJSON(data)) - } - case "all": - out.All = bool(in.Bool()) - case "changed": - if in.IsNull() { - in.Skip() - out.Changed = nil - } else { - in.Delim('[') - if out.Changed == nil { - if !in.IsDelim(']') { - out.Changed = make([]map[string]interface{}, 0, 8) - } else { - out.Changed = []map[string]interface{}{} - } - } else { - out.Changed = (out.Changed)[:0] - } - for !in.IsDelim(']') { - var v28 map[string]interface{} - if in.IsNull() { - in.Skip() - } else { - in.Delim('{') - if !in.IsDelim('}') { - v28 = make(map[string]interface{}) - } else { - v28 = nil - } - for !in.IsDelim('}') { - key := string(in.String()) - in.WantColon() - var v29 interface{} - if m, ok := v29.(easyjson.Unmarshaler); ok { - m.UnmarshalEasyJSON(in) - } else if m, ok := v29.(json.Unmarshaler); ok { - _ = m.UnmarshalJSON(in.Raw()) - } else { - v29 = in.Interface() - } - (v28)[key] = v29 - in.WantComma() - } - in.Delim('}') - } - out.Changed = append(out.Changed, v28) - in.WantComma() - } - in.Delim(']') - } - case "users": - if in.IsNull() { - in.Skip() - out.Users = nil - } else { - in.Delim('[') - if out.Users == nil { - if !in.IsDelim(']') { - out.Users = make([]map[string]interface{}, 0, 8) - } else { - out.Users = []map[string]interface{}{} - } - } else { - out.Users = (out.Users)[:0] - } - for !in.IsDelim(']') { - var v30 map[string]interface{} - if in.IsNull() { - in.Skip() - } else { - in.Delim('{') - if !in.IsDelim('}') { - v30 = make(map[string]interface{}) - } else { - v30 = nil - } - for !in.IsDelim('}') { - key := string(in.String()) - in.WantColon() - var v31 interface{} - if m, ok := v31.(easyjson.Unmarshaler); ok { - m.UnmarshalEasyJSON(in) - } else if m, ok := v31.(json.Unmarshaler); ok { - _ = m.UnmarshalJSON(in.Raw()) - } else { - v31 = in.Interface() - } - (v30)[key] = v31 - in.WantComma() - } - in.Delim('}') - } - out.Users = append(out.Users, v30) - in.WantComma() - } - in.Delim(']') - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling13(out *jwriter.Writer, in BackendRoomInCallRequest) { - out.RawByte('{') - first := true - _ = first - if len(in.InCall) != 0 { - const prefix string = ",\"incall\":" - first = false - out.RawString(prefix[1:]) - out.Raw((in.InCall).MarshalJSON()) - } - if in.All { - const prefix string = ",\"all\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.Bool(bool(in.All)) - } - if len(in.Changed) != 0 { - const prefix string = ",\"changed\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - { - out.RawByte('[') - for v32, v33 := range in.Changed { - if v32 > 0 { - out.RawByte(',') - } - if v33 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { - out.RawString(`null`) - } else { - out.RawByte('{') - v34First := true - for v34Name, v34Value := range v33 { - if v34First { - v34First = false - } else { - out.RawByte(',') - } - out.String(string(v34Name)) - out.RawByte(':') - if m, ok := v34Value.(easyjson.Marshaler); ok { - m.MarshalEasyJSON(out) - } else if m, ok := v34Value.(json.Marshaler); ok { - out.Raw(m.MarshalJSON()) - } else { - out.Raw(json.Marshal(v34Value)) - } - } - out.RawByte('}') - } - } - out.RawByte(']') - } - } - if len(in.Users) != 0 { - const prefix string = ",\"users\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - { - out.RawByte('[') - for v35, v36 := range in.Users { - if v35 > 0 { - out.RawByte(',') - } - if v36 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { - out.RawString(`null`) - } else { - out.RawByte('{') - v37First := true - for v37Name, v37Value := range v36 { - if v37First { - v37First = false - } else { - out.RawByte(',') - } - out.String(string(v37Name)) - out.RawByte(':') - if m, ok := v37Value.(easyjson.Marshaler); ok { - m.MarshalEasyJSON(out) - } else if m, ok := v37Value.(json.Marshaler); ok { - out.Raw(m.MarshalJSON()) - } else { - out.Raw(json.Marshal(v37Value)) - } - } - out.RawByte('}') - } - } - out.RawByte(']') - } - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomInCallRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling13(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomInCallRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling13(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomInCallRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling13(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomInCallRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling13(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling14(in *jlexer.Lexer, out *BackendRoomDisinviteRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "userids": - if in.IsNull() { - in.Skip() - out.UserIds = nil - } else { - in.Delim('[') - if out.UserIds == nil { - if !in.IsDelim(']') { - out.UserIds = make([]string, 0, 4) - } else { - out.UserIds = []string{} - } - } else { - out.UserIds = (out.UserIds)[:0] - } - for !in.IsDelim(']') { - var v38 string - v38 = string(in.String()) - out.UserIds = append(out.UserIds, v38) - in.WantComma() - } - in.Delim(']') - } - case "sessionids": - if in.IsNull() { - in.Skip() - out.SessionIds = nil - } else { - in.Delim('[') - if out.SessionIds == nil { - if !in.IsDelim(']') { - out.SessionIds = make([]string, 0, 4) - } else { - out.SessionIds = []string{} - } - } else { - out.SessionIds = (out.SessionIds)[:0] - } - for !in.IsDelim(']') { - var v39 string - v39 = string(in.String()) - out.SessionIds = append(out.SessionIds, v39) - in.WantComma() - } - in.Delim(']') - } - case "alluserids": - if in.IsNull() { - in.Skip() - out.AllUserIds = nil - } else { - in.Delim('[') - if out.AllUserIds == nil { - if !in.IsDelim(']') { - out.AllUserIds = make([]string, 0, 4) - } else { - out.AllUserIds = []string{} - } - } else { - out.AllUserIds = (out.AllUserIds)[:0] - } - for !in.IsDelim(']') { - var v40 string - v40 = string(in.String()) - out.AllUserIds = append(out.AllUserIds, v40) - in.WantComma() - } - in.Delim(']') - } - case "properties": - if data := in.Raw(); in.Ok() { - in.AddError((out.Properties).UnmarshalJSON(data)) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling14(out *jwriter.Writer, in BackendRoomDisinviteRequest) { - out.RawByte('{') - first := true - _ = first - if len(in.UserIds) != 0 { - const prefix string = ",\"userids\":" - first = false - out.RawString(prefix[1:]) - { - out.RawByte('[') - for v41, v42 := range in.UserIds { - if v41 > 0 { - out.RawByte(',') - } - out.String(string(v42)) - } - out.RawByte(']') - } - } - if len(in.SessionIds) != 0 { - const prefix string = ",\"sessionids\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - { - out.RawByte('[') - for v43, v44 := range in.SessionIds { - if v43 > 0 { - out.RawByte(',') - } - out.String(string(v44)) - } - out.RawByte(']') - } - } - if len(in.AllUserIds) != 0 { - const prefix string = ",\"alluserids\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - { - out.RawByte('[') - for v45, v46 := range in.AllUserIds { - if v45 > 0 { - out.RawByte(',') - } - out.String(string(v46)) - } - out.RawByte(']') - } - } - if len(in.Properties) != 0 { - const prefix string = ",\"properties\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.Raw((in.Properties).MarshalJSON()) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomDisinviteRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling14(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomDisinviteRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling14(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomDisinviteRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling14(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomDisinviteRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling14(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling15(in *jlexer.Lexer, out *BackendRoomDialoutResponse) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "callid": - out.CallId = string(in.String()) - case "error": - if in.IsNull() { - in.Skip() - out.Error = nil - } else { - if out.Error == nil { - out.Error = new(Error) - } - (*out.Error).UnmarshalEasyJSON(in) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling15(out *jwriter.Writer, in BackendRoomDialoutResponse) { - out.RawByte('{') - first := true - _ = first - if in.CallId != "" { - const prefix string = ",\"callid\":" - first = false - out.RawString(prefix[1:]) - out.String(string(in.CallId)) - } - if in.Error != nil { - const prefix string = ",\"error\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - (*in.Error).MarshalEasyJSON(out) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomDialoutResponse) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling15(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomDialoutResponse) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling15(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomDialoutResponse) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling15(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomDialoutResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling15(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling16(in *jlexer.Lexer, out *BackendRoomDialoutRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "number": - out.Number = string(in.String()) - case "options": - if data := in.Raw(); in.Ok() { - in.AddError((out.Options).UnmarshalJSON(data)) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling16(out *jwriter.Writer, in BackendRoomDialoutRequest) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"number\":" - out.RawString(prefix[1:]) - out.String(string(in.Number)) - } - if len(in.Options) != 0 { - const prefix string = ",\"options\":" - out.RawString(prefix) - out.Raw((in.Options).MarshalJSON()) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomDialoutRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling16(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomDialoutRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling16(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomDialoutRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling16(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomDialoutRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling16(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling17(in *jlexer.Lexer, out *BackendRoomDialoutError) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "code": - out.Code = string(in.String()) - case "message": - out.Message = string(in.String()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling17(out *jwriter.Writer, in BackendRoomDialoutError) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"code\":" - out.RawString(prefix[1:]) - out.String(string(in.Code)) - } - if in.Message != "" { - const prefix string = ",\"message\":" - out.RawString(prefix) - out.String(string(in.Message)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomDialoutError) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling17(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomDialoutError) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling17(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomDialoutError) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling17(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomDialoutError) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling17(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling18(in *jlexer.Lexer, out *BackendRoomDeleteRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "userids": - if in.IsNull() { - in.Skip() - out.UserIds = nil - } else { - in.Delim('[') - if out.UserIds == nil { - if !in.IsDelim(']') { - out.UserIds = make([]string, 0, 4) - } else { - out.UserIds = []string{} - } - } else { - out.UserIds = (out.UserIds)[:0] - } - for !in.IsDelim(']') { - var v47 string - v47 = string(in.String()) - out.UserIds = append(out.UserIds, v47) - in.WantComma() - } - in.Delim(']') - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling18(out *jwriter.Writer, in BackendRoomDeleteRequest) { - out.RawByte('{') - first := true - _ = first - if len(in.UserIds) != 0 { - const prefix string = ",\"userids\":" - first = false - out.RawString(prefix[1:]) - { - out.RawByte('[') - for v48, v49 := range in.UserIds { - if v48 > 0 { - out.RawByte(',') - } - out.String(string(v49)) - } - out.RawByte(']') - } - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendRoomDeleteRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling18(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendRoomDeleteRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling18(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendRoomDeleteRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling18(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendRoomDeleteRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling18(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling19(in *jlexer.Lexer, out *BackendPingEntry) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "userid": - out.UserId = string(in.String()) - case "sessionid": - out.SessionId = string(in.String()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling19(out *jwriter.Writer, in BackendPingEntry) { - out.RawByte('{') - first := true - _ = first - if in.UserId != "" { - const prefix string = ",\"userid\":" - first = false - out.RawString(prefix[1:]) - out.String(string(in.UserId)) - } - { - const prefix string = ",\"sessionid\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.String(string(in.SessionId)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendPingEntry) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling19(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendPingEntry) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling19(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendPingEntry) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling19(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendPingEntry) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling19(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling20(in *jlexer.Lexer, out *BackendInformationEtcd) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "url": - out.Url = string(in.String()) - case "secret": - out.Secret = string(in.String()) - case "maxstreambitrate": - out.MaxStreamBitrate = int(in.Int()) - case "maxscreenbitrate": - out.MaxScreenBitrate = int(in.Int()) - case "sessionlimit": - out.SessionLimit = uint64(in.Uint64()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling20(out *jwriter.Writer, in BackendInformationEtcd) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"url\":" - out.RawString(prefix[1:]) - out.String(string(in.Url)) - } - { - const prefix string = ",\"secret\":" - out.RawString(prefix) - out.String(string(in.Secret)) - } - if in.MaxStreamBitrate != 0 { - const prefix string = ",\"maxstreambitrate\":" - out.RawString(prefix) - out.Int(int(in.MaxStreamBitrate)) - } - if in.MaxScreenBitrate != 0 { - const prefix string = ",\"maxscreenbitrate\":" - out.RawString(prefix) - out.Int(int(in.MaxScreenBitrate)) - } - if in.SessionLimit != 0 { - const prefix string = ",\"sessionlimit\":" - out.RawString(prefix) - out.Uint64(uint64(in.SessionLimit)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendInformationEtcd) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling20(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendInformationEtcd) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling20(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendInformationEtcd) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling20(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendInformationEtcd) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling20(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling21(in *jlexer.Lexer, out *BackendClientSessionResponse) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "version": - out.Version = string(in.String()) - case "roomid": - out.RoomId = string(in.String()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling21(out *jwriter.Writer, in BackendClientSessionResponse) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"version\":" - out.RawString(prefix[1:]) - out.String(string(in.Version)) - } - { - const prefix string = ",\"roomid\":" - out.RawString(prefix) - out.String(string(in.RoomId)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendClientSessionResponse) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling21(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendClientSessionResponse) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling21(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendClientSessionResponse) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling21(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendClientSessionResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling21(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling22(in *jlexer.Lexer, out *BackendClientSessionRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "version": - out.Version = string(in.String()) - case "roomid": - out.RoomId = string(in.String()) - case "action": - out.Action = string(in.String()) - case "sessionid": - out.SessionId = string(in.String()) - case "userid": - out.UserId = string(in.String()) - case "user": - if data := in.Raw(); in.Ok() { - in.AddError((out.User).UnmarshalJSON(data)) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling22(out *jwriter.Writer, in BackendClientSessionRequest) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"version\":" - out.RawString(prefix[1:]) - out.String(string(in.Version)) - } - { - const prefix string = ",\"roomid\":" - out.RawString(prefix) - out.String(string(in.RoomId)) - } - { - const prefix string = ",\"action\":" - out.RawString(prefix) - out.String(string(in.Action)) - } - { - const prefix string = ",\"sessionid\":" - out.RawString(prefix) - out.String(string(in.SessionId)) - } - if in.UserId != "" { - const prefix string = ",\"userid\":" - out.RawString(prefix) - out.String(string(in.UserId)) - } - if len(in.User) != 0 { - const prefix string = ",\"user\":" - out.RawString(prefix) - out.Raw((in.User).MarshalJSON()) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendClientSessionRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling22(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendClientSessionRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling22(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendClientSessionRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling22(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendClientSessionRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling22(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling23(in *jlexer.Lexer, out *BackendClientRoomResponse) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "version": - out.Version = string(in.String()) - case "roomid": - out.RoomId = string(in.String()) - case "properties": - if data := in.Raw(); in.Ok() { - in.AddError((out.Properties).UnmarshalJSON(data)) - } - case "session": - if data := in.Raw(); in.Ok() { - in.AddError((out.Session).UnmarshalJSON(data)) - } - case "permissions": - if in.IsNull() { - in.Skip() - out.Permissions = nil - } else { - if out.Permissions == nil { - out.Permissions = new([]Permission) - } - if in.IsNull() { - in.Skip() - *out.Permissions = nil - } else { - in.Delim('[') - if *out.Permissions == nil { - if !in.IsDelim(']') { - *out.Permissions = make([]Permission, 0, 4) - } else { - *out.Permissions = []Permission{} - } - } else { - *out.Permissions = (*out.Permissions)[:0] - } - for !in.IsDelim(']') { - var v50 Permission - v50 = Permission(in.String()) - *out.Permissions = append(*out.Permissions, v50) - in.WantComma() - } - in.Delim(']') - } - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling23(out *jwriter.Writer, in BackendClientRoomResponse) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"version\":" - out.RawString(prefix[1:]) - out.String(string(in.Version)) - } - { - const prefix string = ",\"roomid\":" - out.RawString(prefix) - out.String(string(in.RoomId)) - } - { - const prefix string = ",\"properties\":" - out.RawString(prefix) - out.Raw((in.Properties).MarshalJSON()) - } - if len(in.Session) != 0 { - const prefix string = ",\"session\":" - out.RawString(prefix) - out.Raw((in.Session).MarshalJSON()) - } - if in.Permissions != nil { - const prefix string = ",\"permissions\":" - out.RawString(prefix) - if *in.Permissions == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { - out.RawString("null") - } else { - out.RawByte('[') - for v51, v52 := range *in.Permissions { - if v51 > 0 { - out.RawByte(',') - } - out.String(string(v52)) - } - out.RawByte(']') - } - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendClientRoomResponse) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling23(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendClientRoomResponse) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling23(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendClientRoomResponse) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling23(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendClientRoomResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling23(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling24(in *jlexer.Lexer, out *BackendClientRoomRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "version": - out.Version = string(in.String()) - case "roomid": - out.RoomId = string(in.String()) - case "action": - out.Action = string(in.String()) - case "userid": - out.UserId = string(in.String()) - case "sessionid": - out.SessionId = string(in.String()) - case "actorid": - out.ActorId = string(in.String()) - case "actortype": - out.ActorType = string(in.String()) - case "incall": - out.InCall = int(in.Int()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling24(out *jwriter.Writer, in BackendClientRoomRequest) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"version\":" - out.RawString(prefix[1:]) - out.String(string(in.Version)) - } - { - const prefix string = ",\"roomid\":" - out.RawString(prefix) - out.String(string(in.RoomId)) - } - if in.Action != "" { - const prefix string = ",\"action\":" - out.RawString(prefix) - out.String(string(in.Action)) - } - { - const prefix string = ",\"userid\":" - out.RawString(prefix) - out.String(string(in.UserId)) - } - { - const prefix string = ",\"sessionid\":" - out.RawString(prefix) - out.String(string(in.SessionId)) - } - if in.ActorId != "" { - const prefix string = ",\"actorid\":" - out.RawString(prefix) - out.String(string(in.ActorId)) - } - if in.ActorType != "" { - const prefix string = ",\"actortype\":" - out.RawString(prefix) - out.String(string(in.ActorType)) - } - if in.InCall != 0 { - const prefix string = ",\"incall\":" - out.RawString(prefix) - out.Int(int(in.InCall)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendClientRoomRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling24(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendClientRoomRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling24(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendClientRoomRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling24(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendClientRoomRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling24(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling25(in *jlexer.Lexer, out *BackendClientRingResponse) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "version": - out.Version = string(in.String()) - case "roomid": - out.RoomId = string(in.String()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling25(out *jwriter.Writer, in BackendClientRingResponse) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"version\":" - out.RawString(prefix[1:]) - out.String(string(in.Version)) - } - { - const prefix string = ",\"roomid\":" - out.RawString(prefix) - out.String(string(in.RoomId)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendClientRingResponse) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling25(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendClientRingResponse) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling25(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendClientRingResponse) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling25(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendClientRingResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling25(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling26(in *jlexer.Lexer, out *BackendClientResponse) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "type": - out.Type = string(in.String()) - case "error": - if in.IsNull() { - in.Skip() - out.Error = nil - } else { - if out.Error == nil { - out.Error = new(Error) - } - (*out.Error).UnmarshalEasyJSON(in) - } - case "auth": - if in.IsNull() { - in.Skip() - out.Auth = nil - } else { - if out.Auth == nil { - out.Auth = new(BackendClientAuthResponse) - } - (*out.Auth).UnmarshalEasyJSON(in) - } - case "room": - if in.IsNull() { - in.Skip() - out.Room = nil - } else { - if out.Room == nil { - out.Room = new(BackendClientRoomResponse) - } - (*out.Room).UnmarshalEasyJSON(in) - } - case "ping": - if in.IsNull() { - in.Skip() - out.Ping = nil - } else { - if out.Ping == nil { - out.Ping = new(BackendClientRingResponse) - } - (*out.Ping).UnmarshalEasyJSON(in) - } - case "session": - if in.IsNull() { - in.Skip() - out.Session = nil - } else { - if out.Session == nil { - out.Session = new(BackendClientSessionResponse) - } - (*out.Session).UnmarshalEasyJSON(in) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling26(out *jwriter.Writer, in BackendClientResponse) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"type\":" - out.RawString(prefix[1:]) - out.String(string(in.Type)) - } - if in.Error != nil { - const prefix string = ",\"error\":" - out.RawString(prefix) - (*in.Error).MarshalEasyJSON(out) - } - if in.Auth != nil { - const prefix string = ",\"auth\":" - out.RawString(prefix) - (*in.Auth).MarshalEasyJSON(out) - } - if in.Room != nil { - const prefix string = ",\"room\":" - out.RawString(prefix) - (*in.Room).MarshalEasyJSON(out) - } - if in.Ping != nil { - const prefix string = ",\"ping\":" - out.RawString(prefix) - (*in.Ping).MarshalEasyJSON(out) - } - if in.Session != nil { - const prefix string = ",\"session\":" - out.RawString(prefix) - (*in.Session).MarshalEasyJSON(out) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendClientResponse) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling26(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendClientResponse) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling26(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendClientResponse) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling26(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendClientResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling26(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling27(in *jlexer.Lexer, out *BackendClientRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "type": - out.Type = string(in.String()) - case "auth": - if in.IsNull() { - in.Skip() - out.Auth = nil - } else { - if out.Auth == nil { - out.Auth = new(BackendClientAuthRequest) - } - (*out.Auth).UnmarshalEasyJSON(in) - } - case "room": - if in.IsNull() { - in.Skip() - out.Room = nil - } else { - if out.Room == nil { - out.Room = new(BackendClientRoomRequest) - } - (*out.Room).UnmarshalEasyJSON(in) - } - case "ping": - if in.IsNull() { - in.Skip() - out.Ping = nil - } else { - if out.Ping == nil { - out.Ping = new(BackendClientPingRequest) - } - (*out.Ping).UnmarshalEasyJSON(in) - } - case "session": - if in.IsNull() { - in.Skip() - out.Session = nil - } else { - if out.Session == nil { - out.Session = new(BackendClientSessionRequest) - } - (*out.Session).UnmarshalEasyJSON(in) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling27(out *jwriter.Writer, in BackendClientRequest) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"type\":" - out.RawString(prefix[1:]) - out.String(string(in.Type)) - } - if in.Auth != nil { - const prefix string = ",\"auth\":" - out.RawString(prefix) - (*in.Auth).MarshalEasyJSON(out) - } - if in.Room != nil { - const prefix string = ",\"room\":" - out.RawString(prefix) - (*in.Room).MarshalEasyJSON(out) - } - if in.Ping != nil { - const prefix string = ",\"ping\":" - out.RawString(prefix) - (*in.Ping).MarshalEasyJSON(out) - } - if in.Session != nil { - const prefix string = ",\"session\":" - out.RawString(prefix) - (*in.Session).MarshalEasyJSON(out) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendClientRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling27(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendClientRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling27(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendClientRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling27(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendClientRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling27(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling28(in *jlexer.Lexer, out *BackendClientPingRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "version": - out.Version = string(in.String()) - case "roomid": - out.RoomId = string(in.String()) - case "entries": - if in.IsNull() { - in.Skip() - out.Entries = nil - } else { - in.Delim('[') - if out.Entries == nil { - if !in.IsDelim(']') { - out.Entries = make([]BackendPingEntry, 0, 2) - } else { - out.Entries = []BackendPingEntry{} - } - } else { - out.Entries = (out.Entries)[:0] - } - for !in.IsDelim(']') { - var v53 BackendPingEntry - (v53).UnmarshalEasyJSON(in) - out.Entries = append(out.Entries, v53) - in.WantComma() - } - in.Delim(']') - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling28(out *jwriter.Writer, in BackendClientPingRequest) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"version\":" - out.RawString(prefix[1:]) - out.String(string(in.Version)) - } - { - const prefix string = ",\"roomid\":" - out.RawString(prefix) - out.String(string(in.RoomId)) - } - { - const prefix string = ",\"entries\":" - out.RawString(prefix) - if in.Entries == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { - out.RawString("null") - } else { - out.RawByte('[') - for v54, v55 := range in.Entries { - if v54 > 0 { - out.RawByte(',') - } - (v55).MarshalEasyJSON(out) - } - out.RawByte(']') - } - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendClientPingRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling28(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendClientPingRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling28(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendClientPingRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling28(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendClientPingRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling28(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling29(in *jlexer.Lexer, out *BackendClientAuthResponse) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "version": - out.Version = string(in.String()) - case "userid": - out.UserId = string(in.String()) - case "user": - if data := in.Raw(); in.Ok() { - in.AddError((out.User).UnmarshalJSON(data)) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling29(out *jwriter.Writer, in BackendClientAuthResponse) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"version\":" - out.RawString(prefix[1:]) - out.String(string(in.Version)) - } - { - const prefix string = ",\"userid\":" - out.RawString(prefix) - out.String(string(in.UserId)) - } - { - const prefix string = ",\"user\":" - out.RawString(prefix) - out.Raw((in.User).MarshalJSON()) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendClientAuthResponse) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling29(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendClientAuthResponse) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling29(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendClientAuthResponse) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling29(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendClientAuthResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling29(l, v) -} -func easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling30(in *jlexer.Lexer, out *BackendClientAuthRequest) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "version": - out.Version = string(in.String()) - case "params": - if data := in.Raw(); in.Ok() { - in.AddError((out.Params).UnmarshalJSON(data)) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling30(out *jwriter.Writer, in BackendClientAuthRequest) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"version\":" - out.RawString(prefix[1:]) - out.String(string(in.Version)) - } - { - const prefix string = ",\"params\":" - out.RawString(prefix) - out.Raw((in.Params).MarshalJSON()) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v BackendClientAuthRequest) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling30(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v BackendClientAuthRequest) MarshalEasyJSON(w *jwriter.Writer) { - easyjson4354c623EncodeGithubComStrukturagNextcloudSpreedSignaling30(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *BackendClientAuthRequest) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling30(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *BackendClientAuthRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson4354c623DecodeGithubComStrukturagNextcloudSpreedSignaling30(l, v) -} diff --git a/api_signaling_test.go b/api_signaling_test.go deleted file mode 100644 index b33169b..0000000 --- a/api_signaling_test.go +++ /dev/null @@ -1,426 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "encoding/json" - "fmt" - "sort" - "testing" - - "github.com/stretchr/testify/assert" -) - -type testCheckValid interface { - CheckValid() error -} - -func wrapMessage(messageType string, msg testCheckValid) *ClientMessage { - wrapped := &ClientMessage{ - Type: messageType, - } - switch messageType { - case "hello": - wrapped.Hello = msg.(*HelloClientMessage) - case "message": - wrapped.Message = msg.(*MessageClientMessage) - case "bye": - wrapped.Bye = msg.(*ByeClientMessage) - case "room": - wrapped.Room = msg.(*RoomClientMessage) - default: - return nil - } - return wrapped -} - -func testMessages(t *testing.T, messageType string, valid_messages []testCheckValid, invalid_messages []testCheckValid) { - t.Helper() - assert := assert.New(t) - for _, msg := range valid_messages { - assert.NoError(msg.CheckValid(), "Message %+v should be valid", msg) - - // If the inner message is valid, it should also be valid in a wrapped - // ClientMessage. - if wrapped := wrapMessage(messageType, msg); assert.NotNil(wrapped, "Unknown message type: %s", messageType) { - assert.NoError(wrapped.CheckValid(), "Message %+v should be valid", wrapped) - } - } - for _, msg := range invalid_messages { - assert.Error(msg.CheckValid(), "Message %+v should not be valid", msg) - - // If the inner message is invalid, it should also be invalid in a - // wrapped ClientMessage. - if wrapped := wrapMessage(messageType, msg); assert.NotNil(wrapped, "Unknown message type: %s", messageType) { - assert.Error(wrapped.CheckValid(), "Message %+v should not be valid", wrapped) - } - } -} - -func TestClientMessage(t *testing.T) { - t.Parallel() - assert := assert.New(t) - // The message needs a type. - msg := ClientMessage{} - assert.Error(msg.CheckValid()) -} - -func TestHelloClientMessage(t *testing.T) { - t.Parallel() - internalAuthParams := []byte("{\"backend\":\"https://domain.invalid\"}") - tokenAuthParams := []byte("{\"token\":\"invalid-token\"}") - valid_messages := []testCheckValid{ - // Hello version 1 - &HelloClientMessage{ - Version: HelloVersionV1, - Auth: &HelloClientMessageAuth{ - Params: json.RawMessage("{}"), - Url: "https://domain.invalid", - }, - }, - &HelloClientMessage{ - Version: HelloVersionV1, - Auth: &HelloClientMessageAuth{ - Type: "client", - Params: json.RawMessage("{}"), - Url: "https://domain.invalid", - }, - }, - &HelloClientMessage{ - Version: HelloVersionV1, - Auth: &HelloClientMessageAuth{ - Type: "internal", - Params: internalAuthParams, - }, - }, - &HelloClientMessage{ - Version: HelloVersionV1, - ResumeId: "the-resume-id", - }, - // Hello version 2 - &HelloClientMessage{ - Version: HelloVersionV2, - Auth: &HelloClientMessageAuth{ - Params: tokenAuthParams, - Url: "https://domain.invalid", - }, - }, - &HelloClientMessage{ - Version: HelloVersionV2, - Auth: &HelloClientMessageAuth{ - Type: "client", - Params: tokenAuthParams, - Url: "https://domain.invalid", - }, - }, - &HelloClientMessage{ - Version: HelloVersionV2, - ResumeId: "the-resume-id", - }, - } - invalid_messages := []testCheckValid{ - // Hello version 1 - &HelloClientMessage{}, - &HelloClientMessage{Version: "0.0"}, - &HelloClientMessage{Version: HelloVersionV1}, - &HelloClientMessage{ - Version: HelloVersionV1, - Auth: &HelloClientMessageAuth{ - Params: json.RawMessage("{}"), - Type: "invalid-type", - }, - }, - &HelloClientMessage{ - Version: HelloVersionV1, - Auth: &HelloClientMessageAuth{ - Url: "https://domain.invalid", - }, - }, - &HelloClientMessage{ - Version: HelloVersionV1, - Auth: &HelloClientMessageAuth{ - Params: json.RawMessage("{}"), - }, - }, - &HelloClientMessage{ - Version: HelloVersionV1, - Auth: &HelloClientMessageAuth{ - Params: json.RawMessage("{}"), - Url: "invalid-url", - }, - }, - &HelloClientMessage{ - Version: HelloVersionV1, - Auth: &HelloClientMessageAuth{ - Type: "internal", - Params: json.RawMessage("{}"), - }, - }, - &HelloClientMessage{ - Version: HelloVersionV1, - Auth: &HelloClientMessageAuth{ - Type: "internal", - Params: json.RawMessage("xyz"), // Invalid JSON. - }, - }, - // Hello version 2 - &HelloClientMessage{ - Version: HelloVersionV2, - Auth: &HelloClientMessageAuth{ - Url: "https://domain.invalid", - }, - }, - &HelloClientMessage{ - Version: HelloVersionV2, - Auth: &HelloClientMessageAuth{ - Params: tokenAuthParams, - }, - }, - &HelloClientMessage{ - Version: HelloVersionV2, - Auth: &HelloClientMessageAuth{ - Params: tokenAuthParams, - Url: "invalid-url", - }, - }, - &HelloClientMessage{ - Version: HelloVersionV2, - Auth: &HelloClientMessageAuth{ - Params: internalAuthParams, - Url: "https://domain.invalid", - }, - }, - &HelloClientMessage{ - Version: HelloVersionV2, - Auth: &HelloClientMessageAuth{ - Params: json.RawMessage("xyz"), // Invalid JSON. - Url: "https://domain.invalid", - }, - }, - } - - testMessages(t, "hello", valid_messages, invalid_messages) - - // A "hello" message must be present - msg := ClientMessage{ - Type: "hello", - } - assert := assert.New(t) - assert.Error(msg.CheckValid()) -} - -func TestMessageClientMessage(t *testing.T) { - t.Parallel() - valid_messages := []testCheckValid{ - &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - Type: "session", - SessionId: "the-session-id", - }, - Data: json.RawMessage("{}"), - }, - &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - Type: "user", - UserId: "the-user-id", - }, - Data: json.RawMessage("{}"), - }, - &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - Type: "room", - }, - Data: json.RawMessage("{}"), - }, - } - invalid_messages := []testCheckValid{ - &MessageClientMessage{}, - &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - Type: "session", - SessionId: "the-session-id", - }, - }, - &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - Type: "session", - }, - Data: json.RawMessage("{}"), - }, - &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - Type: "session", - UserId: "the-user-id", - }, - Data: json.RawMessage("{}"), - }, - &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - Type: "user", - }, - Data: json.RawMessage("{}"), - }, - &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - Type: "user", - UserId: "the-user-id", - }, - }, - &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - Type: "user", - SessionId: "the-user-id", - }, - Data: json.RawMessage("{}"), - }, - &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - Type: "unknown-type", - }, - Data: json.RawMessage("{}"), - }, - } - testMessages(t, "message", valid_messages, invalid_messages) - - // A "message" message must be present - msg := ClientMessage{ - Type: "message", - } - assert := assert.New(t) - assert.Error(msg.CheckValid()) -} - -func TestByeClientMessage(t *testing.T) { - t.Parallel() - // Any "bye" message is valid. - valid_messages := []testCheckValid{ - &ByeClientMessage{}, - } - invalid_messages := []testCheckValid{} - - testMessages(t, "bye", valid_messages, invalid_messages) - - // The "bye" message is optional. - msg := ClientMessage{ - Type: "bye", - } - assert := assert.New(t) - assert.NoError(msg.CheckValid()) -} - -func TestRoomClientMessage(t *testing.T) { - t.Parallel() - // Any "room" message is valid. - valid_messages := []testCheckValid{ - &RoomClientMessage{}, - } - invalid_messages := []testCheckValid{} - - testMessages(t, "room", valid_messages, invalid_messages) - - // But a "room" message must be present - msg := ClientMessage{ - Type: "room", - } - assert := assert.New(t) - assert.Error(msg.CheckValid()) -} - -func TestErrorMessages(t *testing.T) { - t.Parallel() - assert := assert.New(t) - id := "request-id" - msg := ClientMessage{ - Id: id, - } - err1 := msg.NewErrorServerMessage(&Error{}) - assert.Equal(id, err1.Id, "%+v", err1) - assert.Equal("error", err1.Type, "%+v", err1) - assert.NotNil(err1.Error, "%+v", err1) - - err2 := msg.NewWrappedErrorServerMessage(fmt.Errorf("test-error")) - assert.Equal(id, err2.Id, "%+v", err2) - assert.Equal("error", err2.Type, "%+v", err2) - if assert.NotNil(err2.Error, "%+v", err2) { - assert.Equal("internal_error", err2.Error.Code, "%+v", err2) - assert.Equal("test-error", err2.Error.Message, "%+v", err2) - } - // Test "error" interface - assert.Equal("test-error", err2.Error.Error(), "%+v", err2) -} - -func TestIsChatRefresh(t *testing.T) { - t.Parallel() - var msg ServerMessage - data_true := []byte("{\"type\":\"chat\",\"chat\":{\"refresh\":true}}") - msg = ServerMessage{ - Type: "message", - Message: &MessageServerMessage{ - Data: data_true, - }, - } - assert.True(t, msg.IsChatRefresh()) - - data_false := []byte("{\"type\":\"chat\",\"chat\":{\"refresh\":false}}") - msg = ServerMessage{ - Type: "message", - Message: &MessageServerMessage{ - Data: data_false, - }, - } - assert.False(t, msg.IsChatRefresh()) -} - -func assertEqualStrings(t *testing.T, expected, result []string) { - t.Helper() - - if expected == nil { - expected = make([]string, 0) - } else { - sort.Strings(expected) - } - if result == nil { - result = make([]string, 0) - } else { - sort.Strings(result) - } - - assert.Equal(t, expected, result) -} - -func Test_Welcome_AddRemoveFeature(t *testing.T) { - t.Parallel() - assert := assert.New(t) - var msg WelcomeServerMessage - assertEqualStrings(t, []string{}, msg.Features) - - msg.AddFeature("one", "two", "one") - assertEqualStrings(t, []string{"one", "two"}, msg.Features) - assert.True(sort.StringsAreSorted(msg.Features), "features should be sorted, got %+v", msg.Features) - - msg.AddFeature("three") - assertEqualStrings(t, []string{"one", "two", "three"}, msg.Features) - assert.True(sort.StringsAreSorted(msg.Features), "features should be sorted, got %+v", msg.Features) - - msg.RemoveFeature("three", "one") - assertEqualStrings(t, []string{"two"}, msg.Features) -} diff --git a/backoff.go b/async/backoff.go similarity index 87% rename from backoff.go rename to async/backoff.go index 5b49521..4d6953d 100644 --- a/backoff.go +++ b/async/backoff.go @@ -19,11 +19,11 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package async import ( "context" - "fmt" + "errors" "time" ) @@ -41,10 +41,10 @@ type exponentialBackoff struct { func NewExponentialBackoff(initial time.Duration, maxWait time.Duration) (Backoff, error) { if initial <= 0 { - return nil, fmt.Errorf("initial must be larger than 0") + return nil, errors.New("initial must be larger than 0") } if maxWait < initial { - return nil, fmt.Errorf("maxWait must be larger or equal to initial") + return nil, errors.New("maxWait must be larger or equal to initial") } return &exponentialBackoff{ @@ -67,10 +67,6 @@ func (b *exponentialBackoff) Wait(ctx context.Context) { waiter, cancel := context.WithTimeout(ctx, b.nextWait) defer cancel() - b.nextWait = b.nextWait * 2 - if b.nextWait > b.maxWait { - b.nextWait = b.maxWait - } - + b.nextWait = min(b.nextWait*2, b.maxWait) <-waiter.Done() } diff --git a/backoff_test.go b/async/backoff_test.go similarity index 64% rename from backoff_test.go rename to async/backoff_test.go index 87de048..7ade659 100644 --- a/backoff_test.go +++ b/async/backoff_test.go @@ -19,11 +19,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package async import ( "context" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/assert" @@ -32,31 +33,32 @@ import ( func TestBackoff_Exponential(t *testing.T) { t.Parallel() - assert := assert.New(t) - minWait := 100 * time.Millisecond - backoff, err := NewExponentialBackoff(minWait, 500*time.Millisecond) - require.NoError(t, err) + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + minWait := 100 * time.Millisecond + backoff, err := NewExponentialBackoff(minWait, 500*time.Millisecond) + require.NoError(t, err) - waitTimes := []time.Duration{ - minWait, - 200 * time.Millisecond, - 400 * time.Millisecond, - 500 * time.Millisecond, - 500 * time.Millisecond, - } + waitTimes := []time.Duration{ + minWait, + 200 * time.Millisecond, + 400 * time.Millisecond, + 500 * time.Millisecond, + 500 * time.Millisecond, + } - for _, wait := range waitTimes { - assert.Equal(wait, backoff.NextWait()) + for _, wait := range waitTimes { + assert.Equal(wait, backoff.NextWait()) + a := time.Now() + backoff.Wait(context.Background()) + b := time.Now() + assert.Equal(b.Sub(a), wait) + } + backoff.Reset() a := time.Now() backoff.Wait(context.Background()) b := time.Now() - assert.GreaterOrEqual(b.Sub(a), wait) - } - - backoff.Reset() - a := time.Now() - backoff.Wait(context.Background()) - b := time.Now() - assert.GreaterOrEqual(b.Sub(a), minWait) + assert.Equal(b.Sub(a), minWait) + }) } diff --git a/deferred_executor.go b/async/deferred_executor.go similarity index 81% rename from deferred_executor.go rename to async/deferred_executor.go index a6f46c7..be04695 100644 --- a/deferred_executor.go +++ b/async/deferred_executor.go @@ -19,29 +19,32 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package async import ( - "log" "reflect" "runtime" "runtime/debug" "sync" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) // DeferredExecutor will asynchronously execute functions while maintaining // their order. type DeferredExecutor struct { + logger log.Logger queue chan func() closed chan struct{} closeOnce sync.Once } -func NewDeferredExecutor(queueSize int) *DeferredExecutor { +func NewDeferredExecutor(logger log.Logger, queueSize int) *DeferredExecutor { if queueSize < 0 { queueSize = 0 } result := &DeferredExecutor{ + logger: logger, queue: make(chan func(), queueSize), closed: make(chan struct{}), } @@ -62,15 +65,15 @@ func (e *DeferredExecutor) run() { } } -func getFunctionName(i interface{}) string { +func getFunctionName(i any) string { return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() } func (e *DeferredExecutor) Execute(f func()) { defer func() { - if e := recover(); e != nil { - log.Printf("Could not defer function %v: %+v", getFunctionName(f), e) - log.Printf("Called from %s", string(debug.Stack())) + if err := recover(); err != nil { + e.logger.Printf("Could not defer function %v: %+v", getFunctionName(f), err) + e.logger.Printf("Called from %s", string(debug.Stack())) } }() diff --git a/deferred_executor_test.go b/async/deferred_executor_test.go similarity index 65% rename from deferred_executor_test.go rename to async/deferred_executor_test.go index 8fa96ce..7ebf8b8 100644 --- a/deferred_executor_test.go +++ b/async/deferred_executor_test.go @@ -19,17 +19,22 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package async import ( "testing" + "testing/synctest" "time" "github.com/stretchr/testify/assert" + + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" ) func TestDeferredExecutor_MultiClose(t *testing.T) { - e := NewDeferredExecutor(0) + t.Parallel() + logger := logtest.NewLoggerForTest(t) + e := NewDeferredExecutor(logger, 0) defer e.waitForStop() e.Close() @@ -38,28 +43,32 @@ func TestDeferredExecutor_MultiClose(t *testing.T) { func TestDeferredExecutor_QueueSize(t *testing.T) { t.Parallel() - e := NewDeferredExecutor(0) - defer e.waitForStop() - defer e.Close() + synctest.Test(t, func(t *testing.T) { + logger := logtest.NewLoggerForTest(t) + e := NewDeferredExecutor(logger, 0) + defer e.waitForStop() + defer e.Close() - delay := 100 * time.Millisecond - e.Execute(func() { - time.Sleep(delay) - }) + delay := 100 * time.Millisecond + e.Execute(func() { + time.Sleep(delay) + }) - // The queue will block until the first command finishes. - a := time.Now() - e.Execute(func() { - time.Sleep(time.Millisecond) + // The queue will block until the first command finishes. + a := time.Now() + e.Execute(func() { + time.Sleep(time.Millisecond) + }) + b := time.Now() + delta := b.Sub(a) + assert.Equal(t, delay, delta) }) - b := time.Now() - delta := b.Sub(a) - // Allow one millisecond less delay to account for time variance on CI runners. - assert.GreaterOrEqual(t, delta+time.Millisecond, delay) } func TestDeferredExecutor_Order(t *testing.T) { - e := NewDeferredExecutor(64) + t.Parallel() + logger := logtest.NewLoggerForTest(t) + e := NewDeferredExecutor(logger, 64) defer e.waitForStop() defer e.Close() @@ -71,7 +80,7 @@ func TestDeferredExecutor_Order(t *testing.T) { } done := make(chan struct{}) - for x := 0; x < 10; x++ { + for x := range 10 { e.Execute(getFunc(x)) } @@ -80,13 +89,15 @@ func TestDeferredExecutor_Order(t *testing.T) { }) <-done - for x := 0; x < 10; x++ { + for x := range 10 { assert.Equal(t, entries[x], x, "Unexpected at position %d", x) } } func TestDeferredExecutor_CloseFromFunc(t *testing.T) { - e := NewDeferredExecutor(64) + t.Parallel() + logger := logtest.NewLoggerForTest(t) + e := NewDeferredExecutor(logger, 64) defer e.waitForStop() done := make(chan struct{}) @@ -99,8 +110,9 @@ func TestDeferredExecutor_CloseFromFunc(t *testing.T) { } func TestDeferredExecutor_DeferAfterClose(t *testing.T) { - CatchLogForTest(t) - e := NewDeferredExecutor(64) + t.Parallel() + logger := logtest.NewLoggerForTest(t) + e := NewDeferredExecutor(logger, 64) defer e.waitForStop() e.Close() @@ -111,7 +123,9 @@ func TestDeferredExecutor_DeferAfterClose(t *testing.T) { } func TestDeferredExecutor_WaitForStopTwice(t *testing.T) { - e := NewDeferredExecutor(64) + t.Parallel() + logger := logtest.NewLoggerForTest(t) + e := NewDeferredExecutor(logger, 64) defer e.waitForStop() e.Close() diff --git a/api_async.go b/async/events/api.go similarity index 69% rename from api_async.go rename to async/events/api.go index d3c0426..26bb6ea 100644 --- a/api_async.go +++ b/async/events/api.go @@ -19,12 +19,15 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package events import ( "encoding/json" "fmt" "time" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) type AsyncMessage struct { @@ -32,11 +35,11 @@ type AsyncMessage struct { Type string `json:"type"` - Message *ServerMessage `json:"message,omitempty"` + Message *api.ServerMessage `json:"message,omitempty"` - Room *BackendServerRoomRequest `json:"room,omitempty"` + Room *talk.BackendServerRoomRequest `json:"room,omitempty"` - Permissions []Permission `json:"permissions,omitempty"` + Permissions []api.Permission `json:"permissions,omitempty"` AsyncRoom *AsyncRoomMessage `json:"asyncroom,omitempty"` @@ -56,12 +59,12 @@ func (m *AsyncMessage) String() string { type AsyncRoomMessage struct { Type string `json:"type"` - SessionId string `json:"sessionid,omitempty"` - ClientType string `json:"clienttype,omitempty"` + SessionId api.PublicSessionId `json:"sessionid,omitempty"` + ClientType api.ClientType `json:"clienttype,omitempty"` } type SendOfferMessage struct { - MessageId string `json:"messageid,omitempty"` - SessionId string `json:"sessionid"` - Data *MessageClientMessageData `json:"data"` + MessageId string `json:"messageid,omitempty"` + SessionId api.PublicSessionId `json:"sessionid"` + Data *api.MessageClientMessageData `json:"data"` } diff --git a/api_async_easyjson.go b/async/events/api_easyjson.go similarity index 64% rename from api_async_easyjson.go rename to async/events/api_easyjson.go index 1439e78..86cc164 100644 --- a/api_async_easyjson.go +++ b/async/events/api_easyjson.go @@ -1,12 +1,14 @@ // Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. -package signaling +package events import ( json "encoding/json" easyjson "github.com/mailru/easyjson" jlexer "github.com/mailru/easyjson/jlexer" jwriter "github.com/mailru/easyjson/jwriter" + api "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + talk "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) // suppress unused package warning @@ -17,7 +19,7 @@ var ( _ easyjson.Marshaler ) -func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexer.Lexer, out *SendOfferMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents(in *jlexer.Lexer, out *SendOfferMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -30,25 +32,32 @@ func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "messageid": - out.MessageId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.MessageId = string(in.String()) + } case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = api.PublicSessionId(in.String()) + } case "data": if in.IsNull() { in.Skip() out.Data = nil } else { if out.Data == nil { - out.Data = new(MessageClientMessageData) + out.Data = new(api.MessageClientMessageData) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Data).UnmarshalEasyJSON(in) } - (*out.Data).UnmarshalEasyJSON(in) } default: in.SkipRecursive() @@ -60,7 +69,7 @@ func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe in.Consumed() } } -func easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling(out *jwriter.Writer, in SendOfferMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents(out *jwriter.Writer, in SendOfferMessage) { out.RawByte('{') first := true _ = first @@ -95,27 +104,27 @@ func easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling(out *jwri // MarshalJSON supports json.Marshaler interface func (v SendOfferMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v SendOfferMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling(w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *SendOfferMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *SendOfferMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling(l, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents(l, v) } -func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlexer.Lexer, out *AsyncRoomMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents1(in *jlexer.Lexer, out *AsyncRoomMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -128,18 +137,25 @@ func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "sessionid": - out.SessionId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.SessionId = api.PublicSessionId(in.String()) + } case "clienttype": - out.ClientType = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ClientType = api.ClientType(in.String()) + } default: in.SkipRecursive() } @@ -150,7 +166,7 @@ func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex in.Consumed() } } -func easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling1(out *jwriter.Writer, in AsyncRoomMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents1(out *jwriter.Writer, in AsyncRoomMessage) { out.RawByte('{') first := true _ = first @@ -175,27 +191,27 @@ func easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling1(out *jwr // MarshalJSON supports json.Marshaler interface func (v AsyncRoomMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling1(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents1(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v AsyncRoomMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling1(w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents1(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *AsyncRoomMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling1(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents1(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *AsyncRoomMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling1(l, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents1(l, v) } -func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlexer.Lexer, out *AsyncMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents2(in *jlexer.Lexer, out *AsyncMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -208,27 +224,34 @@ func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "sendtime": - if data := in.Raw(); in.Ok() { - in.AddError((out.SendTime).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.SendTime).UnmarshalJSON(data)) + } } case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "message": if in.IsNull() { in.Skip() out.Message = nil } else { if out.Message == nil { - out.Message = new(ServerMessage) + out.Message = new(api.ServerMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Message).UnmarshalEasyJSON(in) } - (*out.Message).UnmarshalEasyJSON(in) } case "room": if in.IsNull() { @@ -236,9 +259,13 @@ func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlex out.Room = nil } else { if out.Room == nil { - out.Room = new(BackendServerRoomRequest) + out.Room = new(talk.BackendServerRoomRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Room).UnmarshalEasyJSON(in) } - (*out.Room).UnmarshalEasyJSON(in) } case "permissions": if in.IsNull() { @@ -248,16 +275,20 @@ func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlex in.Delim('[') if out.Permissions == nil { if !in.IsDelim(']') { - out.Permissions = make([]Permission, 0, 4) + out.Permissions = make([]api.Permission, 0, 4) } else { - out.Permissions = []Permission{} + out.Permissions = []api.Permission{} } } else { out.Permissions = (out.Permissions)[:0] } for !in.IsDelim(']') { - var v1 Permission - v1 = Permission(in.String()) + var v1 api.Permission + if in.IsNull() { + in.Skip() + } else { + v1 = api.Permission(in.String()) + } out.Permissions = append(out.Permissions, v1) in.WantComma() } @@ -271,7 +302,11 @@ func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlex if out.AsyncRoom == nil { out.AsyncRoom = new(AsyncRoomMessage) } - (*out.AsyncRoom).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.AsyncRoom).UnmarshalEasyJSON(in) + } } case "sendoffer": if in.IsNull() { @@ -281,10 +316,18 @@ func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlex if out.SendOffer == nil { out.SendOffer = new(SendOfferMessage) } - (*out.SendOffer).UnmarshalEasyJSON(in) + if in.IsNull() { + in.Skip() + } else { + (*out.SendOffer).UnmarshalEasyJSON(in) + } } case "id": - out.Id = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Id = string(in.String()) + } default: in.SkipRecursive() } @@ -295,7 +338,7 @@ func easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlex in.Consumed() } } -func easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling2(out *jwriter.Writer, in AsyncMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents2(out *jwriter.Writer, in AsyncMessage) { out.RawByte('{') first := true _ = first @@ -354,23 +397,23 @@ func easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling2(out *jwr // MarshalJSON supports json.Marshaler interface func (v AsyncMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling2(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents2(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v AsyncMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson9289e183EncodeGithubComStrukturagNextcloudSpreedSignaling2(w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents2(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *AsyncMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling2(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents2(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *AsyncMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson9289e183DecodeGithubComStrukturagNextcloudSpreedSignaling2(l, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2AsyncEvents2(l, v) } diff --git a/async/events/async_events.go b/async/events/async_events.go new file mode 100644 index 0000000..aec0baa --- /dev/null +++ b/async/events/async_events.go @@ -0,0 +1,76 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2022 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package events + +import ( + "context" + "errors" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +var ( + ErrAlreadyRegistered = errors.New("already registered") // +checklocksignore: Global readonly variable. +) + +const ( + DefaultAsyncChannelSize = 64 +) + +type AsyncChannel chan *nats.Msg + +type AsyncEventListener interface { + AsyncChannel() AsyncChannel +} + +type AsyncEvents interface { + Close(ctx context.Context) error + + RegisterBackendRoomListener(roomId string, backend *talk.Backend, listener AsyncEventListener) error + UnregisterBackendRoomListener(roomId string, backend *talk.Backend, listener AsyncEventListener) error + + RegisterRoomListener(roomId string, backend *talk.Backend, listener AsyncEventListener) error + UnregisterRoomListener(roomId string, backend *talk.Backend, listener AsyncEventListener) error + + RegisterUserListener(userId string, backend *talk.Backend, listener AsyncEventListener) error + UnregisterUserListener(userId string, backend *talk.Backend, listener AsyncEventListener) error + + RegisterSessionListener(sessionId api.PublicSessionId, backend *talk.Backend, listener AsyncEventListener) error + UnregisterSessionListener(sessionId api.PublicSessionId, backend *talk.Backend, listener AsyncEventListener) error + + PublishBackendRoomMessage(roomId string, backend *talk.Backend, message *AsyncMessage) error + PublishRoomMessage(roomId string, backend *talk.Backend, message *AsyncMessage) error + PublishUserMessage(userId string, backend *talk.Backend, message *AsyncMessage) error + PublishSessionMessage(sessionId api.PublicSessionId, backend *talk.Backend, message *AsyncMessage) error +} + +func NewAsyncEvents(ctx context.Context, url string) (AsyncEvents, error) { + client, err := nats.NewClient(ctx, url) + if err != nil { + return nil, err + } + + return NewAsyncEventsNats(log.LoggerFromContext(ctx), client) +} diff --git a/async/events/async_events_nats.go b/async/events/async_events_nats.go new file mode 100644 index 0000000..4cc776d --- /dev/null +++ b/async/events/async_events_nats.go @@ -0,0 +1,292 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2022 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package events + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +func GetSubjectForBackendRoomId(roomId string, backend *talk.Backend) string { + if backend == nil || backend.IsCompat() { + return nats.GetEncodedSubject("backend.room", roomId) + } + + return nats.GetEncodedSubject("backend.room", roomId+"|"+backend.Id()) +} + +func GetSubjectForRoomId(roomId string, backend *talk.Backend) string { + if backend == nil || backend.IsCompat() { + return nats.GetEncodedSubject("room", roomId) + } + + return nats.GetEncodedSubject("room", roomId+"|"+backend.Id()) +} + +func GetSubjectForUserId(userId string, backend *talk.Backend) string { + if backend == nil || backend.IsCompat() { + return nats.GetEncodedSubject("user", userId) + } + + return nats.GetEncodedSubject("user", userId+"|"+backend.Id()) +} + +func GetSubjectForSessionId(sessionId api.PublicSessionId, backend *talk.Backend) string { + return string("session." + sessionId) +} + +type asyncEventsNatsSubscriptions map[string]map[AsyncEventListener]nats.Subscription + +type asyncEventsNats struct { + mu sync.Mutex + client nats.Client + logger log.Logger // +checklocksignore + + // +checklocks:mu + backendRoomSubscriptions asyncEventsNatsSubscriptions + // +checklocks:mu + roomSubscriptions asyncEventsNatsSubscriptions + // +checklocks:mu + userSubscriptions asyncEventsNatsSubscriptions + // +checklocks:mu + sessionSubscriptions asyncEventsNatsSubscriptions +} + +func NewAsyncEventsNats(logger log.Logger, client nats.Client) (AsyncEvents, error) { + events := &asyncEventsNats{ + client: client, + logger: logger, + + backendRoomSubscriptions: make(asyncEventsNatsSubscriptions), + roomSubscriptions: make(asyncEventsNatsSubscriptions), + userSubscriptions: make(asyncEventsNatsSubscriptions), + sessionSubscriptions: make(asyncEventsNatsSubscriptions), + } + return events, nil +} + +func (e *asyncEventsNats) GetNatsClient() nats.Client { + return e.client +} + +func (e *asyncEventsNats) GetServerInfoNats() *talk.BackendServerInfoNats { + // TODO: This should call a method on "e.client" directly instead of having a type switch. + var result *talk.BackendServerInfoNats + switch n := e.client.(type) { + case *nats.NativeClient: + result = &talk.BackendServerInfoNats{ + Urls: n.URLs(), + } + if n.IsConnected() { + result.Connected = true + result.ServerUrl = n.ConnectedUrl() + result.ServerID = n.ConnectedServerId() + result.ServerVersion = n.ConnectedServerVersion() + result.ClusterName = n.ConnectedClusterName() + } + case *nats.LoopbackClient: + result = &talk.BackendServerInfoNats{ + Urls: []string{nats.LoopbackUrl}, + Connected: true, + ServerUrl: nats.LoopbackUrl, + } + } + + return result +} + +func closeSubscriptions(logger log.Logger, wg *sync.WaitGroup, subscriptions asyncEventsNatsSubscriptions) { + defer wg.Done() + + for subject, subs := range subscriptions { + for _, sub := range subs { + if err := sub.Unsubscribe(); err != nil && !errors.Is(err, nats.ErrConnectionClosed) { + logger.Printf("Error unsubscribing %s: %s", subject, err) + } + } + } +} + +func (e *asyncEventsNats) Close(ctx context.Context) error { + e.mu.Lock() + defer e.mu.Unlock() + var wg sync.WaitGroup + wg.Add(1) + go closeSubscriptions(e.logger, &wg, e.backendRoomSubscriptions) + wg.Add(1) + go closeSubscriptions(e.logger, &wg, e.roomSubscriptions) + wg.Add(1) + go closeSubscriptions(e.logger, &wg, e.userSubscriptions) + wg.Add(1) + go closeSubscriptions(e.logger, &wg, e.sessionSubscriptions) + // Can't use clear(...) here as the maps are processed asynchronously by the + // goroutines above. + e.backendRoomSubscriptions = make(asyncEventsNatsSubscriptions) + e.roomSubscriptions = make(asyncEventsNatsSubscriptions) + e.userSubscriptions = make(asyncEventsNatsSubscriptions) + e.sessionSubscriptions = make(asyncEventsNatsSubscriptions) + wg.Wait() + return e.client.Close(ctx) +} + +// +checklocks:e.mu +func (e *asyncEventsNats) registerListener(key string, subscriptions asyncEventsNatsSubscriptions, listener AsyncEventListener) error { + subs, found := subscriptions[key] + if !found { + subs = make(map[AsyncEventListener]nats.Subscription) + subscriptions[key] = subs + } else if _, found := subs[listener]; found { + return ErrAlreadyRegistered + } + + sub, err := e.client.Subscribe(key, listener.AsyncChannel()) + if err != nil { + return err + } + + subs[listener] = sub + return nil +} + +// +checklocks:e.mu +func (e *asyncEventsNats) unregisterListener(key string, subscriptions asyncEventsNatsSubscriptions, listener AsyncEventListener) error { + subs, found := subscriptions[key] + if !found { + return nil + } + + sub, found := subs[listener] + if !found { + return nil + } + + delete(subs, listener) + if len(subs) == 0 { + delete(subscriptions, key) + } + + return sub.Unsubscribe() +} + +func (e *asyncEventsNats) RegisterBackendRoomListener(roomId string, backend *talk.Backend, listener AsyncEventListener) error { + key := GetSubjectForBackendRoomId(roomId, backend) + + e.mu.Lock() + defer e.mu.Unlock() + + return e.registerListener(key, e.backendRoomSubscriptions, listener) +} + +func (e *asyncEventsNats) UnregisterBackendRoomListener(roomId string, backend *talk.Backend, listener AsyncEventListener) error { + key := GetSubjectForBackendRoomId(roomId, backend) + + e.mu.Lock() + defer e.mu.Unlock() + + return e.unregisterListener(key, e.backendRoomSubscriptions, listener) +} + +func (e *asyncEventsNats) RegisterRoomListener(roomId string, backend *talk.Backend, listener AsyncEventListener) error { + key := GetSubjectForRoomId(roomId, backend) + + e.mu.Lock() + defer e.mu.Unlock() + + return e.registerListener(key, e.roomSubscriptions, listener) +} + +func (e *asyncEventsNats) UnregisterRoomListener(roomId string, backend *talk.Backend, listener AsyncEventListener) error { + key := GetSubjectForRoomId(roomId, backend) + + e.mu.Lock() + defer e.mu.Unlock() + + return e.unregisterListener(key, e.roomSubscriptions, listener) +} + +func (e *asyncEventsNats) RegisterUserListener(roomId string, backend *talk.Backend, listener AsyncEventListener) error { + key := GetSubjectForUserId(roomId, backend) + + e.mu.Lock() + defer e.mu.Unlock() + + return e.registerListener(key, e.userSubscriptions, listener) +} + +func (e *asyncEventsNats) UnregisterUserListener(roomId string, backend *talk.Backend, listener AsyncEventListener) error { + key := GetSubjectForUserId(roomId, backend) + + e.mu.Lock() + defer e.mu.Unlock() + + return e.unregisterListener(key, e.userSubscriptions, listener) +} + +func (e *asyncEventsNats) RegisterSessionListener(sessionId api.PublicSessionId, backend *talk.Backend, listener AsyncEventListener) error { + key := GetSubjectForSessionId(sessionId, backend) + + e.mu.Lock() + defer e.mu.Unlock() + + return e.registerListener(key, e.sessionSubscriptions, listener) +} + +func (e *asyncEventsNats) UnregisterSessionListener(sessionId api.PublicSessionId, backend *talk.Backend, listener AsyncEventListener) error { + key := GetSubjectForSessionId(sessionId, backend) + + e.mu.Lock() + defer e.mu.Unlock() + + return e.unregisterListener(key, e.sessionSubscriptions, listener) +} + +func (e *asyncEventsNats) publish(subject string, message *AsyncMessage) error { + message.SendTime = time.Now().Truncate(time.Microsecond) + return e.client.Publish(subject, message) +} + +func (e *asyncEventsNats) PublishBackendRoomMessage(roomId string, backend *talk.Backend, message *AsyncMessage) error { + subject := GetSubjectForBackendRoomId(roomId, backend) + return e.publish(subject, message) +} + +func (e *asyncEventsNats) PublishRoomMessage(roomId string, backend *talk.Backend, message *AsyncMessage) error { + subject := GetSubjectForRoomId(roomId, backend) + return e.publish(subject, message) +} + +func (e *asyncEventsNats) PublishUserMessage(userId string, backend *talk.Backend, message *AsyncMessage) error { + subject := GetSubjectForUserId(userId, backend) + return e.publish(subject, message) +} + +func (e *asyncEventsNats) PublishSessionMessage(sessionId api.PublicSessionId, backend *talk.Backend, message *AsyncMessage) error { + subject := GetSubjectForSessionId(sessionId, backend) + return e.publish(subject, message) +} diff --git a/async/events/async_events_nats_test.go b/async/events/async_events_nats_test.go new file mode 100644 index 0000000..45f28f6 --- /dev/null +++ b/async/events/async_events_nats_test.go @@ -0,0 +1,38 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package events + +import ( + "testing" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +func Benchmark_GetSubjectForSessionId(b *testing.B) { + backend := talk.NewCompatBackend(nil) + sid := api.PublicSessionId(internal.RandomString(256)) + for b.Loop() { + GetSubjectForSessionId(sid, backend) + } +} diff --git a/async/events/async_events_test.go b/async/events/async_events_test.go new file mode 100644 index 0000000..deb5f41 --- /dev/null +++ b/async/events/async_events_test.go @@ -0,0 +1,170 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package events + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" + natstest "github.com/strukturag/nextcloud-spreed-signaling/v2/nats/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +type TestBackendRoomListener struct { + events AsyncChannel +} + +func (l *TestBackendRoomListener) AsyncChannel() AsyncChannel { + return l.events +} + +func testAsyncEvents(t *testing.T, events AsyncEvents) { + require := require.New(t) + assert := assert.New(t) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + assert.NoError(events.Close(ctx)) + }) + + listener := &TestBackendRoomListener{ + events: make(AsyncChannel, 1), + } + + roomId := "1234" + backend := talk.NewCompatBackend(nil) + require.NoError(events.RegisterBackendRoomListener(roomId, backend, listener)) + defer func() { + assert.NoError(events.UnregisterBackendRoomListener(roomId, backend, listener)) + }() + + msg := &AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "test", + }, + } + if assert.NoError(events.PublishBackendRoomMessage(roomId, backend, msg)) { + received := <-listener.events + var receivedMsg AsyncMessage + if assert.NoError(nats.Decode(received, &receivedMsg)) { + assert.True(msg.SendTime.Equal(receivedMsg.SendTime), "send times don't match, expected %s, got %s", msg.SendTime, receivedMsg.SendTime) + receivedMsg.SendTime = msg.SendTime + assert.Equal(msg, &receivedMsg) + } + } + + require.NoError(events.RegisterRoomListener(roomId, backend, listener)) + defer func() { + assert.NoError(events.UnregisterRoomListener(roomId, backend, listener)) + }() + + roomMessage := &AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "other-test", + }, + } + if assert.NoError(events.PublishRoomMessage(roomId, backend, roomMessage)) { + received := <-listener.events + var receivedMsg AsyncMessage + if assert.NoError(nats.Decode(received, &receivedMsg)) { + assert.True(roomMessage.SendTime.Equal(receivedMsg.SendTime), "send times don't match, expected %s, got %s", roomMessage.SendTime, receivedMsg.SendTime) + receivedMsg.SendTime = roomMessage.SendTime + assert.Equal(roomMessage, &receivedMsg) + } + } + + userId := "the-user" + require.NoError(events.RegisterUserListener(userId, backend, listener)) + defer func() { + assert.NoError(events.UnregisterUserListener(userId, backend, listener)) + }() + + userMessage := &AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "user-test", + }, + } + if assert.NoError(events.PublishUserMessage(userId, backend, userMessage)) { + received := <-listener.events + var receivedMsg AsyncMessage + if assert.NoError(nats.Decode(received, &receivedMsg)) { + assert.True(userMessage.SendTime.Equal(receivedMsg.SendTime), "send times don't match, expected %s, got %s", userMessage.SendTime, receivedMsg.SendTime) + receivedMsg.SendTime = userMessage.SendTime + assert.Equal(userMessage, &receivedMsg) + } + } + + sessionId := api.PublicSessionId("the-session") + require.NoError(events.RegisterSessionListener(sessionId, backend, listener)) + defer func() { + assert.NoError(events.UnregisterSessionListener(sessionId, backend, listener)) + }() + + sessionMessage := &AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "session-test", + }, + } + if assert.NoError(events.PublishSessionMessage(sessionId, backend, sessionMessage)) { + received := <-listener.events + var receivedMsg AsyncMessage + if assert.NoError(nats.Decode(received, &receivedMsg)) { + assert.True(sessionMessage.SendTime.Equal(receivedMsg.SendTime), "send times don't match, expected %s, got %s", sessionMessage.SendTime, receivedMsg.SendTime) + receivedMsg.SendTime = sessionMessage.SendTime + assert.Equal(sessionMessage, &receivedMsg) + } + } +} + +func TestAsyncEvents_Loopback(t *testing.T) { + t.Parallel() + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + events, err := NewAsyncEvents(ctx, nats.LoopbackUrl) + require.NoError(t, err) + testAsyncEvents(t, events) +} + +func TestAsyncEvents_NATS(t *testing.T) { + t.Parallel() + + server, _ := natstest.StartLocalServer(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + events, err := NewAsyncEvents(ctx, server.ClientURL()) + require.NoError(t, err) + testAsyncEvents(t, events) +} diff --git a/async/events/test/events.go b/async/events/test/events.go new file mode 100644 index 0000000..e5d52d1 --- /dev/null +++ b/async/events/test/events.go @@ -0,0 +1,114 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" + natstest "github.com/strukturag/nextcloud-spreed-signaling/v2/nats/test" +) + +var ( + testTimeout = 10 * time.Second + + EventBackendsForTest = []string{ + "loopback", + "nats", + } +) + +func GetAsyncEventsForTest(t *testing.T) events.AsyncEvents { + var events events.AsyncEvents + if strings.HasSuffix(t.Name(), "/nats") { + events = getRealAsyncEventsForTest(t) + } else { + events = getLoopbackAsyncEventsForTest(t) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + assert.NoError(t, events.Close(ctx)) + }) + return events +} + +func getRealAsyncEventsForTest(t *testing.T) events.AsyncEvents { + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + server, _ := natstest.StartLocalServer(t) + events, err := events.NewAsyncEvents(ctx, server.ClientURL()) + require.NoError(t, err) + return events +} + +type natsEvents interface { + GetNatsClient() nats.Client +} + +func getLoopbackAsyncEventsForTest(t *testing.T) events.AsyncEvents { + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + events, err := events.NewAsyncEvents(ctx, nats.LoopbackUrl) + require.NoError(t, err) + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + e, ok := (events.(natsEvents)) + if !ok { + // Only can wait for NATS events. + return + } + + natstest.WaitForSubscriptionsEmpty(ctx, t, e.GetNatsClient()) + }) + return events +} + +func WaitForAsyncEventsFlushed(ctx context.Context, t *testing.T, events events.AsyncEvents) { + t.Helper() + + e, ok := (events.(natsEvents)) + if !ok { + // Only can wait for NATS events. + return + } + + client, ok := e.GetNatsClient().(*nats.NativeClient) + if !ok { + // The loopback NATS clients is executing all events synchronously. + return + } + + assert.NoError(t, client.FlushWithContext(ctx)) +} diff --git a/async/events/test/events_test.go b/async/events/test/events_test.go new file mode 100644 index 0000000..b353385 --- /dev/null +++ b/async/events/test/events_test.go @@ -0,0 +1,94 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +type testListener struct { + ch events.AsyncChannel +} + +func (l *testListener) AsyncChannel() events.AsyncChannel { + return l.ch +} + +func TestAsyncEventsTest(t *testing.T) { + t.Parallel() + + for _, backend := range EventBackendsForTest { + t.Run(backend, func(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + eventsHandler := GetAsyncEventsForTest(t) + + listener := &testListener{ + ch: make(events.AsyncChannel, 1), + } + sessionId := api.PublicSessionId("foo") + backend := talk.NewCompatBackend(nil) + require.NoError(eventsHandler.RegisterSessionListener(sessionId, backend, listener)) + defer func() { + assert.NoError(eventsHandler.UnregisterSessionListener(sessionId, backend, listener)) + }() + + msg := events.AsyncMessage{ + Type: "message", + Message: &api.ServerMessage{ + Type: "error", + Error: api.NewError("test_error", "This is a test error."), + }, + } + if err := eventsHandler.PublishSessionMessage(sessionId, backend, &msg); assert.NoError(err) { + select { + case natsMsg := <-listener.ch: + var received events.AsyncMessage + if err := nats.Decode(natsMsg, &received); assert.NoError(err) { + assert.True(msg.SendTime.Equal(received.SendTime), "send times don't match, expected %s, got %s", msg.SendTime, received.SendTime) + received.SendTime = msg.SendTime + assert.Equal(msg, received) + } + case <-ctx.Done(): + require.NoError(ctx.Err()) + } + } + + WaitForAsyncEventsFlushed(ctx, t, eventsHandler) + }) + } +} diff --git a/notifier.go b/async/notifier.go similarity index 62% rename from notifier.go rename to async/notifier.go index 3466f45..747ec5e 100644 --- a/notifier.go +++ b/async/notifier.go @@ -19,60 +19,79 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package async import ( "context" "sync" ) +type rootWaiter struct { + key string + ch chan struct{} +} + +func (w *rootWaiter) notify() { + close(w.ch) +} + type Waiter struct { key string - - sw *SingleWaiter + ch <-chan struct{} } func (w *Waiter) Wait(ctx context.Context) error { - return w.sw.Wait(ctx) + select { + case <-w.ch: + return nil + case <-ctx.Done(): + return ctx.Err() + } } type Notifier struct { sync.Mutex - waiters map[string]*Waiter + // +checklocks:Mutex + waiters map[string]*rootWaiter + // +checklocks:Mutex waiterMap map[string]map[*Waiter]bool } -func (n *Notifier) NewWaiter(key string) *Waiter { +type ReleaseFunc func() + +func (n *Notifier) NewWaiter(key string) (*Waiter, ReleaseFunc) { n.Lock() defer n.Unlock() waiter, found := n.waiters[key] - if found { - w := &Waiter{ + if !found { + waiter = &rootWaiter{ key: key, - sw: waiter.sw, + ch: make(chan struct{}), + } + + if n.waiters == nil { + n.waiters = make(map[string]*rootWaiter) + } + if n.waiterMap == nil { + n.waiterMap = make(map[string]map[*Waiter]bool) + } + n.waiters[key] = waiter + if _, found := n.waiterMap[key]; !found { + n.waiterMap[key] = make(map[*Waiter]bool) } - n.waiterMap[key][w] = true - return w } - waiter = &Waiter{ + w := &Waiter{ key: key, - sw: newSingleWaiter(), + ch: waiter.ch, } - if n.waiters == nil { - n.waiters = make(map[string]*Waiter) + n.waiterMap[key][w] = true + releaseFunc := func() { + n.release(w) } - if n.waiterMap == nil { - n.waiterMap = make(map[string]map[*Waiter]bool) - } - n.waiters[key] = waiter - if _, found := n.waiterMap[key]; !found { - n.waiterMap[key] = make(map[*Waiter]bool) - } - n.waiterMap[key][waiter] = true - return waiter + return w, releaseFunc } func (n *Notifier) Reset() { @@ -80,13 +99,13 @@ func (n *Notifier) Reset() { defer n.Unlock() for _, w := range n.waiters { - w.sw.cancel() + w.notify() } n.waiters = nil n.waiterMap = nil } -func (n *Notifier) Release(w *Waiter) { +func (n *Notifier) release(w *Waiter) { n.Lock() defer n.Unlock() @@ -94,8 +113,10 @@ func (n *Notifier) Release(w *Waiter) { if _, found := waiters[w]; found { delete(waiters, w) if len(waiters) == 0 { - delete(n.waiters, w.key) - w.sw.cancel() + if root, found := n.waiters[w.key]; found { + delete(n.waiters, w.key) + root.notify() + } } } } @@ -106,7 +127,7 @@ func (n *Notifier) Notify(key string) { defer n.Unlock() if w, found := n.waiters[key]; found { - w.sw.cancel() + w.notify() delete(n.waiters, w.key) delete(n.waiterMap, w.key) } diff --git a/notifier_test.go b/async/notifier_test.go similarity index 60% rename from notifier_test.go rename to async/notifier_test.go index c40d7f0..74f88ed 100644 --- a/notifier_test.go +++ b/async/notifier_test.go @@ -19,49 +19,76 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package async import ( "context" "sync" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/assert" ) func TestNotifierNoWaiter(t *testing.T) { + t.Parallel() var notifier Notifier // Notifications can be sent even if no waiter exists. notifier.Notify("foo") } +func TestNotifierWaitTimeout(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + var notifier Notifier + + notified := make(chan struct{}) + go func() { + defer close(notified) + time.Sleep(time.Second) + notifier.Notify("foo") + }() + + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + + waiter, release := notifier.NewWaiter("foo") + defer release() + + err := waiter.Wait(ctx) + assert.ErrorIs(t, err, context.DeadlineExceeded) + <-notified + + assert.NoError(t, waiter.Wait(t.Context())) + }) +} + func TestNotifierSimple(t *testing.T) { + t.Parallel() + var notifier Notifier + waiter, release := notifier.NewWaiter("foo") + defer release() var wg sync.WaitGroup - wg.Add(1) - - waiter := notifier.NewWaiter("foo") - defer notifier.Release(waiter) - - go func() { - defer wg.Done() + wg.Go(func() { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() assert.NoError(t, waiter.Wait(ctx)) - }() + }) notifier.Notify("foo") wg.Wait() } func TestNotifierMultiNotify(t *testing.T) { + t.Parallel() var notifier Notifier - waiter := notifier.NewWaiter("foo") - defer notifier.Release(waiter) + _, release := notifier.NewWaiter("foo") + defer release() notifier.Notify("foo") // The second notification will be ignored while the first is still pending. @@ -69,41 +96,41 @@ func TestNotifierMultiNotify(t *testing.T) { } func TestNotifierWaitClosed(t *testing.T) { + t.Parallel() var notifier Notifier - waiter := notifier.NewWaiter("foo") - notifier.Release(waiter) + waiter, release := notifier.NewWaiter("foo") + release() assert.NoError(t, waiter.Wait(context.Background())) } func TestNotifierWaitClosedMulti(t *testing.T) { + t.Parallel() var notifier Notifier - waiter1 := notifier.NewWaiter("foo") - waiter2 := notifier.NewWaiter("foo") - notifier.Release(waiter1) - notifier.Release(waiter2) + waiter1, release1 := notifier.NewWaiter("foo") + waiter2, release2 := notifier.NewWaiter("foo") + release1() + release2() assert.NoError(t, waiter1.Wait(context.Background())) assert.NoError(t, waiter2.Wait(context.Background())) } func TestNotifierResetWillNotify(t *testing.T) { + t.Parallel() + var notifier Notifier + waiter, release := notifier.NewWaiter("foo") + defer release() var wg sync.WaitGroup - wg.Add(1) - - waiter := notifier.NewWaiter("foo") - defer notifier.Release(waiter) - - go func() { - defer wg.Done() + wg.Go(func() { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() assert.NoError(t, waiter.Wait(ctx)) - }() + }) notifier.Reset() wg.Wait() @@ -111,31 +138,24 @@ func TestNotifierResetWillNotify(t *testing.T) { func TestNotifierDuplicate(t *testing.T) { t.Parallel() - var notifier Notifier - var wgStart sync.WaitGroup - var wgEnd sync.WaitGroup + synctest.Test(t, func(t *testing.T) { + var notifier Notifier + var done sync.WaitGroup - for i := 0; i < 2; i++ { - wgStart.Add(1) - wgEnd.Add(1) + for range 2 { + done.Go(func() { + waiter, release := notifier.NewWaiter("foo") + defer release() - go func() { - defer wgEnd.Done() - waiter := notifier.NewWaiter("foo") - defer notifier.Release(waiter) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + assert.NoError(t, waiter.Wait(ctx)) + }) + } - // Goroutine has created the waiter and is ready. - wgStart.Done() + synctest.Wait() - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - assert.NoError(t, waiter.Wait(ctx)) - }() - } - - wgStart.Wait() - - time.Sleep(100 * time.Millisecond) - notifier.Notify("foo") - wgEnd.Wait() + notifier.Notify("foo") + done.Wait() + }) } diff --git a/throttle.go b/async/throttle.go similarity index 87% rename from throttle.go rename to async/throttle.go index dcd5332..7b1ebe5 100644 --- a/throttle.go +++ b/async/throttle.go @@ -19,16 +19,18 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package async import ( "context" "errors" - "log" "net" "strconv" "sync" "time" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) const ( @@ -90,25 +92,33 @@ type throttleEntry struct { ts time.Time } -type memoryThrottler struct { - getNow func() time.Time - doDelay func(context.Context, time.Duration) +type GetTimeFunc func() time.Time +type ThrottleDelayFunc func(context.Context, time.Duration) - mu sync.RWMutex +type memoryThrottler struct { + getNow GetTimeFunc + doDelay ThrottleDelayFunc + + mu sync.RWMutex + // +checklocks:mu clients map[string]map[string][]throttleEntry - closer *Closer + closer *internal.Closer } func NewMemoryThrottler() (Throttler, error) { + return NewCustomMemoryThrottler(time.Now, defaultDelay) +} + +func NewCustomMemoryThrottler(getNow GetTimeFunc, delay ThrottleDelayFunc) (Throttler, error) { result := &memoryThrottler{ - getNow: time.Now, + getNow: getNow, + doDelay: delay, clients: make(map[string]map[string][]throttleEntry), - closer: NewCloser(), + closer: internal.NewCloser(), } - result.doDelay = result.delay go result.housekeeping() return result, nil } @@ -257,10 +267,7 @@ func (t *memoryThrottler) getDelay(count int) time.Duration { return maxThrottleDelay } - delay := time.Duration(100*intPow(2, count)) * time.Millisecond - if delay > maxThrottleDelay { - delay = maxThrottleDelay - } + delay := min(time.Duration(100*intPow(2, count))*time.Millisecond, maxThrottleDelay) return delay } @@ -279,7 +286,8 @@ func (t *memoryThrottler) CheckBruteforce(ctx context.Context, client string, ac if l >= maxBruteforceAttempts { delta := now.Sub(entries[l-maxBruteforceAttempts].ts) if delta <= maxBruteforceDurationThreshold { - log.Printf("Detected bruteforce attempt on \"%s\" from %s", action, client) + logger := log.LoggerFromContext(ctx) + logger.Printf("Detected bruteforce attempt on \"%s\" from %s", action, client) statsThrottleBruteforceTotal.WithLabelValues(action).Inc() return doThrottle, ErrBruteforceDetected } @@ -303,12 +311,13 @@ func (t *memoryThrottler) throttle(ctx context.Context, client string, action st } count := t.addEntry(client, action, entry) delay := t.getDelay(count - 1) - log.Printf("Failed attempt on \"%s\" from %s, throttling by %s", action, client, delay) + logger := log.LoggerFromContext(ctx) + logger.Printf("Failed attempt on \"%s\" from %s, throttling by %s", action, client, delay) statsThrottleDelayedTotal.WithLabelValues(action, strconv.FormatInt(delay.Milliseconds(), 10)).Inc() t.doDelay(ctx, delay) } -func (t *memoryThrottler) delay(ctx context.Context, duration time.Duration) { +func defaultDelay(ctx context.Context, duration time.Duration) { c, cancel := context.WithTimeout(ctx, duration) defer cancel() diff --git a/throttle_stats_prometheus.go b/async/throttle_stats_prometheus.go similarity index 93% rename from throttle_stats_prometheus.go rename to async/throttle_stats_prometheus.go index 8279fe0..64c140b 100644 --- a/throttle_stats_prometheus.go +++ b/async/throttle_stats_prometheus.go @@ -19,10 +19,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package async import ( "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" ) var ( @@ -47,5 +49,5 @@ var ( ) func RegisterThrottleStats() { - registerAll(throttleStats...) + metrics.RegisterAll(throttleStats...) } diff --git a/async/throttle_test.go b/async/throttle_test.go new file mode 100644 index 0000000..97d1313 --- /dev/null +++ b/async/throttle_test.go @@ -0,0 +1,303 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2024 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package async + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" +) + +func newMemoryThrottlerForTest(t *testing.T) Throttler { + t.Helper() + result, err := NewMemoryThrottler() + require.NoError(t, err) + + t.Cleanup(func() { + result.Close() + }) + + return result +} + +func expectDelay(t *testing.T, f func(), delay time.Duration) { + t.Helper() + a := time.Now() + f() + b := time.Now() + assert.Equal(t, delay, b.Sub(a)) +} + +func TestThrottler(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + th := newMemoryThrottlerForTest(t) + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + throttle1, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle1(ctx) + }, 100*time.Millisecond) + + throttle2, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle2(ctx) + }, 200*time.Millisecond) + + throttle3, err := th.CheckBruteforce(ctx, "192.168.0.2", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle3(ctx) + }, 100*time.Millisecond) + + throttle4, err := th.CheckBruteforce(ctx, "192.168.0.1", "action2") + assert.NoError(err) + expectDelay(t, func() { + throttle4(ctx) + }, 100*time.Millisecond) + }) +} + +func TestThrottlerIPv6(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + th := newMemoryThrottlerForTest(t) + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + // Make sure full /64 subnets are throttled for IPv6. + throttle1, err := th.CheckBruteforce(ctx, "2001:db8:abcd:0012::1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle1(ctx) + }, 100*time.Millisecond) + + throttle2, err := th.CheckBruteforce(ctx, "2001:db8:abcd:0012::2", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle2(ctx) + }, 200*time.Millisecond) + + // A diffent /64 subnet is not throttled yet. + throttle3, err := th.CheckBruteforce(ctx, "2001:db8:abcd:0013::1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle3(ctx) + }, 100*time.Millisecond) + + // A different action is not throttled. + throttle4, err := th.CheckBruteforce(ctx, "2001:db8:abcd:0012::1", "action2") + assert.NoError(err) + expectDelay(t, func() { + throttle4(ctx) + }, 100*time.Millisecond) + }) +} + +func TestThrottler_Bruteforce(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + th := newMemoryThrottlerForTest(t) + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + delay := 100 * time.Millisecond + for range maxBruteforceAttempts { + throttle, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle(ctx) + }, delay) + delay *= 2 + if delay > maxThrottleDelay { + delay = maxThrottleDelay + } + } + + _, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.ErrorIs(err, ErrBruteforceDetected) + }) +} + +func TestThrottler_Cleanup(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + throttler := newMemoryThrottlerForTest(t) + th, ok := throttler.(*memoryThrottler) + require.True(t, ok, "required memoryThrottler, got %T", throttler) + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + throttle1, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle1(ctx) + }, 100*time.Millisecond) + + throttle2, err := th.CheckBruteforce(ctx, "192.168.0.2", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle2(ctx) + }, 100*time.Millisecond) + + time.Sleep(time.Hour) + + throttle3, err := th.CheckBruteforce(ctx, "192.168.0.1", "action2") + assert.NoError(err) + expectDelay(t, func() { + throttle3(ctx) + }, 100*time.Millisecond) + + throttle4, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle4(ctx) + }, 200*time.Millisecond) + + cleanupNow := time.Now().Add(-time.Hour).Add(maxBruteforceAge).Add(time.Second) + th.cleanup(cleanupNow) + + assert.Len(th.getEntries("192.168.0.1", "action1"), 1) + assert.Len(th.getEntries("192.168.0.1", "action2"), 1) + + th.mu.RLock() + if entries, found := th.clients["192.168.0.2"]; found { + assert.Fail("should have removed client \"192.168.0.2\"", "got %+v", entries) + } + th.mu.RUnlock() + + throttle5, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle5(ctx) + }, 200*time.Millisecond) + }) +} + +func TestThrottler_ExpirePartial(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + th := newMemoryThrottlerForTest(t) + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + throttle1, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle1(ctx) + }, 100*time.Millisecond) + + time.Sleep(time.Minute) + + throttle2, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle2(ctx) + }, 200*time.Millisecond) + + time.Sleep(maxBruteforceAge - time.Minute + time.Second) + + throttle3, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle3(ctx) + }, 200*time.Millisecond) + }) +} + +func TestThrottler_ExpireAll(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + th := newMemoryThrottlerForTest(t) + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + throttle1, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle1(ctx) + }, 100*time.Millisecond) + + time.Sleep(time.Millisecond) + + throttle2, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle2(ctx) + }, 200*time.Millisecond) + + time.Sleep(maxBruteforceAge + time.Second) + + throttle3, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + assert.NoError(err) + expectDelay(t, func() { + throttle3(ctx) + }, 100*time.Millisecond) + }) +} + +func TestThrottler_Negative(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + th := newMemoryThrottlerForTest(t) + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + delay := 100 * time.Millisecond + for range maxBruteforceAttempts * 10 { + throttle, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") + if err != nil { + assert.ErrorIs(err, ErrBruteforceDetected) + } + expectDelay(t, func() { + throttle(ctx) + }, delay) + delay *= 2 + if delay > maxThrottleDelay { + delay = maxThrottleDelay + } + } + }) +} diff --git a/async_events.go b/async_events.go deleted file mode 100644 index 4fb34d8..0000000 --- a/async_events.go +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import "sync" - -type AsyncBackendRoomEventListener interface { - ProcessBackendRoomRequest(message *AsyncMessage) -} - -type AsyncRoomEventListener interface { - ProcessAsyncRoomMessage(message *AsyncMessage) -} - -type AsyncUserEventListener interface { - ProcessAsyncUserMessage(message *AsyncMessage) -} - -type AsyncSessionEventListener interface { - ProcessAsyncSessionMessage(message *AsyncMessage) -} - -type AsyncEvents interface { - Close() - - RegisterBackendRoomListener(roomId string, backend *Backend, listener AsyncBackendRoomEventListener) error - UnregisterBackendRoomListener(roomId string, backend *Backend, listener AsyncBackendRoomEventListener) - - RegisterRoomListener(roomId string, backend *Backend, listener AsyncRoomEventListener) error - UnregisterRoomListener(roomId string, backend *Backend, listener AsyncRoomEventListener) - - RegisterUserListener(userId string, backend *Backend, listener AsyncUserEventListener) error - UnregisterUserListener(userId string, backend *Backend, listener AsyncUserEventListener) - - RegisterSessionListener(sessionId string, backend *Backend, listener AsyncSessionEventListener) error - UnregisterSessionListener(sessionId string, backend *Backend, listener AsyncSessionEventListener) - - PublishBackendRoomMessage(roomId string, backend *Backend, message *AsyncMessage) error - PublishRoomMessage(roomId string, backend *Backend, message *AsyncMessage) error - PublishUserMessage(userId string, backend *Backend, message *AsyncMessage) error - PublishSessionMessage(sessionId string, backend *Backend, message *AsyncMessage) error -} - -func NewAsyncEvents(url string) (AsyncEvents, error) { - client, err := NewNatsClient(url) - if err != nil { - return nil, err - } - - return NewAsyncEventsNats(client) -} - -type asyncBackendRoomSubscriber struct { - mu sync.Mutex - - listeners map[AsyncBackendRoomEventListener]bool -} - -func (s *asyncBackendRoomSubscriber) processBackendRoomRequest(message *AsyncMessage) { - s.mu.Lock() - defer s.mu.Unlock() - - for listener := range s.listeners { - s.mu.Unlock() - listener.ProcessBackendRoomRequest(message) - s.mu.Lock() - } -} - -func (s *asyncBackendRoomSubscriber) addListener(listener AsyncBackendRoomEventListener) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.listeners == nil { - s.listeners = make(map[AsyncBackendRoomEventListener]bool) - } - s.listeners[listener] = true -} - -func (s *asyncBackendRoomSubscriber) removeListener(listener AsyncBackendRoomEventListener) bool { - s.mu.Lock() - defer s.mu.Unlock() - - delete(s.listeners, listener) - return len(s.listeners) > 0 -} - -type asyncRoomSubscriber struct { - mu sync.Mutex - - listeners map[AsyncRoomEventListener]bool -} - -func (s *asyncRoomSubscriber) processAsyncRoomMessage(message *AsyncMessage) { - s.mu.Lock() - defer s.mu.Unlock() - - for listener := range s.listeners { - s.mu.Unlock() - listener.ProcessAsyncRoomMessage(message) - s.mu.Lock() - } -} - -func (s *asyncRoomSubscriber) addListener(listener AsyncRoomEventListener) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.listeners == nil { - s.listeners = make(map[AsyncRoomEventListener]bool) - } - s.listeners[listener] = true -} - -func (s *asyncRoomSubscriber) removeListener(listener AsyncRoomEventListener) bool { - s.mu.Lock() - defer s.mu.Unlock() - - delete(s.listeners, listener) - return len(s.listeners) > 0 -} - -type asyncUserSubscriber struct { - mu sync.Mutex - - listeners map[AsyncUserEventListener]bool -} - -func (s *asyncUserSubscriber) processAsyncUserMessage(message *AsyncMessage) { - s.mu.Lock() - defer s.mu.Unlock() - - for listener := range s.listeners { - s.mu.Unlock() - listener.ProcessAsyncUserMessage(message) - s.mu.Lock() - } -} - -func (s *asyncUserSubscriber) addListener(listener AsyncUserEventListener) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.listeners == nil { - s.listeners = make(map[AsyncUserEventListener]bool) - } - s.listeners[listener] = true -} - -func (s *asyncUserSubscriber) removeListener(listener AsyncUserEventListener) bool { - s.mu.Lock() - defer s.mu.Unlock() - - delete(s.listeners, listener) - return len(s.listeners) > 0 -} - -type asyncSessionSubscriber struct { - mu sync.Mutex - - listeners map[AsyncSessionEventListener]bool -} - -func (s *asyncSessionSubscriber) processAsyncSessionMessage(message *AsyncMessage) { - s.mu.Lock() - defer s.mu.Unlock() - - for listener := range s.listeners { - s.mu.Unlock() - listener.ProcessAsyncSessionMessage(message) - s.mu.Lock() - } -} - -func (s *asyncSessionSubscriber) addListener(listener AsyncSessionEventListener) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.listeners == nil { - s.listeners = make(map[AsyncSessionEventListener]bool) - } - s.listeners[listener] = true -} - -func (s *asyncSessionSubscriber) removeListener(listener AsyncSessionEventListener) bool { - s.mu.Lock() - defer s.mu.Unlock() - - delete(s.listeners, listener) - return len(s.listeners) > 0 -} diff --git a/async_events_nats.go b/async_events_nats.go deleted file mode 100644 index 0db3502..0000000 --- a/async_events_nats.go +++ /dev/null @@ -1,452 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "log" - "sync" - "time" - - "github.com/nats-io/nats.go" -) - -func GetSubjectForBackendRoomId(roomId string, backend *Backend) string { - if backend == nil || backend.IsCompat() { - return GetEncodedSubject("backend.room", roomId) - } - - return GetEncodedSubject("backend.room", roomId+"|"+backend.Id()) -} - -func GetSubjectForRoomId(roomId string, backend *Backend) string { - if backend == nil || backend.IsCompat() { - return GetEncodedSubject("room", roomId) - } - - return GetEncodedSubject("room", roomId+"|"+backend.Id()) -} - -func GetSubjectForUserId(userId string, backend *Backend) string { - if backend == nil || backend.IsCompat() { - return GetEncodedSubject("user", userId) - } - - return GetEncodedSubject("user", userId+"|"+backend.Id()) -} - -func GetSubjectForSessionId(sessionId string, backend *Backend) string { - return "session." + sessionId -} - -type asyncSubscriberNats struct { - key string - client NatsClient - - receiver chan *nats.Msg - closeChan chan struct{} - subscription NatsSubscription - - processMessage func(*nats.Msg) -} - -func newAsyncSubscriberNats(key string, client NatsClient) (*asyncSubscriberNats, error) { - receiver := make(chan *nats.Msg, 64) - sub, err := client.Subscribe(key, receiver) - if err != nil { - return nil, err - } - - result := &asyncSubscriberNats{ - key: key, - client: client, - - receiver: receiver, - closeChan: make(chan struct{}), - subscription: sub, - } - return result, nil -} - -func (s *asyncSubscriberNats) run() { - defer func() { - if err := s.subscription.Unsubscribe(); err != nil { - log.Printf("Error unsubscribing %s: %s", s.key, err) - } - }() - - for { - select { - case msg := <-s.receiver: - s.processMessage(msg) - for count := len(s.receiver); count > 0; count-- { - s.processMessage(<-s.receiver) - } - case <-s.closeChan: - return - } - } -} - -func (s *asyncSubscriberNats) close() { - close(s.closeChan) -} - -type asyncBackendRoomSubscriberNats struct { - *asyncSubscriberNats - asyncBackendRoomSubscriber -} - -func newAsyncBackendRoomSubscriberNats(key string, client NatsClient) (*asyncBackendRoomSubscriberNats, error) { - sub, err := newAsyncSubscriberNats(key, client) - if err != nil { - return nil, err - } - - result := &asyncBackendRoomSubscriberNats{ - asyncSubscriberNats: sub, - } - result.processMessage = result.doProcessMessage - go result.run() - return result, nil -} - -func (s *asyncBackendRoomSubscriberNats) doProcessMessage(msg *nats.Msg) { - var message AsyncMessage - if err := s.client.Decode(msg, &message); err != nil { - log.Printf("Could not decode NATS message %+v, %s", msg, err) - return - } - - s.processBackendRoomRequest(&message) -} - -type asyncRoomSubscriberNats struct { - asyncRoomSubscriber - *asyncSubscriberNats -} - -func newAsyncRoomSubscriberNats(key string, client NatsClient) (*asyncRoomSubscriberNats, error) { - sub, err := newAsyncSubscriberNats(key, client) - if err != nil { - return nil, err - } - - result := &asyncRoomSubscriberNats{ - asyncSubscriberNats: sub, - } - result.processMessage = result.doProcessMessage - go result.run() - return result, nil -} - -func (s *asyncRoomSubscriberNats) doProcessMessage(msg *nats.Msg) { - var message AsyncMessage - if err := s.client.Decode(msg, &message); err != nil { - log.Printf("Could not decode nats message %+v, %s", msg, err) - return - } - - s.processAsyncRoomMessage(&message) -} - -type asyncUserSubscriberNats struct { - *asyncSubscriberNats - asyncUserSubscriber -} - -func newAsyncUserSubscriberNats(key string, client NatsClient) (*asyncUserSubscriberNats, error) { - sub, err := newAsyncSubscriberNats(key, client) - if err != nil { - return nil, err - } - - result := &asyncUserSubscriberNats{ - asyncSubscriberNats: sub, - } - result.processMessage = result.doProcessMessage - go result.run() - return result, nil -} - -func (s *asyncUserSubscriberNats) doProcessMessage(msg *nats.Msg) { - var message AsyncMessage - if err := s.client.Decode(msg, &message); err != nil { - log.Printf("Could not decode nats message %+v, %s", msg, err) - return - } - - s.processAsyncUserMessage(&message) -} - -type asyncSessionSubscriberNats struct { - *asyncSubscriberNats - asyncSessionSubscriber -} - -func newAsyncSessionSubscriberNats(key string, client NatsClient) (*asyncSessionSubscriberNats, error) { - sub, err := newAsyncSubscriberNats(key, client) - if err != nil { - return nil, err - } - - result := &asyncSessionSubscriberNats{ - asyncSubscriberNats: sub, - } - result.processMessage = result.doProcessMessage - go result.run() - return result, nil -} - -func (s *asyncSessionSubscriberNats) doProcessMessage(msg *nats.Msg) { - var message AsyncMessage - if err := s.client.Decode(msg, &message); err != nil { - log.Printf("Could not decode nats message %+v, %s", msg, err) - return - } - - s.processAsyncSessionMessage(&message) -} - -type asyncEventsNats struct { - mu sync.Mutex - client NatsClient - - backendRoomSubscriptions map[string]*asyncBackendRoomSubscriberNats - roomSubscriptions map[string]*asyncRoomSubscriberNats - userSubscriptions map[string]*asyncUserSubscriberNats - sessionSubscriptions map[string]*asyncSessionSubscriberNats -} - -func NewAsyncEventsNats(client NatsClient) (AsyncEvents, error) { - events := &asyncEventsNats{ - client: client, - - backendRoomSubscriptions: make(map[string]*asyncBackendRoomSubscriberNats), - roomSubscriptions: make(map[string]*asyncRoomSubscriberNats), - userSubscriptions: make(map[string]*asyncUserSubscriberNats), - sessionSubscriptions: make(map[string]*asyncSessionSubscriberNats), - } - return events, nil -} - -func (e *asyncEventsNats) Close() { - e.mu.Lock() - defer e.mu.Unlock() - var wg sync.WaitGroup - wg.Add(1) - go func(subscriptions map[string]*asyncBackendRoomSubscriberNats) { - defer wg.Done() - for _, sub := range subscriptions { - sub.close() - } - }(e.backendRoomSubscriptions) - wg.Add(1) - go func(subscriptions map[string]*asyncRoomSubscriberNats) { - defer wg.Done() - for _, sub := range subscriptions { - sub.close() - } - }(e.roomSubscriptions) - wg.Add(1) - go func(subscriptions map[string]*asyncUserSubscriberNats) { - defer wg.Done() - for _, sub := range subscriptions { - sub.close() - } - }(e.userSubscriptions) - wg.Add(1) - go func(subscriptions map[string]*asyncSessionSubscriberNats) { - defer wg.Done() - for _, sub := range subscriptions { - sub.close() - } - }(e.sessionSubscriptions) - // Can't use clear(...) here as the maps are processed asynchronously by the - // goroutines above. - e.backendRoomSubscriptions = make(map[string]*asyncBackendRoomSubscriberNats) - e.roomSubscriptions = make(map[string]*asyncRoomSubscriberNats) - e.userSubscriptions = make(map[string]*asyncUserSubscriberNats) - e.sessionSubscriptions = make(map[string]*asyncSessionSubscriberNats) - wg.Wait() - e.client.Close() -} - -func (e *asyncEventsNats) RegisterBackendRoomListener(roomId string, backend *Backend, listener AsyncBackendRoomEventListener) error { - key := GetSubjectForBackendRoomId(roomId, backend) - - e.mu.Lock() - defer e.mu.Unlock() - sub, found := e.backendRoomSubscriptions[key] - if !found { - var err error - if sub, err = newAsyncBackendRoomSubscriberNats(key, e.client); err != nil { - return err - } - - e.backendRoomSubscriptions[key] = sub - } - sub.addListener(listener) - return nil -} - -func (e *asyncEventsNats) UnregisterBackendRoomListener(roomId string, backend *Backend, listener AsyncBackendRoomEventListener) { - key := GetSubjectForBackendRoomId(roomId, backend) - - e.mu.Lock() - defer e.mu.Unlock() - sub, found := e.backendRoomSubscriptions[key] - if !found { - return - } - - if !sub.removeListener(listener) { - delete(e.backendRoomSubscriptions, key) - sub.close() - } -} - -func (e *asyncEventsNats) RegisterRoomListener(roomId string, backend *Backend, listener AsyncRoomEventListener) error { - key := GetSubjectForRoomId(roomId, backend) - - e.mu.Lock() - defer e.mu.Unlock() - sub, found := e.roomSubscriptions[key] - if !found { - var err error - if sub, err = newAsyncRoomSubscriberNats(key, e.client); err != nil { - return err - } - - e.roomSubscriptions[key] = sub - } - sub.addListener(listener) - return nil -} - -func (e *asyncEventsNats) UnregisterRoomListener(roomId string, backend *Backend, listener AsyncRoomEventListener) { - key := GetSubjectForRoomId(roomId, backend) - - e.mu.Lock() - defer e.mu.Unlock() - sub, found := e.roomSubscriptions[key] - if !found { - return - } - - if !sub.removeListener(listener) { - delete(e.roomSubscriptions, key) - sub.close() - } -} - -func (e *asyncEventsNats) RegisterUserListener(roomId string, backend *Backend, listener AsyncUserEventListener) error { - key := GetSubjectForUserId(roomId, backend) - - e.mu.Lock() - defer e.mu.Unlock() - sub, found := e.userSubscriptions[key] - if !found { - var err error - if sub, err = newAsyncUserSubscriberNats(key, e.client); err != nil { - return err - } - - e.userSubscriptions[key] = sub - } - sub.addListener(listener) - return nil -} - -func (e *asyncEventsNats) UnregisterUserListener(roomId string, backend *Backend, listener AsyncUserEventListener) { - key := GetSubjectForUserId(roomId, backend) - - e.mu.Lock() - defer e.mu.Unlock() - sub, found := e.userSubscriptions[key] - if !found { - return - } - - if !sub.removeListener(listener) { - delete(e.userSubscriptions, key) - sub.close() - } -} - -func (e *asyncEventsNats) RegisterSessionListener(sessionId string, backend *Backend, listener AsyncSessionEventListener) error { - key := GetSubjectForSessionId(sessionId, backend) - - e.mu.Lock() - defer e.mu.Unlock() - sub, found := e.sessionSubscriptions[key] - if !found { - var err error - if sub, err = newAsyncSessionSubscriberNats(key, e.client); err != nil { - return err - } - - e.sessionSubscriptions[key] = sub - } - sub.addListener(listener) - return nil -} - -func (e *asyncEventsNats) UnregisterSessionListener(sessionId string, backend *Backend, listener AsyncSessionEventListener) { - key := GetSubjectForSessionId(sessionId, backend) - - e.mu.Lock() - defer e.mu.Unlock() - sub, found := e.sessionSubscriptions[key] - if !found { - return - } - - if !sub.removeListener(listener) { - delete(e.sessionSubscriptions, key) - sub.close() - } -} - -func (e *asyncEventsNats) publish(subject string, message *AsyncMessage) error { - message.SendTime = time.Now() - return e.client.Publish(subject, message) -} - -func (e *asyncEventsNats) PublishBackendRoomMessage(roomId string, backend *Backend, message *AsyncMessage) error { - subject := GetSubjectForBackendRoomId(roomId, backend) - return e.publish(subject, message) -} - -func (e *asyncEventsNats) PublishRoomMessage(roomId string, backend *Backend, message *AsyncMessage) error { - subject := GetSubjectForRoomId(roomId, backend) - return e.publish(subject, message) -} - -func (e *asyncEventsNats) PublishUserMessage(userId string, backend *Backend, message *AsyncMessage) error { - subject := GetSubjectForUserId(userId, backend) - return e.publish(subject, message) -} - -func (e *asyncEventsNats) PublishSessionMessage(sessionId string, backend *Backend, message *AsyncMessage) error { - subject := GetSubjectForSessionId(sessionId, backend) - return e.publish(subject, message) -} diff --git a/async_events_test.go b/async_events_test.go deleted file mode 100644 index b72a30a..0000000 --- a/async_events_test.go +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -var ( - eventBackendsForTest = []string{ - "loopback", - "nats", - } -) - -func getAsyncEventsForTest(t *testing.T) AsyncEvents { - var events AsyncEvents - if strings.HasSuffix(t.Name(), "/nats") { - events = getRealAsyncEventsForTest(t) - } else { - events = getLoopbackAsyncEventsForTest(t) - } - t.Cleanup(func() { - events.Close() - }) - return events -} - -func getRealAsyncEventsForTest(t *testing.T) AsyncEvents { - url := startLocalNatsServer(t) - events, err := NewAsyncEvents(url) - if err != nil { - require.NoError(t, err) - } - return events -} - -func getLoopbackAsyncEventsForTest(t *testing.T) AsyncEvents { - events, err := NewAsyncEvents(NatsLoopbackUrl) - if err != nil { - require.NoError(t, err) - } - - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - nats := (events.(*asyncEventsNats)).client - (nats).(*LoopbackNatsClient).waitForSubscriptionsEmpty(ctx, t) - }) - return events -} diff --git a/backend_client.go b/backend_client.go deleted file mode 100644 index 8b60869..0000000 --- a/backend_client.go +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net/http" - "net/url" - "strings" - - "github.com/dlintw/goconf" -) - -var ( - ErrNotRedirecting = errors.New("not redirecting to different host") - ErrUnsupportedContentType = errors.New("unsupported_content_type") - - ErrIncompleteResponse = errors.New("incomplete OCS response") - ErrThrottledResponse = errors.New("throttled OCS response") -) - -type BackendClient struct { - hub *Hub - version string - backends *BackendConfiguration - - pool *HttpClientPool - capabilities *Capabilities -} - -func NewBackendClient(config *goconf.ConfigFile, maxConcurrentRequestsPerHost int, version string, etcdClient *EtcdClient) (*BackendClient, error) { - backends, err := NewBackendConfiguration(config, etcdClient) - if err != nil { - return nil, err - } - - skipverify, _ := config.GetBool("backend", "skipverify") - if skipverify { - log.Println("WARNING: Backend verification is disabled!") - } - - pool, err := NewHttpClientPool(maxConcurrentRequestsPerHost, skipverify) - if err != nil { - return nil, err - } - - capabilities, err := NewCapabilities(version, pool) - if err != nil { - return nil, err - } - - return &BackendClient{ - version: version, - backends: backends, - - pool: pool, - capabilities: capabilities, - }, nil -} - -func (b *BackendClient) Close() { - b.backends.Close() -} - -func (b *BackendClient) Reload(config *goconf.ConfigFile) { - b.backends.Reload(config) -} - -func (b *BackendClient) GetCompatBackend() *Backend { - return b.backends.GetCompatBackend() -} - -func (b *BackendClient) GetBackend(u *url.URL) *Backend { - return b.backends.GetBackend(u) -} - -func (b *BackendClient) GetBackends() []*Backend { - return b.backends.GetBackends() -} - -func (b *BackendClient) IsUrlAllowed(u *url.URL) bool { - return b.backends.IsUrlAllowed(u) -} - -func isOcsRequest(u *url.URL) bool { - return strings.Contains(u.Path, "/ocs/v2.php") || strings.Contains(u.Path, "/ocs/v1.php") -} - -// PerformJSONRequest sends a JSON POST request to the given url and decodes -// the result into "response". -func (b *BackendClient) PerformJSONRequest(ctx context.Context, u *url.URL, request interface{}, response interface{}) error { - if u == nil { - return fmt.Errorf("no url passed to perform JSON request %+v", request) - } - - secret := b.backends.GetSecret(u) - if secret == nil { - return fmt.Errorf("no backend secret configured for for %s", u) - } - - var requestUrl *url.URL - if b.capabilities.HasCapabilityFeature(ctx, u, FeatureSignalingV3Api) { - newUrl := *u - newUrl.Path = strings.Replace(newUrl.Path, "/spreed/api/v1/signaling/", "/spreed/api/v3/signaling/", -1) - newUrl.Path = strings.Replace(newUrl.Path, "/spreed/api/v2/signaling/", "/spreed/api/v3/signaling/", -1) - requestUrl = &newUrl - } else { - requestUrl = u - } - - c, pool, err := b.pool.Get(ctx, u) - if err != nil { - log.Printf("Could not get client for host %s: %s", u.Host, err) - return err - } - defer pool.Put(c) - - data, err := json.Marshal(request) - if err != nil { - log.Printf("Could not marshal request %+v: %s", request, err) - return err - } - - req, err := http.NewRequestWithContext(ctx, "POST", requestUrl.String(), bytes.NewReader(data)) - if err != nil { - log.Printf("Could not create request to %s: %s", requestUrl, err) - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("OCS-APIRequest", "true") - req.Header.Set("User-Agent", "nextcloud-spreed-signaling/"+b.version) - if b.hub != nil { - req.Header.Set("X-Spreed-Signaling-Features", strings.Join(b.hub.info.Features, ", ")) - } - - // Add checksum so the backend can validate the request. - AddBackendChecksum(req, data, secret) - - resp, err := c.Do(req) - if err != nil { - log.Printf("Could not send request %s to %s: %s", string(data), req.URL, err) - return err - } - defer resp.Body.Close() - - ct := resp.Header.Get("Content-Type") - if !strings.HasPrefix(ct, "application/json") { - log.Printf("Received unsupported content-type from %s: %s (%s)", req.URL, ct, resp.Status) - return ErrUnsupportedContentType - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Printf("Could not read response body from %s: %s", req.URL, err) - return err - } - - if isOcsRequest(u) || req.Header.Get("OCS-APIRequest") != "" { - // OCS response are wrapped in an OCS container that needs to be parsed - // to get the actual contents: - // { - // "ocs": { - // "meta": { ... }, - // "data": { ... } - // } - // } - var ocs OcsResponse - if err := json.Unmarshal(body, &ocs); err != nil { - log.Printf("Could not decode OCS response %s from %s: %s", string(body), req.URL, err) - return err - } else if ocs.Ocs == nil || len(ocs.Ocs.Data) == 0 { - log.Printf("Incomplete OCS response %s from %s", string(body), req.URL) - return ErrIncompleteResponse - } - - switch ocs.Ocs.Meta.StatusCode { - case http.StatusTooManyRequests: - log.Printf("Throttled OCS response %s from %s", string(body), req.URL) - return ErrThrottledResponse - } - - if err := json.Unmarshal(ocs.Ocs.Data, response); err != nil { - log.Printf("Could not decode OCS response body %s from %s: %s", string(ocs.Ocs.Data), req.URL, err) - return err - } - } else if err := json.Unmarshal(body, response); err != nil { - log.Printf("Could not decode response body %s from %s: %s", string(body), req.URL, err) - return err - } - return nil -} diff --git a/backend_storage_static.go b/backend_storage_static.go deleted file mode 100644 index 4a60c3f..0000000 --- a/backend_storage_static.go +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "log" - "net/url" - "reflect" - "strings" - - "github.com/dlintw/goconf" -) - -type backendStorageStatic struct { - backendStorageCommon - - // Deprecated - allowAll bool - commonSecret []byte - compatBackend *Backend -} - -func NewBackendStorageStatic(config *goconf.ConfigFile) (BackendStorage, error) { - allowAll, _ := config.GetBool("backend", "allowall") - allowHttp, _ := config.GetBool("backend", "allowhttp") - commonSecret, _ := config.GetString("backend", "secret") - sessionLimit, err := config.GetInt("backend", "sessionlimit") - if err != nil || sessionLimit < 0 { - sessionLimit = 0 - } - backends := make(map[string][]*Backend) - var compatBackend *Backend - numBackends := 0 - if allowAll { - log.Println("WARNING: All backend hostnames are allowed, only use for development!") - compatBackend = &Backend{ - id: "compat", - secret: []byte(commonSecret), - compat: true, - - allowHttp: allowHttp, - - sessionLimit: uint64(sessionLimit), - } - if sessionLimit > 0 { - log.Printf("Allow a maximum of %d sessions", sessionLimit) - } - updateBackendStats(compatBackend) - numBackends++ - } else if backendIds, _ := config.GetString("backend", "backends"); backendIds != "" { - for host, configuredBackends := range getConfiguredHosts(backendIds, config, commonSecret) { - backends[host] = append(backends[host], configuredBackends...) - for _, be := range configuredBackends { - log.Printf("Backend %s added for %s", be.id, be.url) - updateBackendStats(be) - } - numBackends += len(configuredBackends) - } - } else if allowedUrls, _ := config.GetString("backend", "allowed"); allowedUrls != "" { - // Old-style configuration, only hosts are configured and are using a common secret. - allowMap := make(map[string]bool) - for _, u := range strings.Split(allowedUrls, ",") { - u = strings.TrimSpace(u) - if idx := strings.IndexByte(u, '/'); idx != -1 { - log.Printf("WARNING: Removing path from allowed hostname \"%s\", check your configuration!", u) - u = u[:idx] - } - if u != "" { - allowMap[strings.ToLower(u)] = true - } - } - - if len(allowMap) == 0 { - log.Println("WARNING: No backend hostnames are allowed, check your configuration!") - } else { - compatBackend = &Backend{ - id: "compat", - secret: []byte(commonSecret), - compat: true, - - allowHttp: allowHttp, - - sessionLimit: uint64(sessionLimit), - } - hosts := make([]string, 0, len(allowMap)) - for host := range allowMap { - hosts = append(hosts, host) - backends[host] = []*Backend{compatBackend} - } - if len(hosts) > 1 { - log.Println("WARNING: Using deprecated backend configuration. Please migrate the \"allowed\" setting to the new \"backends\" configuration.") - } - log.Printf("Allowed backend hostnames: %s", hosts) - if sessionLimit > 0 { - log.Printf("Allow a maximum of %d sessions", sessionLimit) - } - updateBackendStats(compatBackend) - numBackends++ - } - } - - if numBackends == 0 { - log.Printf("WARNING: No backends configured, client connections will not be possible.") - } - - statsBackendsCurrent.Add(float64(numBackends)) - return &backendStorageStatic{ - backendStorageCommon: backendStorageCommon{ - backends: backends, - }, - - allowAll: allowAll, - commonSecret: []byte(commonSecret), - compatBackend: compatBackend, - }, nil -} - -func (s *backendStorageStatic) Close() { -} - -func (s *backendStorageStatic) RemoveBackendsForHost(host string) { - if oldBackends := s.backends[host]; len(oldBackends) > 0 { - for _, backend := range oldBackends { - log.Printf("Backend %s removed for %s", backend.id, backend.url) - deleteBackendStats(backend) - } - statsBackendsCurrent.Sub(float64(len(oldBackends))) - } - delete(s.backends, host) -} - -func (s *backendStorageStatic) UpsertHost(host string, backends []*Backend) { - for existingIndex, existingBackend := range s.backends[host] { - found := false - index := 0 - for _, newBackend := range backends { - if reflect.DeepEqual(existingBackend, newBackend) { // otherwise we could manually compare the struct members here - found = true - backends = append(backends[:index], backends[index+1:]...) - break - } else if newBackend.id == existingBackend.id { - found = true - s.backends[host][existingIndex] = newBackend - backends = append(backends[:index], backends[index+1:]...) - log.Printf("Backend %s updated for %s", newBackend.id, newBackend.url) - updateBackendStats(newBackend) - break - } - index++ - } - if !found { - removed := s.backends[host][existingIndex] - log.Printf("Backend %s removed for %s", removed.id, removed.url) - s.backends[host] = append(s.backends[host][:existingIndex], s.backends[host][existingIndex+1:]...) - deleteBackendStats(removed) - statsBackendsCurrent.Dec() - } - } - - s.backends[host] = append(s.backends[host], backends...) - for _, added := range backends { - log.Printf("Backend %s added for %s", added.id, added.url) - updateBackendStats(added) - } - statsBackendsCurrent.Add(float64(len(backends))) -} - -func getConfiguredBackendIDs(backendIds string) (ids []string) { - seen := make(map[string]bool) - - for _, id := range strings.Split(backendIds, ",") { - id = strings.TrimSpace(id) - if id == "" { - continue - } - - if seen[id] { - continue - } - ids = append(ids, id) - seen[id] = true - } - - return ids -} - -func getConfiguredHosts(backendIds string, config *goconf.ConfigFile, commonSecret string) (hosts map[string][]*Backend) { - hosts = make(map[string][]*Backend) - for _, id := range getConfiguredBackendIDs(backendIds) { - u, _ := config.GetString(id, "url") - if u == "" { - log.Printf("Backend %s is missing or incomplete, skipping", id) - continue - } - - if u[len(u)-1] != '/' { - u += "/" - } - parsed, err := url.Parse(u) - if err != nil { - log.Printf("Backend %s has an invalid url %s configured (%s), skipping", id, u, err) - continue - } - - if strings.Contains(parsed.Host, ":") && hasStandardPort(parsed) { - parsed.Host = parsed.Hostname() - u = parsed.String() - } - - secret, _ := config.GetString(id, "secret") - if secret == "" && commonSecret != "" { - log.Printf("Backend %s has no own shared secret set, using common shared secret", id) - secret = commonSecret - } - if u == "" || secret == "" { - log.Printf("Backend %s is missing or incomplete, skipping", id) - continue - } - - sessionLimit, err := config.GetInt(id, "sessionlimit") - if err != nil || sessionLimit < 0 { - sessionLimit = 0 - } - if sessionLimit > 0 { - log.Printf("Backend %s allows a maximum of %d sessions", id, sessionLimit) - } - - maxStreamBitrate, err := config.GetInt(id, "maxstreambitrate") - if err != nil || maxStreamBitrate < 0 { - maxStreamBitrate = 0 - } - maxScreenBitrate, err := config.GetInt(id, "maxscreenbitrate") - if err != nil || maxScreenBitrate < 0 { - maxScreenBitrate = 0 - } - - hosts[parsed.Host] = append(hosts[parsed.Host], &Backend{ - id: id, - url: u, - parsedUrl: parsed, - secret: []byte(secret), - - allowHttp: parsed.Scheme == "http", - - maxStreamBitrate: maxStreamBitrate, - maxScreenBitrate: maxScreenBitrate, - - sessionLimit: uint64(sessionLimit), - }) - } - - return hosts -} - -func (s *backendStorageStatic) Reload(config *goconf.ConfigFile) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.compatBackend != nil { - log.Println("Old-style configuration active, reload is not supported") - return - } - - commonSecret, _ := config.GetString("backend", "secret") - - if backendIds, _ := config.GetString("backend", "backends"); backendIds != "" { - configuredHosts := getConfiguredHosts(backendIds, config, commonSecret) - - // remove backends that are no longer configured - for hostname := range s.backends { - if _, ok := configuredHosts[hostname]; !ok { - s.RemoveBackendsForHost(hostname) - } - } - - // rewrite backends adding newly configured ones and rewriting existing ones - for hostname, configuredBackends := range configuredHosts { - s.UpsertHost(hostname, configuredBackends) - } - } -} - -func (s *backendStorageStatic) GetCompatBackend() *Backend { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.compatBackend -} - -func (s *backendStorageStatic) GetBackend(u *url.URL) *Backend { - s.mu.RLock() - defer s.mu.RUnlock() - - if _, found := s.backends[u.Host]; !found { - if s.allowAll { - return s.compatBackend - } - return nil - } - - return s.getBackendLocked(u) -} diff --git a/client.go b/client/client.go similarity index 59% rename from client.go rename to client/client.go index 3980218..7f53bb0 100644 --- a/client.go +++ b/client/client.go @@ -19,14 +19,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package client import ( "bytes" "context" "encoding/json" "errors" - "log" + "io" "net" "strconv" "strings" @@ -36,6 +36,12 @@ import ( "github.com/gorilla/websocket" "github.com/mailru/easyjson" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/pool" ) const ( @@ -52,136 +58,112 @@ const ( maxMessageSize = 64 * 1024 ) -var ( - noCountry = "no-country" - - loopback = "loopback" - - unknownCountry = "unknown-country" -) - func init() { RegisterClientStats() } -func IsValidCountry(country string) bool { - switch country { - case "": - fallthrough - case noCountry: - fallthrough - case loopback: - fallthrough - case unknownCountry: - return false - default: - return true - } -} - var ( - InvalidFormat = NewError("invalid_format", "Invalid data format.") + InvalidFormat = api.NewError("invalid_format", "Invalid data format.") - bufferPool = sync.Pool{ - New: func() interface{} { - return new(bytes.Buffer) - }, - } + bufferPool pool.BufferPool ) type WritableClientMessage interface { json.Marshaler - CloseAfterSend(session Session) bool + CloseAfterSend(session api.RoomAware) bool } type HandlerClient interface { Context() context.Context RemoteAddr() string - Country() string + Country() geoip.Country UserAgent() string IsConnected() bool - IsAuthenticated() bool - GetSession() Session - SetSession(session Session) - - SendError(e *Error) bool - SendByeResponse(message *ClientMessage) bool - SendByeResponseWithReason(message *ClientMessage, reason string) bool + SendError(e *api.Error) bool + SendByeResponse(message *api.ClientMessage) bool + SendByeResponseWithReason(message *api.ClientMessage, reason string) bool SendMessage(message WritableClientMessage) bool Close() } -type ClientHandler interface { - OnClosed(HandlerClient) - OnMessageReceived(HandlerClient, []byte) - OnRTTReceived(HandlerClient, time.Duration) +type Handler interface { + GetSessionId() api.PublicSessionId + + OnClosed() + OnMessageReceived([]byte) + OnRTTReceived(time.Duration) } -type ClientGeoIpHandler interface { - OnLookupCountry(HandlerClient) string +type GeoIpHandler interface { + OnLookupCountry(addr string) geoip.Country +} + +type InRoomHandler interface { + IsInRoom(string) bool +} + +type SessionCloserHandler interface { + CloseSession() } type Client struct { - ctx context.Context + logger log.Logger + ctx context.Context + // +checklocks:mu conn *websocket.Conn addr string agent string closed atomic.Int32 - country *string + country *geoip.Country logRTT bool handlerMu sync.RWMutex - handler ClientHandler + // +checklocks:handlerMu + handler Handler - session atomic.Pointer[Session] - sessionId atomic.Pointer[string] + sessionId atomic.Pointer[api.PublicSessionId] mu sync.Mutex - closer *Closer + closer *internal.Closer closeOnce sync.Once messagesDone chan struct{} messageChan chan *bytes.Buffer } -func NewClient(ctx context.Context, conn *websocket.Conn, remoteAddress string, agent string, handler ClientHandler) (*Client, error) { - remoteAddress = strings.TrimSpace(remoteAddress) - if remoteAddress == "" { - remoteAddress = "unknown remote address" - } - agent = strings.TrimSpace(agent) - if agent == "" { - agent = "unknown user agent" - } +func (c *Client) SetConn(ctx context.Context, conn *websocket.Conn, remoteAddress string, agent string, logRTT bool, handler Handler) { + c.mu.Lock() + defer c.mu.Unlock() - client := &Client{ - agent: agent, - logRTT: true, - } - client.SetConn(ctx, conn, remoteAddress, handler) - return client, nil -} - -func (c *Client) SetConn(ctx context.Context, conn *websocket.Conn, remoteAddress string, handler ClientHandler) { + c.logger = log.LoggerFromContext(ctx) c.ctx = ctx c.conn = conn c.addr = remoteAddress + c.agent = agent + c.logRTT = logRTT c.SetHandler(handler) - c.closer = NewCloser() + c.closer = internal.NewCloser() c.messageChan = make(chan *bytes.Buffer, 16) c.messagesDone = make(chan struct{}) } -func (c *Client) SetHandler(handler ClientHandler) { +func (c *Client) GetConn() *websocket.Conn { + c.mu.Lock() + defer c.mu.Unlock() + + return c.conn +} + +func (c *Client) SetHandler(handler Handler) { c.handlerMu.Lock() defer c.handlerMu.Unlock() c.handler = handler } -func (c *Client) getHandler() ClientHandler { +func (c *Client) getHandler() Handler { c.handlerMu.RLock() defer c.handlerMu.RUnlock() return c.handler @@ -195,40 +177,19 @@ func (c *Client) IsConnected() bool { return c.closed.Load() == 0 } -func (c *Client) IsAuthenticated() bool { - return c.GetSession() != nil -} - -func (c *Client) GetSession() Session { - session := c.session.Load() - if session == nil { - return nil - } - - return *session -} - -func (c *Client) SetSession(session Session) { - if session == nil { - c.session.Store(nil) - } else { - c.session.Store(&session) - } -} - -func (c *Client) SetSessionId(sessionId string) { +func (c *Client) SetSessionId(sessionId api.PublicSessionId) { c.sessionId.Store(&sessionId) } -func (c *Client) GetSessionId() string { +func (c *Client) GetSessionId() api.PublicSessionId { sessionId := c.sessionId.Load() if sessionId == nil { - session := c.GetSession() - if session == nil { + sessionId := c.getHandler().GetSessionId() + if sessionId == "" { return "" } - return session.PublicId() + return sessionId } return *sessionId @@ -242,13 +203,13 @@ func (c *Client) UserAgent() string { return c.agent } -func (c *Client) Country() string { +func (c *Client) Country() geoip.Country { if c.country == nil { - var country string - if handler, ok := c.getHandler().(ClientGeoIpHandler); ok { - country = handler.OnLookupCountry(c) + var country geoip.Country + if handler, ok := c.getHandler().(GeoIpHandler); ok { + country = handler.OnLookupCountry(c.addr) } else { - country = unknownCountry + country = geoip.UnknownCountry } c.country = &country } @@ -256,6 +217,14 @@ func (c *Client) Country() string { return *c.country } +func (c *Client) IsInRoom(id string) bool { + if handler, ok := c.getHandler().(InRoomHandler); ok { + return handler.IsInRoom(id) + } + + return false +} + func (c *Client) Close() { if c.closed.Load() >= 2 { // Prevent reentrant call in case this was the second closing @@ -271,7 +240,8 @@ func (c *Client) Close() { func (c *Client) doClose() { closed := c.closed.Add(1) - if closed == 1 { + switch closed { + case 1: c.mu.Lock() defer c.mu.Unlock() if c.conn != nil { @@ -279,30 +249,29 @@ func (c *Client) doClose() { c.conn.Close() c.conn = nil } - } else if closed == 2 { + case 2: // Both the read pump and message processing must be finished before closing. c.closer.Close() <-c.messagesDone - c.getHandler().OnClosed(c) - c.SetSession(nil) + c.getHandler().OnClosed() } } -func (c *Client) SendError(e *Error) bool { - message := &ServerMessage{ +func (c *Client) SendError(e *api.Error) bool { + message := &api.ServerMessage{ Type: "error", Error: e, } return c.SendMessage(message) } -func (c *Client) SendByeResponse(message *ClientMessage) bool { +func (c *Client) SendByeResponse(message *api.ClientMessage) bool { return c.SendByeResponseWithReason(message, "") } -func (c *Client) SendByeResponseWithReason(message *ClientMessage, reason string) bool { - response := &ServerMessage{ +func (c *Client) SendByeResponseWithReason(message *api.ClientMessage, reason string) bool { + response := &api.ServerMessage{ Type: "bye", } if message != nil { @@ -310,7 +279,7 @@ func (c *Client) SendByeResponseWithReason(message *ClientMessage, reason string } if reason != "" { if response.Bye == nil { - response.Bye = &ByeServerMessage{} + response.Bye = &api.ByeServerMessage{} } response.Bye.Reason = reason } @@ -334,7 +303,7 @@ func (c *Client) ReadPump() { conn := c.conn c.mu.Unlock() if conn == nil { - log.Printf("Connection from %s closed while starting readPump", addr) + c.logger.Printf("Connection from %s closed while starting readPump", addr) return } @@ -345,17 +314,19 @@ func (c *Client) ReadPump() { if msg == "" { return nil } + statsClientBytesTotal.WithLabelValues("incoming").Add(float64(len(msg))) if ts, err := strconv.ParseInt(msg, 10, 64); err == nil { rtt := now.Sub(time.Unix(0, ts)) if c.logRTT { rtt_ms := rtt.Nanoseconds() / time.Millisecond.Nanoseconds() if sessionId := c.GetSessionId(); sessionId != "" { - log.Printf("Client %s has RTT of %d ms (%s)", sessionId, rtt_ms, rtt) + c.logger.Printf("Client %s has RTT of %d ms (%s)", sessionId, rtt_ms, rtt) } else { - log.Printf("Client from %s has RTT of %d ms (%s)", addr, rtt_ms, rtt) + c.logger.Printf("Client from %s has RTT of %d ms (%s)", addr, rtt_ms, rtt) } } - c.getHandler().OnRTTReceived(c, rtt) + statsClientRTT.Observe(float64(rtt.Milliseconds())) + c.getHandler().OnRTTReceived(rtt) } return nil }) @@ -372,9 +343,9 @@ func (c *Client) ReadPump() { websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { if sessionId := c.GetSessionId(); sessionId != "" { - log.Printf("Error reading from client %s: %v", sessionId, err) + c.logger.Printf("Error reading from client %s: %v", sessionId, err) } else { - log.Printf("Error reading from %s: %v", addr, err) + c.logger.Printf("Error reading from %s: %v", addr, err) } } break @@ -382,22 +353,20 @@ func (c *Client) ReadPump() { if messageType != websocket.TextMessage { if sessionId := c.GetSessionId(); sessionId != "" { - log.Printf("Unsupported message type %v from client %s", messageType, sessionId) + c.logger.Printf("Unsupported message type %v from client %s", messageType, sessionId) } else { - log.Printf("Unsupported message type %v from %s", messageType, addr) + c.logger.Printf("Unsupported message type %v from %s", messageType, addr) } c.SendError(InvalidFormat) continue } - decodeBuffer := bufferPool.Get().(*bytes.Buffer) - decodeBuffer.Reset() - if _, err := decodeBuffer.ReadFrom(reader); err != nil { - bufferPool.Put(decodeBuffer) + decodeBuffer, err := bufferPool.ReadAll(reader) + if err != nil { if sessionId := c.GetSessionId(); sessionId != "" { - log.Printf("Error reading message from client %s: %v", sessionId, err) + c.logger.Printf("Error reading message from client %s: %v", sessionId, err) } else { - log.Printf("Error reading message from %s: %v", addr, err) + c.logger.Printf("Error reading message from %s: %v", addr, err) } break } @@ -408,6 +377,8 @@ func (c *Client) ReadPump() { break } + statsClientBytesTotal.WithLabelValues("incoming").Add(float64(decodeBuffer.Len())) + statsClientMessagesTotal.WithLabelValues("incoming").Inc() c.messageChan <- decodeBuffer } } @@ -419,7 +390,7 @@ func (c *Client) processMessages() { break } - c.getHandler().OnMessageReceived(c, buffer.Bytes()) + c.getHandler().OnMessageReceived(buffer.Bytes()) bufferPool.Put(buffer) } @@ -427,16 +398,34 @@ func (c *Client) processMessages() { c.doClose() } +type counterWriter struct { + w io.Writer + counter *int +} + +func (w *counterWriter) Write(p []byte) (int, error) { + written, err := w.w.Write(p) + if written > 0 { + *w.counter += written + } + return written, err +} + +// +checklocks:c.mu func (c *Client) writeInternal(message json.Marshaler) bool { var closeData []byte c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint writer, err := c.conn.NextWriter(websocket.TextMessage) + var written int if err == nil { - if m, ok := (interface{}(message)).(easyjson.Marshaler); ok { - _, err = easyjson.MarshalToWriter(m, writer) + if m, ok := (any(message)).(easyjson.Marshaler); ok { + written, err = easyjson.MarshalToWriter(m, writer) } else { - err = json.NewEncoder(writer).Encode(message) + err = json.NewEncoder(&counterWriter{ + w: writer, + counter: &written, + }).Encode(message) } } if err == nil { @@ -449,49 +438,25 @@ func (c *Client) writeInternal(message json.Marshaler) bool { } if sessionId := c.GetSessionId(); sessionId != "" { - log.Printf("Could not send message %+v to client %s: %v", message, sessionId, err) + c.logger.Printf("Could not send message %+v to client %s: %v", message, sessionId, err) } else { - log.Printf("Could not send message %+v to %s: %v", message, c.RemoteAddr(), err) + c.logger.Printf("Could not send message %+v to %s: %v", message, c.RemoteAddr(), err) } closeData = websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "") goto close } + + statsClientBytesTotal.WithLabelValues("outgoing").Add(float64(written)) + statsClientMessagesTotal.WithLabelValues("outgoing").Inc() return true close: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint if err := c.conn.WriteMessage(websocket.CloseMessage, closeData); err != nil { if sessionId := c.GetSessionId(); sessionId != "" { - log.Printf("Could not send close message to client %s: %v", sessionId, err) + c.logger.Printf("Could not send close message to client %s: %v", sessionId, err) } else { - log.Printf("Could not send close message to %s: %v", c.RemoteAddr(), err) - } - } - return false -} - -func (c *Client) writeError(e error) bool { // nolint - message := &ServerMessage{ - Type: "error", - Error: NewError("internal_error", e.Error()), - } - c.mu.Lock() - defer c.mu.Unlock() - if c.conn == nil { - return false - } - - if !c.writeMessageLocked(message) { - return false - } - - closeData := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, e.Error()) - c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint - if err := c.conn.WriteMessage(websocket.CloseMessage, closeData); err != nil { - if sessionId := c.GetSessionId(); sessionId != "" { - log.Printf("Could not send close message to client %s: %v", sessionId, err) - } else { - log.Printf("Could not send close message to %s: %v", c.RemoteAddr(), err) + c.logger.Printf("Could not send close message to %s: %v", c.RemoteAddr(), err) } } return false @@ -507,19 +472,19 @@ func (c *Client) writeMessage(message WritableClientMessage) bool { return c.writeMessageLocked(message) } +// +checklocks:c.mu func (c *Client) writeMessageLocked(message WritableClientMessage) bool { if !c.writeInternal(message) { return false } - session := c.GetSession() - if message.CloseAfterSend(session) { - c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint - c.conn.WriteMessage(websocket.CloseMessage, []byte{}) // nolint - if session != nil { - go session.Close() - } - go c.Close() + if message.CloseAfterSend(c) { + go func() { + if sc, ok := c.getHandler().(SessionCloserHandler); ok { + sc.CloseSession() + } + c.Close() + }() } return true @@ -537,13 +502,14 @@ func (c *Client) sendPing() bool { c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint if err := c.conn.WriteMessage(websocket.PingMessage, []byte(msg)); err != nil { if sessionId := c.GetSessionId(); sessionId != "" { - log.Printf("Could not send ping to client %s: %v", sessionId, err) + c.logger.Printf("Could not send ping to client %s: %v", sessionId, err) } else { - log.Printf("Could not send ping to %s: %v", c.RemoteAddr(), err) + c.logger.Printf("Could not send ping to %s: %v", c.RemoteAddr(), err) } return false } + statsClientBytesTotal.WithLabelValues("outgoing").Add(float64(len(msg))) return true } diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..ef3aa2c --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,339 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" +) + +func TestCounterWriter(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + var b bytes.Buffer + var written int + w := &counterWriter{ + w: &b, + counter: &written, + } + if count, err := w.Write(nil); assert.NoError(err) && assert.Equal(0, count) { + assert.Equal(0, written) + } + if count, err := w.Write([]byte("foo")); assert.NoError(err) && assert.Equal(3, count) { + assert.Equal(3, written) + } +} + +type serverClient struct { + Client + + t *testing.T + handler *testHandler + + id string + received atomic.Uint32 + sessionClosed atomic.Bool +} + +func newTestClient(h *testHandler, r *http.Request, conn *websocket.Conn, id uint64) *serverClient { + result := &serverClient{ + t: h.t, + handler: h, + id: fmt.Sprintf("session-%d", id), + } + + addr := r.RemoteAddr + if host, _, err := net.SplitHostPort(addr); err == nil { + addr = host + } + + logger := logtest.NewLoggerForTest(h.t) + ctx := log.NewLoggerContext(r.Context(), logger) + result.SetConn(ctx, conn, addr, r.Header.Get("User-Agent"), false, result) + return result +} + +func (c *serverClient) WaitReceived(ctx context.Context, count uint32) error { + for { + if err := ctx.Err(); err != nil { + return err + } else if c.received.Load() >= count { + return nil + } + + time.Sleep(time.Millisecond) + } +} + +func (c *serverClient) GetSessionId() api.PublicSessionId { + return api.PublicSessionId(c.id) +} + +func (c *serverClient) OnClosed() { + c.Close() + c.handler.removeClient(c) +} + +func (c *serverClient) OnMessageReceived(message []byte) { + switch c.received.Add(1) { + case 1: + var s string + if err := json.Unmarshal(message, &s); assert.NoError(c.t, err) { + assert.Equal(c.t, "Hello world!", s) + c.sendPing() + assert.EqualValues(c.t, "DE", c.Country()) + assert.False(c.t, c.Client.IsInRoom("room-id")) + c.SendMessage(&api.ServerMessage{ + Type: "welcome", + Welcome: &api.WelcomeServerMessage{ + Version: "1.0", + }, + }) + } + case 2: + var s string + if err := json.Unmarshal(message, &s); assert.NoError(c.t, err) { + assert.Equal(c.t, "Send error", s) + c.SendError(api.NewError("test_error", "This is a test error.")) + } + case 3: + var s string + if err := json.Unmarshal(message, &s); assert.NoError(c.t, err) { + assert.Equal(c.t, "Send bye", s) + c.SendByeResponseWithReason(nil, "Go away!") + } + } +} + +func (c *serverClient) OnRTTReceived(rtt time.Duration) { + +} + +func (c *serverClient) OnLookupCountry(addr string) geoip.Country { + return "DE" +} + +func (c *serverClient) IsInRoom(roomId string) bool { + return false +} + +func (c *serverClient) CloseSession() { + if c.sessionClosed.Swap(true) { + assert.Fail(c.t, "session closed more than once") + } +} + +type testHandler struct { + mu sync.Mutex + + t *testing.T + + upgrader websocket.Upgrader + + id atomic.Uint64 + // +checklocks:mu + activeClients map[string]*serverClient + // +checklocks:mu + allClients []*serverClient +} + +func newTestHandler(t *testing.T) *testHandler { + return &testHandler{ + t: t, + activeClients: make(map[string]*serverClient), + } +} + +func (h *testHandler) addClient(client *serverClient) { + h.mu.Lock() + defer h.mu.Unlock() + + h.activeClients[client.id] = client + h.allClients = append(h.allClients, client) +} + +func (h *testHandler) removeClient(client *serverClient) { + h.mu.Lock() + defer h.mu.Unlock() + + delete(h.activeClients, client.id) +} + +func (h *testHandler) getClients() []*serverClient { + h.mu.Lock() + defer h.mu.Unlock() + + return slices.Clone(h.allClients) +} + +func (h *testHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := h.upgrader.Upgrade(w, r, nil) + if !assert.NoError(h.t, err) { + return + } + + id := h.id.Add(1) + client := newTestClient(h, r, conn, id) + h.addClient(client) + + closed := make(chan struct{}) + context.AfterFunc(client.Context(), func() { + close(closed) + }) + + go client.WritePump() + client.ReadPump() + <-closed +} + +type localClient struct { + t *testing.T + + conn *websocket.Conn +} + +func newLocalClient(t *testing.T, url string) *localClient { + t.Helper() + + conn, _, err := websocket.DefaultDialer.DialContext(t.Context(), url, nil) + require.NoError(t, err) + return &localClient{ + t: t, + + conn: conn, + } +} + +func (c *localClient) Close() error { + err := c.conn.Close() + if errors.Is(err, net.ErrClosed) { + err = nil + } + return err +} + +func (c *localClient) WriteJSON(v any) error { + return c.conn.WriteJSON(v) +} + +func (c *localClient) Write(v []byte) error { + return c.conn.WriteMessage(websocket.BinaryMessage, v) +} + +func (c *localClient) ReadJSON(v any) error { + return c.conn.ReadJSON(v) +} + +func TestClient(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + serverHandler := newTestHandler(t) + + server := httptest.NewServer(serverHandler) + t.Cleanup(func() { + server.Close() + }) + + client := newLocalClient(t, strings.ReplaceAll(server.URL, "http://", "ws://")) + t.Cleanup(func() { + assert.NoError(client.Close()) + }) + + var msg api.ServerMessage + + require.NoError(client.WriteJSON("Hello world!")) + if assert.NoError(client.ReadJSON(&msg)) && + assert.Equal("welcome", msg.Type) && + assert.NotNil(msg.Welcome) { + assert.Equal("1.0", msg.Welcome.Version) + } + if clients := serverHandler.getClients(); assert.Len(clients, 1) { + assert.False(clients[0].sessionClosed.Load()) + assert.EqualValues(1, clients[0].received.Load()) + } + + require.NoError(client.Write([]byte("Hello world!"))) + if assert.NoError(client.ReadJSON(&msg)) && + assert.Equal("error", msg.Type) && + assert.NotNil(msg.Error) { + assert.Equal("invalid_format", msg.Error.Code) + assert.Equal("Invalid data format.", msg.Error.Message) + } + + require.NoError(client.WriteJSON("Send error")) + if assert.NoError(client.ReadJSON(&msg)) && + assert.Equal("error", msg.Type) && + assert.NotNil(msg.Error) { + assert.Equal("test_error", msg.Error.Code) + assert.Equal("This is a test error.", msg.Error.Message) + } + if clients := serverHandler.getClients(); assert.Len(clients, 1) { + assert.False(clients[0].sessionClosed.Load()) + assert.EqualValues(2, clients[0].received.Load()) + } + + require.NoError(client.WriteJSON("Send bye")) + if assert.NoError(client.ReadJSON(&msg)) && + assert.Equal("bye", msg.Type) && + assert.NotNil(msg.Bye) { + assert.Equal("Go away!", msg.Bye.Reason) + } + if clients := serverHandler.getClients(); assert.Len(clients, 1) { + assert.EqualValues(3, clients[0].received.Load()) + } + + // Sending a "bye" will close the connection. + var we *websocket.CloseError + if err := client.ReadJSON(&msg); assert.ErrorAs(err, &we) { + assert.Equal(websocket.CloseNormalClosure, we.Code) + assert.Empty(we.Text) + } + if clients := serverHandler.getClients(); assert.Len(clients, 1) { + assert.True(clients[0].sessionClosed.Load()) + assert.EqualValues(3, clients[0].received.Load()) + } +} diff --git a/client/ip.go b/client/ip.go new file mode 100644 index 0000000..5f73195 --- /dev/null +++ b/client/ip.go @@ -0,0 +1,93 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package client + +import ( + "net" + "net/http" + "slices" + "strings" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/container" +) + +var ( + DefaultTrustedProxies = container.DefaultPrivateIPs() +) + +func GetRealUserIP(r *http.Request, trusted *container.IPList) string { + addr := r.RemoteAddr + if host, _, err := net.SplitHostPort(addr); err == nil { + addr = host + } + + ip := net.ParseIP(addr) + if len(ip) == 0 { + return addr + } + + // Don't check any headers if the server can be reached by untrusted clients directly. + if trusted == nil || !trusted.Contains(ip) { + return addr + } + + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + if ip := net.ParseIP(realIP); len(ip) > 0 { + return realIP + } + } + + // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#selecting_an_ip_address + forwarded := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",") + if len(forwarded) > 0 { + slices.Reverse(forwarded) + var lastTrusted string + for _, hop := range forwarded { + hop = strings.TrimSpace(hop) + // Make sure to remove any port. + if host, _, err := net.SplitHostPort(hop); err == nil { + hop = host + } + + ip := net.ParseIP(hop) + if len(ip) == 0 { + continue + } + + if trusted.Contains(ip) { + lastTrusted = hop + continue + } + + return hop + } + + // If all entries in the "X-Forwarded-For" list are trusted, the left-most + // will be the client IP. This can happen if a subnet is trusted and the + // client also has an IP from this subnet. + if lastTrusted != "" { + return lastTrusted + } + } + + return addr +} diff --git a/client/ip_test.go b/client/ip_test.go new file mode 100644 index 0000000..fbe5358 --- /dev/null +++ b/client/ip_test.go @@ -0,0 +1,278 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package client + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/container" +) + +func TestGetRealUserIP(t *testing.T) { + t.Parallel() + testcases := []struct { + expected string + headers http.Header + trusted string + addr string + }{ + { + "192.168.1.2", + nil, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "invalid-ip", + nil, + "192.168.0.0/16", + "invalid-ip", + }, + { + "invalid-ip", + nil, + "192.168.0.0/16", + "invalid-ip:12345", + }, + { + "10.11.12.13", + nil, + "192.168.0.0/16", + "10.11.12.13:23456", + }, + { + "10.11.12.13", + http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{"10.11.12.13"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "2002:db8::1", + http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{"2002:db8::1"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "11.12.13.14", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "11.12.13.14", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14:1234, 192.168.30.32:2345"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "10.11.12.13", + http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{"10.11.12.13"}, + }, + "2001:db8::/48", + "[2001:db8::1]:23456", + }, + { + "2002:db8::1", + http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{"2002:db8::1"}, + }, + "2001:db8::/48", + "[2001:db8::1]:23456", + }, + { + "2002:db8::1", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 192.168.30.32"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "2002:db8::1", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 2001:db8::1"}, + }, + "192.168.0.0/16, 2001:db8::/48", + "192.168.1.2:23456", + }, + { + "2002:db8::1", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 192.168.30.32"}, + }, + "192.168.0.0/16, 2001:db8::/48", + "[2001:db8::1]:23456", + }, + { + "2002:db8::1", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 2001:db8::2"}, + }, + "2001:db8::/48", + "[2001:db8::1]:23456", + }, + // "X-Real-IP" has preference before "X-Forwarded-For" + { + "10.11.12.13", + http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{"10.11.12.13"}, + http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + // Multiple "X-Forwarded-For" headers are merged. + { + "11.12.13.14", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14", "192.168.30.32"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "11.12.13.14", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"1.2.3.4", "11.12.13.14", "192.168.30.32"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "11.12.13.14", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"1.2.3.4", "2.3.4.5", "11.12.13.14", "192.168.31.32", "192.168.30.32"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + // Headers are ignored if coming from untrusted clients. + { + "10.11.12.13", + http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{"11.12.13.14"}, + }, + "192.168.0.0/16", + "10.11.12.13:23456", + }, + { + "10.11.12.13", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"}, + }, + "192.168.0.0/16", + "10.11.12.13:23456", + }, + // X-Forwarded-For is filtered for trusted proxies. + { + "1.2.3.4", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 1.2.3.4"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "1.2.3.4", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 1.2.3.4, 192.168.2.3"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "10.11.12.13", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 1.2.3.4"}, + }, + "192.168.0.0/16", + "10.11.12.13:23456", + }, + // Invalid IPs are ignored. + { + "192.168.1.2", + http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{"this-is-not-an-ip"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "11.12.13.14", + http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{"this-is-not-an-ip"}, + http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "11.12.13.14", + http.Header{ + http.CanonicalHeaderKey("x-real-ip"): []string{"this-is-not-an-ip"}, + http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32, proxy1"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "192.168.1.2", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"this-is-not-an-ip"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + { + "192.168.2.3", + http.Header{ + http.CanonicalHeaderKey("x-forwarded-for"): []string{"this-is-not-an-ip, 192.168.2.3"}, + }, + "192.168.0.0/16", + "192.168.1.2:23456", + }, + } + + for _, tc := range testcases { + trustedProxies, err := container.ParseIPList(tc.trusted) + if !assert.NoError(t, err, "invalid trusted proxies in %+v", tc) { + continue + } + request := &http.Request{ + RemoteAddr: tc.addr, + Header: tc.headers, + } + assert.Equal(t, tc.expected, GetRealUserIP(request, trustedProxies), "failed for %+v", tc) + } +} diff --git a/client/stats_prometheus.go b/client/stats_prometheus.go new file mode 100644 index 0000000..2d263b3 --- /dev/null +++ b/client/stats_prometheus.go @@ -0,0 +1,60 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package client + +import ( + "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" +) + +var ( + statsClientRTT = prometheus.NewHistogram(prometheus.HistogramOpts{ + Namespace: "signaling", + Subsystem: "client", + Name: "rtt", + Help: "The roundtrip time of WebSocket ping messages in milliseconds", + Buckets: prometheus.ExponentialBucketsRange(1, 30000, 50), + }) + statsClientBytesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "client", + Name: "bytes_total", + Help: "The total number of bytes sent to or received by clients", + }, []string{"direction"}) + statsClientMessagesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "client", + Name: "messages_total", + Help: "The total number of messages sent to or received by clients", + }, []string{"direction"}) + + clientStats = []prometheus.Collector{ + statsClientRTT, + statsClientBytesTotal, + statsClientMessagesTotal, + } +) + +func RegisterClientStats() { + metrics.RegisterAll(clientStats...) +} diff --git a/clientsession.go b/clientsession.go deleted file mode 100644 index c59920a..0000000 --- a/clientsession.go +++ /dev/null @@ -1,1500 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/url" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/pion/sdp/v3" -) - -var ( - // Warn if a session has 32 or more pending messages. - warnPendingMessagesCount = 32 - - PathToOcsSignalingBackend = "ocs/v2.php/apps/spreed/api/v1/signaling/backend" -) - -const ( - FederatedRoomSessionIdPrefix = "federated|" -) - -// ResponseHandlerFunc will return "true" has been fully processed. -type ResponseHandlerFunc func(message *ClientMessage) bool - -type ClientSession struct { - hub *Hub - events AsyncEvents - privateId string - publicId string - data *SessionIdData - ctx context.Context - closeFunc context.CancelFunc - - clientType string - features []string - userId string - userData json.RawMessage - - parseUserData func() (map[string]interface{}, error) - - inCall Flags - supportsPermissions bool - permissions map[Permission]bool - - backend *Backend - backendUrl string - parsedBackendUrl *url.URL - - mu sync.Mutex - - client HandlerClient - room atomic.Pointer[Room] - roomJoinTime atomic.Int64 - federation atomic.Pointer[FederationClient] - - roomSessionIdLock sync.RWMutex - roomSessionId string - - publisherWaiters ChannelWaiters - - publishers map[StreamType]McuPublisher - subscribers map[string]McuSubscriber - - pendingClientMessages []*ServerMessage - hasPendingChat bool - hasPendingParticipantsUpdate bool - - virtualSessions map[*VirtualSession]bool - - seenJoinedLock sync.Mutex - seenJoinedEvents map[string]bool - - responseHandlersLock sync.Mutex - responseHandlers map[string]ResponseHandlerFunc -} - -func NewClientSession(hub *Hub, privateId string, publicId string, data *SessionIdData, backend *Backend, hello *HelloClientMessage, auth *BackendClientAuthResponse) (*ClientSession, error) { - ctx, closeFunc := context.WithCancel(context.Background()) - s := &ClientSession{ - hub: hub, - events: hub.events, - privateId: privateId, - publicId: publicId, - data: data, - ctx: ctx, - closeFunc: closeFunc, - - clientType: hello.Auth.Type, - features: hello.Features, - userId: auth.UserId, - userData: auth.User, - parseUserData: parseUserData(auth.User), - - backend: backend, - } - if s.clientType == HelloClientTypeInternal { - s.backendUrl = hello.Auth.internalParams.Backend - s.parsedBackendUrl = hello.Auth.internalParams.parsedBackend - if !s.HasFeature(ClientFeatureInternalInCall) { - s.SetInCall(FlagInCall | FlagWithAudio) - } - } else { - s.backendUrl = hello.Auth.Url - s.parsedBackendUrl = hello.Auth.parsedUrl - } - if !strings.Contains(s.backendUrl, "/ocs/v2.php/") { - backendUrl := s.backendUrl - if !strings.HasSuffix(backendUrl, "/") { - backendUrl += "/" - } - backendUrl += PathToOcsSignalingBackend - u, err := url.Parse(backendUrl) - if err != nil { - return nil, err - } - - if strings.Contains(u.Host, ":") && hasStandardPort(u) { - u.Host = u.Hostname() - } - - s.backendUrl = backendUrl - s.parsedBackendUrl = u - } - - if err := s.SubscribeEvents(); err != nil { - return nil, err - } - return s, nil -} - -func (s *ClientSession) Context() context.Context { - return s.ctx -} - -func (s *ClientSession) PrivateId() string { - return s.privateId -} - -func (s *ClientSession) PublicId() string { - return s.publicId -} - -func (s *ClientSession) RoomSessionId() string { - s.roomSessionIdLock.RLock() - defer s.roomSessionIdLock.RUnlock() - return s.roomSessionId -} - -func (s *ClientSession) Data() *SessionIdData { - return s.data -} - -func (s *ClientSession) ClientType() string { - return s.clientType -} - -// GetInCall is only used for internal clients. -func (s *ClientSession) GetInCall() int { - return int(s.inCall.Get()) -} - -func (s *ClientSession) SetInCall(inCall int) bool { - if inCall < 0 { - inCall = 0 - } - - return s.inCall.Set(uint32(inCall)) -} - -func (s *ClientSession) GetFeatures() []string { - return s.features -} - -func (s *ClientSession) HasFeature(feature string) bool { - for _, f := range s.features { - if f == feature { - return true - } - } - return false -} - -// HasPermission checks if the session has the passed permissions. -func (s *ClientSession) HasPermission(permission Permission) bool { - s.mu.Lock() - defer s.mu.Unlock() - - return s.hasPermissionLocked(permission) -} - -// HasAnyPermission checks if the session has one of the passed permissions. -func (s *ClientSession) HasAnyPermission(permission ...Permission) bool { - if len(permission) == 0 { - return false - } - - s.mu.Lock() - defer s.mu.Unlock() - - return s.hasAnyPermissionLocked(permission...) -} - -func (s *ClientSession) hasAnyPermissionLocked(permission ...Permission) bool { - if len(permission) == 0 { - return false - } - - for _, p := range permission { - if s.hasPermissionLocked(p) { - return true - } - } - return false -} - -func (s *ClientSession) hasPermissionLocked(permission Permission) bool { - if !s.supportsPermissions { - // Old-style session that doesn't receive permissions from Nextcloud. - if result, found := DefaultPermissionOverrides[permission]; found { - return result - } - return true - } - - if val, found := s.permissions[permission]; found { - return val - } - return false -} - -func permissionsEqual(a, b map[Permission]bool) bool { - if a == nil && b == nil { - return true - } else if a != nil && b == nil { - return false - } else if a == nil && b != nil { - return false - } - if len(a) != len(b) { - return false - } - - for k, v1 := range a { - if v2, found := b[k]; !found || v1 != v2 { - return false - } - } - return true -} - -func (s *ClientSession) SetPermissions(permissions []Permission) { - var p map[Permission]bool - for _, permission := range permissions { - if p == nil { - p = make(map[Permission]bool) - } - p[permission] = true - } - - s.mu.Lock() - defer s.mu.Unlock() - if s.supportsPermissions && permissionsEqual(s.permissions, p) { - return - } - - s.permissions = p - s.supportsPermissions = true - log.Printf("Permissions of session %s changed: %s", s.PublicId(), permissions) -} - -func (s *ClientSession) Backend() *Backend { - return s.backend -} - -func (s *ClientSession) BackendUrl() string { - return s.backendUrl -} - -func (s *ClientSession) ParsedBackendUrl() *url.URL { - return s.parsedBackendUrl -} - -func (s *ClientSession) AuthUserId() string { - return s.userId -} - -func (s *ClientSession) UserId() string { - userId := s.userId - if userId == "" { - if room := s.GetRoom(); room != nil { - if data := room.GetRoomSessionData(s); data != nil { - userId = data.UserId - } - } - } - return userId -} - -func (s *ClientSession) UserData() json.RawMessage { - return s.userData -} - -func (s *ClientSession) ParsedUserData() (map[string]interface{}, error) { - return s.parseUserData() -} - -func (s *ClientSession) SetRoom(room *Room) { - s.room.Store(room) - s.onRoomSet(room != nil) -} - -func (s *ClientSession) onRoomSet(hasRoom bool) { - if hasRoom { - s.roomJoinTime.Store(time.Now().UnixNano()) - } else { - s.roomJoinTime.Store(0) - } - - s.seenJoinedLock.Lock() - defer s.seenJoinedLock.Unlock() - s.seenJoinedEvents = nil -} - -func (s *ClientSession) GetFederationClient() *FederationClient { - return s.federation.Load() -} - -func (s *ClientSession) SetFederationClient(federation *FederationClient) { - s.mu.Lock() - defer s.mu.Unlock() - - s.doLeaveRoom(true) - s.onRoomSet(federation != nil) - - if prev := s.federation.Swap(federation); prev != nil && prev != federation { - prev.Close() - } -} - -func (s *ClientSession) GetRoom() *Room { - return s.room.Load() -} - -func (s *ClientSession) getRoomJoinTime() time.Time { - t := s.roomJoinTime.Load() - if t == 0 { - return time.Time{} - } - - return time.Unix(0, t) -} - -func (s *ClientSession) releaseMcuObjects() { - if len(s.publishers) > 0 { - go func(publishers map[StreamType]McuPublisher) { - ctx := context.Background() - for _, publisher := range publishers { - publisher.Close(ctx) - } - }(s.publishers) - s.publishers = nil - } - if len(s.subscribers) > 0 { - go func(subscribers map[string]McuSubscriber) { - ctx := context.Background() - for _, subscriber := range subscribers { - subscriber.Close(ctx) - } - }(s.subscribers) - s.subscribers = nil - } -} - -func (s *ClientSession) Close() { - s.closeAndWait(true) -} - -func (s *ClientSession) closeAndWait(wait bool) { - s.closeFunc() - s.hub.removeSession(s) - - if prev := s.federation.Swap(nil); prev != nil { - prev.Close() - } - - s.mu.Lock() - defer s.mu.Unlock() - if s.userId != "" { - s.events.UnregisterUserListener(s.userId, s.backend, s) - } - s.events.UnregisterSessionListener(s.publicId, s.backend, s) - go func(virtualSessions map[*VirtualSession]bool) { - for session := range virtualSessions { - session.Close() - } - }(s.virtualSessions) - s.virtualSessions = nil - s.releaseMcuObjects() - s.clearClientLocked(nil) - s.backend.RemoveSession(s) -} - -func (s *ClientSession) SubscribeEvents() error { - s.mu.Lock() - defer s.mu.Unlock() - - if s.userId != "" { - if err := s.events.RegisterUserListener(s.userId, s.backend, s); err != nil { - return err - } - } - - return s.events.RegisterSessionListener(s.publicId, s.backend, s) -} - -func (s *ClientSession) UpdateRoomSessionId(roomSessionId string) error { - s.roomSessionIdLock.Lock() - defer s.roomSessionIdLock.Unlock() - - if s.roomSessionId == roomSessionId { - return nil - } - - if err := s.hub.roomSessions.SetRoomSession(s, roomSessionId); err != nil { - return err - } - - if roomSessionId != "" { - if room := s.GetRoom(); room != nil { - log.Printf("Session %s updated room session id to %s in room %s", s.PublicId(), roomSessionId, room.Id()) - } else if client := s.GetFederationClient(); client != nil { - log.Printf("Session %s updated room session id to %s in federated room %s", s.PublicId(), roomSessionId, client.RemoteRoomId()) - } else { - log.Printf("Session %s updated room session id to %s in unknown room", s.PublicId(), roomSessionId) - } - } else { - if room := s.GetRoom(); room != nil { - log.Printf("Session %s cleared room session id in room %s", s.PublicId(), room.Id()) - } else if client := s.GetFederationClient(); client != nil { - log.Printf("Session %s cleared room session id in federated room %s", s.PublicId(), client.RemoteRoomId()) - } else { - log.Printf("Session %s cleared room session id in unknown room", s.PublicId()) - } - } - - s.roomSessionId = roomSessionId - return nil -} - -func (s *ClientSession) SubscribeRoomEvents(roomid string, roomSessionId string) error { - s.roomSessionIdLock.Lock() - defer s.roomSessionIdLock.Unlock() - - if err := s.events.RegisterRoomListener(roomid, s.backend, s); err != nil { - return err - } - - if roomSessionId != "" { - if err := s.hub.roomSessions.SetRoomSession(s, roomSessionId); err != nil { - s.doUnsubscribeRoomEvents(true) - return err - } - } - log.Printf("Session %s joined room %s with room session id %s", s.PublicId(), roomid, roomSessionId) - s.roomSessionId = roomSessionId - return nil -} - -func (s *ClientSession) LeaveCall() { - s.mu.Lock() - defer s.mu.Unlock() - - room := s.GetRoom() - if room == nil { - return - } - - log.Printf("Session %s left call %s", s.PublicId(), room.Id()) - s.releaseMcuObjects() -} - -func (s *ClientSession) LeaveRoom(notify bool) *Room { - return s.LeaveRoomWithMessage(notify, nil) -} - -func (s *ClientSession) LeaveRoomWithMessage(notify bool, message *ClientMessage) *Room { - if prev := s.federation.Swap(nil); prev != nil { - // Session was connected to a federation room. - if err := prev.Leave(message); err != nil { - log.Printf("Error leaving room for session %s on federation client %s: %s", s.PublicId(), prev.URL(), err) - prev.Close() - } - return nil - } - - s.mu.Lock() - defer s.mu.Unlock() - - return s.doLeaveRoom(notify) -} - -func (s *ClientSession) doLeaveRoom(notify bool) *Room { - room := s.GetRoom() - if room == nil { - return nil - } - - s.doUnsubscribeRoomEvents(notify) - s.SetRoom(nil) - s.releaseMcuObjects() - room.RemoveSession(s) - return room -} - -func (s *ClientSession) UnsubscribeRoomEvents() { - s.mu.Lock() - defer s.mu.Unlock() - - s.doUnsubscribeRoomEvents(true) -} - -func (s *ClientSession) doUnsubscribeRoomEvents(notify bool) { - room := s.GetRoom() - if room != nil { - s.events.UnregisterRoomListener(room.Id(), s.Backend(), s) - } - s.hub.roomSessions.DeleteRoomSession(s) - - s.roomSessionIdLock.Lock() - defer s.roomSessionIdLock.Unlock() - if notify && room != nil && s.roomSessionId != "" && !strings.HasPrefix(s.roomSessionId, FederatedRoomSessionIdPrefix) { - // Notify - go func(sid string) { - ctx := context.Background() - request := NewBackendClientRoomRequest(room.Id(), s.userId, sid) - request.Room.UpdateFromSession(s) - request.Room.Action = "leave" - var response map[string]interface{} - if err := s.hub.backend.PerformJSONRequest(ctx, s.ParsedBackendUrl(), request, &response); err != nil { - log.Printf("Could not notify about room session %s left room %s: %s", sid, room.Id(), err) - } else { - log.Printf("Removed room session %s: %+v", sid, response) - } - }(s.roomSessionId) - } - s.roomSessionId = "" -} - -func (s *ClientSession) ClearClient(client HandlerClient) { - s.mu.Lock() - defer s.mu.Unlock() - - s.clearClientLocked(client) -} - -func (s *ClientSession) clearClientLocked(client HandlerClient) { - if s.client == nil { - return - } else if client != nil && s.client != client { - log.Printf("Trying to clear other client in session %s", s.PublicId()) - return - } - - prevClient := s.client - s.client = nil - prevClient.SetSession(nil) -} - -func (s *ClientSession) GetClient() HandlerClient { - s.mu.Lock() - defer s.mu.Unlock() - - return s.getClientUnlocked() -} - -func (s *ClientSession) getClientUnlocked() HandlerClient { - return s.client -} - -func (s *ClientSession) SetClient(client HandlerClient) HandlerClient { - if client == nil { - panic("Use ClearClient to set the client to nil") - } - - s.mu.Lock() - defer s.mu.Unlock() - - if client == s.client { - // No change - return nil - } - - client.SetSession(s) - prev := s.client - if prev != nil { - s.clearClientLocked(prev) - } - s.client = client - return prev -} - -func (s *ClientSession) sendOffer(client McuClient, sender string, streamType StreamType, offer map[string]interface{}) { - offer_message := &AnswerOfferMessage{ - To: s.PublicId(), - From: sender, - Type: "offer", - RoomType: string(streamType), - Payload: offer, - Sid: client.Sid(), - } - offer_data, err := json.Marshal(offer_message) - if err != nil { - log.Println("Could not serialize offer", offer_message, err) - return - } - response_message := &ServerMessage{ - Type: "message", - Message: &MessageServerMessage{ - Sender: &MessageServerMessageSender{ - Type: "session", - SessionId: sender, - }, - Data: offer_data, - }, - } - - s.sendMessageUnlocked(response_message) -} - -func (s *ClientSession) sendCandidate(client McuClient, sender string, streamType StreamType, candidate interface{}) { - candidate_message := &AnswerOfferMessage{ - To: s.PublicId(), - From: sender, - Type: "candidate", - RoomType: string(streamType), - Payload: map[string]interface{}{ - "candidate": candidate, - }, - Sid: client.Sid(), - } - candidate_data, err := json.Marshal(candidate_message) - if err != nil { - log.Println("Could not serialize candidate", candidate_message, err) - return - } - response_message := &ServerMessage{ - Type: "message", - Message: &MessageServerMessage{ - Sender: &MessageServerMessageSender{ - Type: "session", - SessionId: sender, - }, - Data: candidate_data, - }, - } - - s.sendMessageUnlocked(response_message) -} - -func (s *ClientSession) sendMessageUnlocked(message *ServerMessage) bool { - if c := s.getClientUnlocked(); c != nil { - if c.SendMessage(message) { - return true - } - } - - s.storePendingMessage(message) - return true -} - -func (s *ClientSession) SendError(e *Error) bool { - message := &ServerMessage{ - Type: "error", - Error: e, - } - return s.SendMessage(message) -} - -func (s *ClientSession) SendMessage(message *ServerMessage) bool { - message = s.filterMessage(message) - if message == nil { - return true - } - - s.mu.Lock() - defer s.mu.Unlock() - - return s.sendMessageUnlocked(message) -} - -func (s *ClientSession) SendMessages(messages []*ServerMessage) bool { - s.mu.Lock() - defer s.mu.Unlock() - - for _, message := range messages { - s.sendMessageUnlocked(message) - } - return true -} - -func (s *ClientSession) OnUpdateOffer(client McuClient, offer map[string]interface{}) { - s.mu.Lock() - defer s.mu.Unlock() - - for _, sub := range s.subscribers { - if sub.Id() == client.Id() { - s.sendOffer(client, sub.Publisher(), client.StreamType(), offer) - return - } - } -} - -func (s *ClientSession) OnIceCandidate(client McuClient, candidate interface{}) { - s.mu.Lock() - defer s.mu.Unlock() - - for _, sub := range s.subscribers { - if sub.Id() == client.Id() { - s.sendCandidate(client, sub.Publisher(), client.StreamType(), candidate) - return - } - } - - for _, pub := range s.publishers { - if pub.Id() == client.Id() { - s.sendCandidate(client, s.PublicId(), client.StreamType(), candidate) - return - } - } - - log.Printf("Session %s received candidate %+v for unknown client %s", s.PublicId(), candidate, client.Id()) -} - -func (s *ClientSession) OnIceCompleted(client McuClient) { - // TODO(jojo): This causes a JavaScript error when creating a candidate from "null". - // Figure out a better way to signal this. - - // An empty candidate signals the end of candidates. - // s.OnIceCandidate(client, nil) -} - -func (s *ClientSession) SubscriberSidUpdated(subscriber McuSubscriber) { -} - -func (s *ClientSession) PublisherClosed(publisher McuPublisher) { - s.mu.Lock() - defer s.mu.Unlock() - - for id, p := range s.publishers { - if p == publisher { - delete(s.publishers, id) - break - } - } -} - -func (s *ClientSession) SubscriberClosed(subscriber McuSubscriber) { - s.mu.Lock() - defer s.mu.Unlock() - - for id, sub := range s.subscribers { - if sub == subscriber { - delete(s.subscribers, id) - break - } - } -} - -type PermissionError struct { - permission Permission -} - -func (e *PermissionError) Permission() Permission { - return e.permission -} - -func (e *PermissionError) Error() string { - return fmt.Sprintf("permission \"%s\" not found", e.permission) -} - -func (s *ClientSession) isSdpAllowedToSendLocked(sdp *sdp.SessionDescription) (MediaType, error) { - if sdp == nil { - // Should have already been checked when data was validated. - return 0, ErrNoSdp - } - - var mediaTypes MediaType - mayPublishMedia := s.hasPermissionLocked(PERMISSION_MAY_PUBLISH_MEDIA) - for _, md := range sdp.MediaDescriptions { - switch md.MediaName.Media { - case "audio": - if !mayPublishMedia && !s.hasPermissionLocked(PERMISSION_MAY_PUBLISH_AUDIO) { - return 0, &PermissionError{PERMISSION_MAY_PUBLISH_AUDIO} - } - - mediaTypes |= MediaTypeAudio - case "video": - if !mayPublishMedia && !s.hasPermissionLocked(PERMISSION_MAY_PUBLISH_VIDEO) { - return 0, &PermissionError{PERMISSION_MAY_PUBLISH_VIDEO} - } - - mediaTypes |= MediaTypeVideo - } - } - - return mediaTypes, nil -} - -func (s *ClientSession) IsAllowedToSend(data *MessageClientMessageData) error { - s.mu.Lock() - defer s.mu.Unlock() - - if data != nil && data.RoomType == "screen" { - if s.hasPermissionLocked(PERMISSION_MAY_PUBLISH_SCREEN) { - return nil - } - return &PermissionError{PERMISSION_MAY_PUBLISH_SCREEN} - } else if s.hasPermissionLocked(PERMISSION_MAY_PUBLISH_MEDIA) { - // Client is allowed to publish any media (audio / video). - return nil - } else if data != nil && data.Type == "offer" { - // Check what user is trying to publish and check permissions accordingly. - if _, err := s.isSdpAllowedToSendLocked(data.offerSdp); err != nil { - return err - } - - return nil - } else { - // Candidate or unknown event, check if client is allowed to publish any media. - if s.hasAnyPermissionLocked(PERMISSION_MAY_PUBLISH_AUDIO, PERMISSION_MAY_PUBLISH_VIDEO) { - return nil - } - - return fmt.Errorf("permission check failed") - } -} - -func (s *ClientSession) CheckOfferType(streamType StreamType, data *MessageClientMessageData) (MediaType, error) { - s.mu.Lock() - defer s.mu.Unlock() - - return s.checkOfferTypeLocked(streamType, data) -} - -func (s *ClientSession) checkOfferTypeLocked(streamType StreamType, data *MessageClientMessageData) (MediaType, error) { - if streamType == StreamTypeScreen { - if !s.hasPermissionLocked(PERMISSION_MAY_PUBLISH_SCREEN) { - return 0, &PermissionError{PERMISSION_MAY_PUBLISH_SCREEN} - } - - return MediaTypeScreen, nil - } else if data != nil && data.Type == "offer" { - mediaTypes, err := s.isSdpAllowedToSendLocked(data.offerSdp) - if err != nil { - return 0, err - } - - return mediaTypes, nil - } - - return 0, nil -} - -func (s *ClientSession) GetOrCreatePublisher(ctx context.Context, mcu Mcu, streamType StreamType, data *MessageClientMessageData) (McuPublisher, error) { - s.mu.Lock() - defer s.mu.Unlock() - - mediaTypes, err := s.checkOfferTypeLocked(streamType, data) - if err != nil { - return nil, err - } - - publisher, found := s.publishers[streamType] - if !found { - client := s.getClientUnlocked() - s.mu.Unlock() - defer s.mu.Lock() - - settings := NewPublisherSettings{ - Bitrate: data.Bitrate, - MediaTypes: mediaTypes, - - AudioCodec: data.AudioCodec, - VideoCodec: data.VideoCodec, - VP9Profile: data.VP9Profile, - H264Profile: data.H264Profile, - } - if backend := s.Backend(); backend != nil { - var maxBitrate int - if streamType == StreamTypeScreen { - maxBitrate = backend.maxScreenBitrate - } else { - maxBitrate = backend.maxStreamBitrate - } - if settings.Bitrate <= 0 { - settings.Bitrate = maxBitrate - } else if maxBitrate > 0 && settings.Bitrate > maxBitrate { - settings.Bitrate = maxBitrate - } - } - var err error - publisher, err = mcu.NewPublisher(ctx, s, s.PublicId(), data.Sid, streamType, settings, client) - if err != nil { - return nil, err - } - if s.publishers == nil { - s.publishers = make(map[StreamType]McuPublisher) - } - if prev, found := s.publishers[streamType]; found { - // Another thread created the publisher while we were waiting. - go func(pub McuPublisher) { - closeCtx := context.Background() - pub.Close(closeCtx) - }(publisher) - publisher = prev - } else { - s.publishers[streamType] = publisher - } - log.Printf("Publishing %s as %s for session %s", streamType, publisher.Id(), s.PublicId()) - s.publisherWaiters.Wakeup() - } else { - publisher.SetMedia(mediaTypes) - } - - return publisher, nil -} - -func (s *ClientSession) getPublisherLocked(streamType StreamType) McuPublisher { - return s.publishers[streamType] -} - -func (s *ClientSession) GetPublisher(streamType StreamType) McuPublisher { - s.mu.Lock() - defer s.mu.Unlock() - - return s.getPublisherLocked(streamType) -} - -func (s *ClientSession) GetOrWaitForPublisher(ctx context.Context, streamType StreamType) McuPublisher { - s.mu.Lock() - defer s.mu.Unlock() - - publisher := s.getPublisherLocked(streamType) - if publisher != nil { - return publisher - } - - ch := make(chan struct{}, 1) - id := s.publisherWaiters.Add(ch) - defer s.publisherWaiters.Remove(id) - - for { - s.mu.Unlock() - select { - case <-ch: - s.mu.Lock() - publisher := s.getPublisherLocked(streamType) - if publisher != nil { - return publisher - } - case <-ctx.Done(): - s.mu.Lock() - return nil - } - } -} - -func (s *ClientSession) GetOrCreateSubscriber(ctx context.Context, mcu Mcu, id string, streamType StreamType) (McuSubscriber, error) { - s.mu.Lock() - defer s.mu.Unlock() - - // TODO(jojo): Add method to remove subscribers. - - subscriber, found := s.subscribers[getStreamId(id, streamType)] - if !found { - client := s.getClientUnlocked() - s.mu.Unlock() - var err error - subscriber, err = mcu.NewSubscriber(ctx, s, id, streamType, client) - s.mu.Lock() - if err != nil { - return nil, err - } - if s.subscribers == nil { - s.subscribers = make(map[string]McuSubscriber) - } - if prev, found := s.subscribers[getStreamId(id, streamType)]; found { - // Another thread created the subscriber while we were waiting. - go func(sub McuSubscriber) { - closeCtx := context.Background() - sub.Close(closeCtx) - }(subscriber) - subscriber = prev - } else { - s.subscribers[getStreamId(id, streamType)] = subscriber - } - log.Printf("Subscribing %s from %s as %s in session %s", streamType, id, subscriber.Id(), s.PublicId()) - } - - return subscriber, nil -} - -func (s *ClientSession) GetSubscriber(id string, streamType StreamType) McuSubscriber { - s.mu.Lock() - defer s.mu.Unlock() - - return s.subscribers[getStreamId(id, streamType)] -} - -func (s *ClientSession) ProcessAsyncRoomMessage(message *AsyncMessage) { - s.processAsyncMessage(message) -} - -func (s *ClientSession) ProcessAsyncUserMessage(message *AsyncMessage) { - s.processAsyncMessage(message) -} - -func (s *ClientSession) ProcessAsyncSessionMessage(message *AsyncMessage) { - s.processAsyncMessage(message) -} - -func (s *ClientSession) processAsyncMessage(message *AsyncMessage) { - switch message.Type { - case "permissions": - s.SetPermissions(message.Permissions) - go func() { - s.mu.Lock() - defer s.mu.Unlock() - - if !s.hasPermissionLocked(PERMISSION_MAY_PUBLISH_MEDIA) { - if publisher, found := s.publishers[StreamTypeVideo]; found { - if (publisher.HasMedia(MediaTypeAudio) && !s.hasPermissionLocked(PERMISSION_MAY_PUBLISH_AUDIO)) || - (publisher.HasMedia(MediaTypeVideo) && !s.hasPermissionLocked(PERMISSION_MAY_PUBLISH_VIDEO)) { - delete(s.publishers, StreamTypeVideo) - log.Printf("Session %s is no longer allowed to publish media, closing publisher %s", s.PublicId(), publisher.Id()) - go func() { - publisher.Close(context.Background()) - }() - return - } - } - } - if !s.hasPermissionLocked(PERMISSION_MAY_PUBLISH_SCREEN) { - if publisher, found := s.publishers[StreamTypeScreen]; found { - delete(s.publishers, StreamTypeScreen) - log.Printf("Session %s is no longer allowed to publish screen, closing publisher %s", s.PublicId(), publisher.Id()) - go func() { - publisher.Close(context.Background()) - }() - return - } - } - }() - return - case "message": - if message.Message.Type == "bye" && message.Message.Bye.Reason == "room_session_reconnected" { - log.Printf("Closing session %s because same room session %s connected", s.PublicId(), s.RoomSessionId()) - s.LeaveRoom(false) - defer s.closeAndWait(false) - } - case "sendoffer": - // Process asynchronously to not block other messages received. - go func() { - ctx, cancel := context.WithTimeout(s.Context(), s.hub.mcuTimeout) - defer cancel() - - mc, err := s.GetOrCreateSubscriber(ctx, s.hub.mcu, message.SendOffer.SessionId, StreamType(message.SendOffer.Data.RoomType)) - if err != nil { - log.Printf("Could not create MCU subscriber for session %s to process sendoffer in %s: %s", message.SendOffer.SessionId, s.PublicId(), err) - if err := s.events.PublishSessionMessage(message.SendOffer.SessionId, s.backend, &AsyncMessage{ - Type: "message", - Message: &ServerMessage{ - Id: message.SendOffer.MessageId, - Type: "error", - Error: NewError("client_not_found", "No MCU client found to send message to."), - }, - }); err != nil { - log.Printf("Error sending sendoffer error response to %s: %s", message.SendOffer.SessionId, err) - } - return - } else if mc == nil { - log.Printf("No MCU subscriber found for session %s to process sendoffer in %s", message.SendOffer.SessionId, s.PublicId()) - if err := s.events.PublishSessionMessage(message.SendOffer.SessionId, s.backend, &AsyncMessage{ - Type: "message", - Message: &ServerMessage{ - Id: message.SendOffer.MessageId, - Type: "error", - Error: NewError("client_not_found", "No MCU client found to send message to."), - }, - }); err != nil { - log.Printf("Error sending sendoffer error response to %s: %s", message.SendOffer.SessionId, err) - } - return - } - - mc.SendMessage(s.Context(), nil, message.SendOffer.Data, func(err error, response map[string]interface{}) { - if err != nil { - log.Printf("Could not send MCU message %+v for session %s to %s: %s", message.SendOffer.Data, message.SendOffer.SessionId, s.PublicId(), err) - if err := s.events.PublishSessionMessage(message.SendOffer.SessionId, s.backend, &AsyncMessage{ - Type: "message", - Message: &ServerMessage{ - Id: message.SendOffer.MessageId, - Type: "error", - Error: NewError("processing_failed", "Processing of the message failed, please check server logs."), - }, - }); err != nil { - log.Printf("Error sending sendoffer error response to %s: %s", message.SendOffer.SessionId, err) - } - return - } else if response == nil { - // No response received - return - } - - s.hub.sendMcuMessageResponse(s, mc, &MessageClientMessage{ - Recipient: MessageClientMessageRecipient{ - SessionId: message.SendOffer.SessionId, - }, - }, message.SendOffer.Data, response) - }) - }() - return - } - - serverMessage := s.filterAsyncMessage(message) - if serverMessage == nil { - return - } - - s.SendMessage(serverMessage) -} - -func (s *ClientSession) storePendingMessage(message *ServerMessage) { - if message.IsChatRefresh() { - if s.hasPendingChat { - // Only send a single "chat-refresh" message on resume. - return - } - - s.hasPendingChat = true - } - if !s.hasPendingParticipantsUpdate && message.IsParticipantsUpdate() { - s.hasPendingParticipantsUpdate = true - } - s.pendingClientMessages = append(s.pendingClientMessages, message) - if len(s.pendingClientMessages) >= warnPendingMessagesCount { - log.Printf("Session %s has %d pending messages", s.PublicId(), len(s.pendingClientMessages)) - } -} - -func filterDisplayNames(events []*EventServerMessageSessionEntry) []*EventServerMessageSessionEntry { - result := make([]*EventServerMessageSessionEntry, 0, len(events)) - for _, event := range events { - if len(event.User) == 0 { - result = append(result, event) - continue - } - - var userdata map[string]interface{} - if err := json.Unmarshal(event.User, &userdata); err != nil { - result = append(result, event) - continue - } - - if _, found := userdata["displayname"]; !found { - result = append(result, event) - continue - } - - delete(userdata, "displayname") - if len(userdata) == 0 { - // No more userdata, no need to serialize empty map. - e := event.Clone() - e.User = nil - result = append(result, e) - continue - } - - data, err := json.Marshal(userdata) - if err != nil { - result = append(result, event) - continue - } - - e := event.Clone() - e.User = data - result = append(result, e) - } - return result -} - -func (s *ClientSession) filterDuplicateJoin(entries []*EventServerMessageSessionEntry) []*EventServerMessageSessionEntry { - s.seenJoinedLock.Lock() - defer s.seenJoinedLock.Unlock() - - // Due to the asynchronous events, a session might received a "Joined" event - // for the same (other) session twice, so filter these out on a per-session - // level. - result := make([]*EventServerMessageSessionEntry, 0, len(entries)) - for _, e := range entries { - if s.seenJoinedEvents[e.SessionId] { - log.Printf("Session %s got duplicate joined event for %s, ignoring", s.publicId, e.SessionId) - continue - } - - if s.seenJoinedEvents == nil { - s.seenJoinedEvents = make(map[string]bool) - } - s.seenJoinedEvents[e.SessionId] = true - result = append(result, e) - } - return result -} - -func (s *ClientSession) filterMessage(message *ServerMessage) *ServerMessage { - switch message.Type { - case "event": - switch message.Event.Target { - case "participants": - if message.Event.Type == "update" { - m := message.Event.Update - users := make(map[string]bool) - for _, entry := range m.Users { - users[entry["sessionId"].(string)] = true - } - for _, entry := range m.Changed { - if users[entry["sessionId"].(string)] { - continue - } - m.Users = append(m.Users, entry) - } - // TODO(jojo): Only send all users if current session id has - // changed its "inCall" flag to true. - m.Changed = nil - } - case "room": - switch message.Event.Type { - case "join": - join := s.filterDuplicateJoin(message.Event.Join) - if len(join) == 0 { - return nil - } - copied := false - if len(join) != len(message.Event.Join) { - // Create unique copy of message for only this client. - copied = true - message = &ServerMessage{ - Id: message.Id, - Type: message.Type, - Event: &EventServerMessage{ - Type: message.Event.Type, - Target: message.Event.Target, - Join: join, - }, - } - } - - if s.HasPermission(PERMISSION_HIDE_DISPLAYNAMES) { - if copied { - message.Event.Join = filterDisplayNames(message.Event.Join) - } else { - message = &ServerMessage{ - Id: message.Id, - Type: message.Type, - Event: &EventServerMessage{ - Type: message.Event.Type, - Target: message.Event.Target, - Join: filterDisplayNames(message.Event.Join), - }, - } - } - } - case "leave": - s.seenJoinedLock.Lock() - defer s.seenJoinedLock.Unlock() - - for _, e := range message.Event.Leave { - delete(s.seenJoinedEvents, e) - } - case "message": - if message.Event.Message == nil || len(message.Event.Message.Data) == 0 || !s.HasPermission(PERMISSION_HIDE_DISPLAYNAMES) { - return message - } - - var data RoomEventMessageData - if err := json.Unmarshal(message.Event.Message.Data, &data); err != nil { - return message - } - - if data.Type == "chat" && data.Chat != nil && data.Chat.Comment != nil { - if displayName, found := (*data.Chat.Comment)["actorDisplayName"]; found && displayName != "" { - (*data.Chat.Comment)["actorDisplayName"] = "" - if encoded, err := json.Marshal(data); err == nil { - // Create unique copy of message for only this client. - message = &ServerMessage{ - Id: message.Id, - Type: message.Type, - Event: &EventServerMessage{ - Type: message.Event.Type, - Target: message.Event.Target, - Message: &RoomEventMessage{ - RoomId: message.Event.Message.RoomId, - Data: encoded, - }, - }, - } - } - } - } - } - } - case "message": - if message.Message != nil && len(message.Message.Data) > 0 && s.HasPermission(PERMISSION_HIDE_DISPLAYNAMES) { - var data MessageServerMessageData - if err := json.Unmarshal(message.Message.Data, &data); err != nil { - return message - } - - if data.Type == "nickChanged" { - return nil - } - } - } - - return message -} - -func (s *ClientSession) filterAsyncMessage(msg *AsyncMessage) *ServerMessage { - switch msg.Type { - case "message": - if msg.Message == nil { - log.Printf("Received asynchronous message without payload: %+v", msg) - return nil - } - - switch msg.Message.Type { - case "message": - if msg.Message.Message != nil { - if sender := msg.Message.Message.Sender; sender != nil { - if sender.SessionId == s.PublicId() { - // Don't send message back to sender (can happen if sent to user or room) - return nil - } - if sender.Type == RecipientTypeCall { - if room := s.GetRoom(); room == nil || !room.IsSessionInCall(s) { - // Session is not in call, so discard. - return nil - } - } - } - } - case "control": - if msg.Message.Control != nil { - if sender := msg.Message.Control.Sender; sender != nil { - if sender.SessionId == s.PublicId() { - // Don't send message back to sender (can happen if sent to user or room) - return nil - } - if sender.Type == RecipientTypeCall { - if room := s.GetRoom(); room == nil || !room.IsSessionInCall(s) { - // Session is not in call, so discard. - return nil - } - } - } - } - case "event": - if msg.Message.Event.Target == "room" { - // Can happen mostly during tests where an older room async message - // could be received by a subscriber that joined after it was sent. - if joined := s.getRoomJoinTime(); joined.IsZero() || msg.SendTime.Before(joined) { - log.Printf("Message %+v was sent on %s before room was joined on %s, ignoring", msg.Message, msg.SendTime, joined) - return nil - } - } - } - - return msg.Message - default: - log.Printf("Received async message with unsupported type %s: %+v", msg.Type, msg) - return nil - } -} - -func (s *ClientSession) NotifySessionResumed(client HandlerClient) { - s.mu.Lock() - if len(s.pendingClientMessages) == 0 { - s.mu.Unlock() - if room := s.GetRoom(); room != nil { - room.NotifySessionResumed(s) - } - return - } - - messages := s.pendingClientMessages - hasPendingParticipantsUpdate := s.hasPendingParticipantsUpdate - s.pendingClientMessages = nil - s.hasPendingChat = false - s.hasPendingParticipantsUpdate = false - s.mu.Unlock() - - log.Printf("Send %d pending messages to session %s", len(messages), s.PublicId()) - // Send through session to handle connection interruptions. - s.SendMessages(messages) - - if !hasPendingParticipantsUpdate { - // Only need to send initial participants list update if none was part of the pending messages. - if room := s.GetRoom(); room != nil { - room.NotifySessionResumed(s) - } - } -} - -func (s *ClientSession) AddVirtualSession(session *VirtualSession) { - s.mu.Lock() - if s.virtualSessions == nil { - s.virtualSessions = make(map[*VirtualSession]bool) - } - s.virtualSessions[session] = true - s.mu.Unlock() -} - -func (s *ClientSession) RemoveVirtualSession(session *VirtualSession) { - s.mu.Lock() - delete(s.virtualSessions, session) - s.mu.Unlock() -} - -func (s *ClientSession) GetVirtualSessions() []*VirtualSession { - s.mu.Lock() - defer s.mu.Unlock() - - result := make([]*VirtualSession, 0, len(s.virtualSessions)) - for session := range s.virtualSessions { - result = append(result, session) - } - return result -} - -func (s *ClientSession) HandleResponse(id string, handler ResponseHandlerFunc) { - s.responseHandlersLock.Lock() - defer s.responseHandlersLock.Unlock() - - if s.responseHandlers == nil { - s.responseHandlers = make(map[string]ResponseHandlerFunc) - } - - s.responseHandlers[id] = handler -} - -func (s *ClientSession) ClearResponseHandler(id string) { - s.responseHandlersLock.Lock() - defer s.responseHandlersLock.Unlock() - - delete(s.responseHandlers, id) -} - -func (s *ClientSession) ProcessResponse(message *ClientMessage) bool { - id := message.Id - if id == "" { - return false - } - - s.responseHandlersLock.Lock() - cb, found := s.responseHandlers[id] - defer s.responseHandlersLock.Unlock() - - if !found { - return false - } - - return cb(message) -} diff --git a/clientsession_test.go b/clientsession_test.go deleted file mode 100644 index e75b132..0000000 --- a/clientsession_test.go +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2019 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "net/url" - "strconv" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var ( - equalStrings = map[bool]string{ - true: "equal", - false: "not equal", - } -) - -type EqualTestData struct { - a map[Permission]bool - b map[Permission]bool - equal bool -} - -func Test_permissionsEqual(t *testing.T) { - tests := []EqualTestData{ - { - a: nil, - b: nil, - equal: true, - }, - { - a: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - }, - b: nil, - equal: false, - }, - { - a: nil, - b: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - }, - equal: false, - }, - { - a: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - }, - b: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - }, - equal: true, - }, - { - a: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - PERMISSION_MAY_PUBLISH_SCREEN: true, - }, - b: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - }, - equal: false, - }, - { - a: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - }, - b: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - PERMISSION_MAY_PUBLISH_SCREEN: true, - }, - equal: false, - }, - { - a: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - PERMISSION_MAY_PUBLISH_SCREEN: true, - }, - b: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - PERMISSION_MAY_PUBLISH_SCREEN: true, - }, - equal: true, - }, - { - a: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - PERMISSION_MAY_PUBLISH_SCREEN: true, - }, - b: map[Permission]bool{ - PERMISSION_MAY_PUBLISH_MEDIA: true, - PERMISSION_MAY_PUBLISH_SCREEN: false, - }, - equal: false, - }, - } - for idx, test := range tests { - test := test - t.Run(strconv.Itoa(idx), func(t *testing.T) { - t.Parallel() - equal := permissionsEqual(test.a, test.b) - assert.Equal(t, test.equal, equal, "Expected %+v to be %s to %+v but was %s", test.a, equalStrings[test.equal], test.b, equalStrings[equal]) - }) - } -} - -func TestBandwidth_Client(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - hub, _, _, server := CreateHubForTest(t) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - mcu, err := NewTestMCU() - require.NoError(err) - require.NoError(mcu.Start(ctx)) - defer mcu.Stop() - - hub.SetMcu(mcu) - - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - - hello, err := client.RunUntilHello(ctx) - require.NoError(err) - - // Join room by id. - roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - // We will receive a "joined" event. - assert.NoError(client.RunUntilJoined(ctx, hello.Hello)) - - // Client may not send an offer with audio and video. - bitrate := 10000 - require.NoError(client.SendMessage(MessageClientMessageRecipient{ - Type: "session", - SessionId: hello.Hello.SessionId, - }, MessageClientMessageData{ - Type: "offer", - Sid: "54321", - RoomType: "video", - Bitrate: bitrate, - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioAndVideo, - }, - })) - - require.NoError(client.RunUntilAnswer(ctx, MockSdpAnswerAudioAndVideo)) - - pub := mcu.GetPublisher(hello.Hello.SessionId) - require.NotNil(pub) - assert.Equal(bitrate, pub.settings.Bitrate) -} - -func TestBandwidth_Backend(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - hub, _, _, server := CreateHubWithMultipleBackendsForTest(t) - - u, err := url.Parse(server.URL + "/one") - require.NoError(t, err) - backend := hub.backend.GetBackend(u) - require.NotNil(t, backend, "Could not get backend") - - backend.maxScreenBitrate = 1000 - backend.maxStreamBitrate = 2000 - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - mcu, err := NewTestMCU() - require.NoError(t, err) - require.NoError(t, mcu.Start(ctx)) - defer mcu.Stop() - - hub.SetMcu(mcu) - - streamTypes := []StreamType{ - StreamTypeVideo, - StreamTypeScreen, - } - - for _, streamType := range streamTypes { - t.Run(string(streamType), func(t *testing.T) { - require := require.New(t) - assert := assert.New(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - params := TestBackendClientAuthParams{ - UserId: testDefaultUserId, - } - require.NoError(client.SendHelloParams(server.URL+"/one", HelloVersionV1, "client", nil, params)) - - hello, err := client.RunUntilHello(ctx) - require.NoError(err) - - // Join room by id. - roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - // We will receive a "joined" event. - require.NoError(client.RunUntilJoined(ctx, hello.Hello)) - - // Client may not send an offer with audio and video. - bitrate := 10000 - require.NoError(client.SendMessage(MessageClientMessageRecipient{ - Type: "session", - SessionId: hello.Hello.SessionId, - }, MessageClientMessageData{ - Type: "offer", - Sid: "54321", - RoomType: string(streamType), - Bitrate: bitrate, - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioAndVideo, - }, - })) - - require.NoError(client.RunUntilAnswer(ctx, MockSdpAnswerAudioAndVideo)) - - pub := mcu.GetPublisher(hello.Hello.SessionId) - require.NotNil(pub, "Could not find publisher") - - var expectBitrate int - if streamType == StreamTypeVideo { - expectBitrate = backend.maxStreamBitrate - } else { - expectBitrate = backend.maxScreenBitrate - } - assert.Equal(expectBitrate, pub.settings.Bitrate) - }) - } -} diff --git a/client/main.go b/cmd/client/main.go similarity index 67% rename from client/main.go rename to cmd/client/main.go index d66503f..f04d3db 100644 --- a/client/main.go +++ b/cmd/client/main.go @@ -35,7 +35,7 @@ import ( "os" "os/signal" "runtime" - "strings" + "runtime/pprof" "sync" "sync/atomic" "time" @@ -44,14 +44,27 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/websocket" "github.com/mailru/easyjson" + "github.com/mailru/easyjson/jlexer" + "github.com/mailru/easyjson/jwriter" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) var ( + version = "unreleased" + + showVersion = flag.Bool("version", false, "show version and quit") + addr = flag.String("addr", "localhost:28080", "http service address") - config = flag.String("config", "server.conf", "config file to use") + configFlag = flag.String("config", "server.conf", "config file to use") + + cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") + + memprofile = flag.String("memprofile", "", "write memory profile to file") maxClients = flag.Int("maxClients", 100, "number of client connections") @@ -75,47 +88,47 @@ const ( maxMessageSize = 64 * 1024 ) -type Stats struct { - numRecvMessages atomic.Uint64 - numSentMessages atomic.Uint64 - resetRecvMessages uint64 - resetSentMessages uint64 - - start time.Time -} - -func (s *Stats) reset(start time.Time) { - s.resetRecvMessages = s.numRecvMessages.Load() - s.resetSentMessages = s.numSentMessages.Load() - s.start = start -} - -func (s *Stats) Log() { - now := time.Now() - duration := now.Sub(s.start) - perSec := uint64(duration / time.Second) - if perSec == 0 { - return - } - - totalSentMessages := s.numSentMessages.Load() - sentMessages := totalSentMessages - s.resetSentMessages - totalRecvMessages := s.numRecvMessages.Load() - recvMessages := totalRecvMessages - s.resetRecvMessages - log.Printf("Stats: sent=%d (%d/sec), recv=%d (%d/sec), delta=%d", - totalSentMessages, sentMessages/perSec, - totalRecvMessages, recvMessages/perSec, - totalSentMessages-totalRecvMessages) - s.reset(now) -} - type MessagePayload struct { Now time.Time `json:"now"` } +func (m *MessagePayload) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + w.RawByte('{') + w.RawString("\"now\":") + w.Raw(m.Now.MarshalJSON()) + w.RawByte('}') + return w.Buffer.BuildBytes(), w.Error +} + +func (m *MessagePayload) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + r.Delim('{') + for !r.IsDelim('}') { + key := r.UnsafeFieldName(false) + r.WantColon() + switch key { + case "now": + if r.IsNull() { + r.Skip() + } else { + if data := r.Raw(); r.Ok() { + r.AddError((m.Now).UnmarshalJSON(data)) + } + } + default: + r.SkipRecursive() + } + r.WantComma() + } + r.Delim('}') + r.Consumed() + + return r.Error() +} + type SignalingClient struct { - readyWg *sync.WaitGroup - cookie *signaling.SessionIdCodec + readyWg *sync.WaitGroup // +checklocksignore: Only written to from constructor. conn *websocket.Conn @@ -124,13 +137,16 @@ type SignalingClient struct { stopChan chan struct{} - lock sync.Mutex - privateSessionId string - publicSessionId string - userId string + lock sync.Mutex + // +checklocks:lock + privateSessionId api.PrivateSessionId + // +checklocks:lock + publicSessionId api.PublicSessionId + // +checklocks:lock + userId string } -func NewSignalingClient(cookie *signaling.SessionIdCodec, url string, stats *Stats, readyWg *sync.WaitGroup, doneWg *sync.WaitGroup) (*SignalingClient, error) { +func NewSignalingClient(url string, stats *Stats, readyWg *sync.WaitGroup, doneWg *sync.WaitGroup) (*SignalingClient, error) { conn, _, err := websocket.DefaultDialer.Dial(url, nil) if err != nil { return nil, err @@ -138,7 +154,6 @@ func NewSignalingClient(cookie *signaling.SessionIdCodec, url string, stats *Sta client := &SignalingClient{ readyWg: readyWg, - cookie: cookie, conn: conn, @@ -146,15 +161,8 @@ func NewSignalingClient(cookie *signaling.SessionIdCodec, url string, stats *Sta stopChan: make(chan struct{}), } - doneWg.Add(2) - go func() { - defer doneWg.Done() - client.readPump() - }() - go func() { - defer doneWg.Done() - client.writePump() - }() + doneWg.Go(client.readPump) + doneWg.Go(client.writePump) return client, nil } @@ -169,6 +177,10 @@ func (c *SignalingClient) Close() { c.lock.Lock() c.publicSessionId = "" c.privateSessionId = "" + c.writeInternal(&api.ClientMessage{ + Type: "bye", + Bye: &api.ByeClientMessage{}, + }) c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) // nolint c.conn.Close() @@ -176,7 +188,7 @@ func (c *SignalingClient) Close() { c.lock.Unlock() } -func (c *SignalingClient) Send(message *signaling.ClientMessage) { +func (c *SignalingClient) Send(message *api.ClientMessage) { c.lock.Lock() if c.conn == nil { c.lock.Unlock() @@ -191,9 +203,11 @@ func (c *SignalingClient) Send(message *signaling.ClientMessage) { c.lock.Unlock() } -func (c *SignalingClient) processMessage(message *signaling.ServerMessage) { +func (c *SignalingClient) processMessage(message *api.ServerMessage) { c.stats.numRecvMessages.Add(1) switch message.Type { + case "welcome": + // Ignore welcome message. case "hello": c.processHelloMessage(message) case "message": @@ -209,37 +223,25 @@ func (c *SignalingClient) processMessage(message *signaling.ServerMessage) { } } -func (c *SignalingClient) privateToPublicSessionId(privateId string) string { - data, err := c.cookie.DecodePrivate(privateId) - if err != nil { - panic(fmt.Sprintf("could not decode private session id: %s", err)) - } - publicId, err := c.cookie.EncodePublic(data) - if err != nil { - panic(fmt.Sprintf("could not encode public id: %s", err)) - } - return publicId -} - -func (c *SignalingClient) processHelloMessage(message *signaling.ServerMessage) { +func (c *SignalingClient) processHelloMessage(message *api.ServerMessage) { c.lock.Lock() defer c.lock.Unlock() c.privateSessionId = message.Hello.ResumeId - c.publicSessionId = c.privateToPublicSessionId(c.privateSessionId) + c.publicSessionId = message.Hello.SessionId c.userId = message.Hello.UserId log.Printf("Registered as %s (userid %s)", c.privateSessionId, c.userId) c.readyWg.Done() } -func (c *SignalingClient) PublicSessionId() string { +func (c *SignalingClient) PublicSessionId() api.PublicSessionId { c.lock.Lock() defer c.lock.Unlock() return c.publicSessionId } -func (c *SignalingClient) processMessageMessage(message *signaling.ServerMessage) { +func (c *SignalingClient) processMessageMessage(message *api.ServerMessage) { var msg MessagePayload - if err := json.Unmarshal(message.Message.Data, &msg); err != nil { + if err := msg.UnmarshalJSON(message.Message.Data); err != nil { log.Println("Error in unmarshal", err) return } @@ -294,7 +296,9 @@ func (c *SignalingClient) readPump() { break } - var message signaling.ServerMessage + c.stats.numRecvBytes.Add(uint64(decodeBuffer.Len())) + + var message api.ServerMessage if err := message.UnmarshalJSON(decodeBuffer.Bytes()); err != nil { log.Printf("Error: %v", err) break @@ -304,13 +308,14 @@ func (c *SignalingClient) readPump() { } } -func (c *SignalingClient) writeInternal(message *signaling.ClientMessage) bool { +func (c *SignalingClient) writeInternal(message *api.ClientMessage) bool { var closeData []byte c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint + var written int writer, err := c.conn.NextWriter(websocket.TextMessage) if err == nil { - _, err = easyjson.MarshalToWriter(message, writer) + written, err = easyjson.MarshalToWriter(message, writer) } if err != nil { if err == websocket.ErrCloseSent { @@ -326,6 +331,9 @@ func (c *SignalingClient) writeInternal(message *signaling.ClientMessage) bool { writer.Close() c.stats.numSentMessages.Add(1) + if written > 0 { + c.stats.numSentBytes.Add(uint64(written)) + } return true close: @@ -369,7 +377,7 @@ func (c *SignalingClient) writePump() { } func (c *SignalingClient) SendMessages(clients []*SignalingClient) { - sessionIds := make(map[*SignalingClient]string) + sessionIds := make(map[*SignalingClient]api.PublicSessionId) for _, c := range clients { sessionIds[c] = c.PublicSessionId() } @@ -387,11 +395,11 @@ func (c *SignalingClient) SendMessages(clients []*SignalingClient) { msgdata := MessagePayload{ Now: now, } - data, _ := json.Marshal(msgdata) - msg := &signaling.ClientMessage{ + data, _ := msgdata.MarshalJSON() + msg := &api.ClientMessage{ Type: "message", - Message: &signaling.MessageClientMessage{ - Recipient: signaling.MessageClientMessageRecipient{ + Message: &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ Type: "session", SessionId: sessionIds[recipient], }, @@ -405,35 +413,35 @@ func (c *SignalingClient) SendMessages(clients []*SignalingClient) { } func registerAuthHandler(router *mux.Router) { - router.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) { + router.HandleFunc("/ocs/v2.php/apps/spreed/api/v1/signaling/backend", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { log.Println("Error reading body:", err) return } - rnd := r.Header.Get(signaling.HeaderBackendSignalingRandom) - checksum := r.Header.Get(signaling.HeaderBackendSignalingChecksum) + rnd := r.Header.Get(talk.HeaderBackendSignalingRandom) + checksum := r.Header.Get(talk.HeaderBackendSignalingChecksum) if rnd == "" || checksum == "" { log.Println("No checksum headers found") return } - if verify := signaling.CalculateBackendChecksum(rnd, body, backendSecret); verify != checksum { + if verify := talk.CalculateBackendChecksum(rnd, body, backendSecret); verify != checksum { log.Println("Backend checksum verification failed") return } - var request signaling.BackendClientRequest + var request talk.BackendClientRequest if err := request.UnmarshalJSON(body); err != nil { log.Println(err) return } - response := &signaling.BackendClientResponse{ + response := &talk.BackendClientResponse{ Type: "auth", - Auth: &signaling.BackendClientAuthResponse{ - Version: signaling.BackendVersion, + Auth: &talk.BackendClientAuthResponse{ + Version: talk.BackendVersion, UserId: "sample-user", }, } @@ -445,9 +453,9 @@ func registerAuthHandler(router *mux.Router) { } rawdata := json.RawMessage(data) - payload := &signaling.OcsResponse{ - Ocs: &signaling.OcsBody{ - Meta: signaling.OcsMeta{ + payload := &talk.OcsResponse{ + Ocs: &talk.OcsBody{ + Meta: talk.OcsMeta{ Status: "ok", StatusCode: http.StatusOK, Message: http.StatusText(http.StatusOK), @@ -488,38 +496,48 @@ func main() { flag.Parse() log.SetFlags(0) - config, err := goconf.ReadConfigFile(*config) + if *showVersion { + fmt.Printf("nextcloud-spreed-signaling-client version %s/%s\n", version, runtime.Version()) + os.Exit(0) + } + + cfg, err := goconf.ReadConfigFile(*configFlag) if err != nil { log.Fatal("Could not read configuration: ", err) } - secret, _ := config.GetString("backend", "secret") + secret, _ := config.GetStringOptionWithEnv(cfg, "backend", "secret") backendSecret = []byte(secret) - hashKey, _ := config.GetString("sessions", "hashkey") - switch len(hashKey) { - case 32: - case 64: - default: - log.Printf("WARNING: The sessions hash key should be 32 or 64 bytes but is %d bytes", len(hashKey)) + log.Printf("Using a maximum of %d CPUs", runtime.GOMAXPROCS(0)) + + if *cpuprofile != "" { + f, err := os.Create(*cpuprofile) + if err != nil { + log.Fatal(err) + } + + if err := pprof.StartCPUProfile(f); err != nil { + log.Fatalf("Error writing CPU profile to %s: %s", *cpuprofile, err) + } + log.Printf("Writing CPU profile to %s ...", *cpuprofile) + defer pprof.StopCPUProfile() } - blockKey, _ := config.GetString("sessions", "blockkey") - blockBytes := []byte(blockKey) - switch len(blockKey) { - case 0: - blockBytes = nil - case 16: - case 24: - case 32: - default: - log.Fatalf("The sessions block key must be 16, 24 or 32 bytes but is %d bytes", len(blockKey)) - } - cookie := signaling.NewSessionIdCodec([]byte(hashKey), blockBytes) + if *memprofile != "" { + f, err := os.Create(*memprofile) + if err != nil { + log.Fatal(err) // nolint (defer pprof.StopCPUProfile() will not run which is ok in case of errors) + } - cpus := runtime.NumCPU() - runtime.GOMAXPROCS(cpus) - log.Printf("Using a maximum of %d CPUs", cpus) + defer func() { + log.Printf("Writing Memory profile to %s ...", *memprofile) + runtime.GC() + if err := pprof.WriteHeapProfile(f); err != nil { + log.Printf("Error writing Memory profile to %s: %s", *memprofile, err) + } + }() + } interrupt := make(chan os.Signal, 1) signal.Notify(interrupt, os.Interrupt) @@ -544,7 +562,7 @@ func main() { urls := make([]url.URL, 0) urlstrings := make([]string, 0) - for _, host := range strings.Split(*addr, ",") { + for host := range internal.SplitEntries(*addr, ",") { u := url.URL{ Scheme: "ws", Host: host, @@ -568,19 +586,19 @@ func main() { var readyWg sync.WaitGroup for i := 0; i < *maxClients; i++ { - client, err := NewSignalingClient(cookie, urls[i%len(urls)].String(), stats, &readyWg, &doneWg) + client, err := NewSignalingClient(urls[i%len(urls)].String(), stats, &readyWg, &doneWg) if err != nil { log.Fatal(err) } defer client.Close() readyWg.Add(1) - request := &signaling.ClientMessage{ + request := &api.ClientMessage{ Type: "hello", - Hello: &signaling.HelloClientMessage{ - Version: signaling.HelloVersionV1, - Auth: &signaling.HelloClientMessageAuth{ - Url: backendUrl + "/auth", + Hello: &api.HelloClientMessage{ + Version: api.HelloVersionV1, + Auth: &api.HelloClientMessageAuth{ + Url: backendUrl, Params: json.RawMessage("{}"), }, }, @@ -596,11 +614,9 @@ func main() { log.Println("All connections established") for _, c := range clients { - doneWg.Add(1) - go func(c *SignalingClient) { - defer doneWg.Done() + doneWg.Go(func() { c.SendMessages(clients) - }(c) + }) } stats.start = time.Now() diff --git a/cmd/client/stats.go b/cmd/client/stats.go new file mode 100644 index 0000000..a7acc99 --- /dev/null +++ b/cmd/client/stats.go @@ -0,0 +1,97 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package main + +import ( + "log" + "sync/atomic" + "time" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" +) + +type Stats struct { + numRecvMessages atomic.Int64 + numSentMessages atomic.Int64 + resetRecvMessages int64 + resetSentMessages int64 + numRecvBytes atomic.Uint64 + numSentBytes atomic.Uint64 + resetRecvBytes uint64 + resetSentBytes uint64 + + start time.Time +} + +func (s *Stats) reset(start time.Time) { + s.resetRecvMessages = s.numRecvMessages.Load() + s.resetSentMessages = s.numSentMessages.Load() + s.resetRecvBytes = s.numRecvBytes.Load() + s.resetSentBytes = s.numSentBytes.Load() + s.start = start +} + +type statsLogEntries struct { + totalSentMessages int64 + sentMessagesPerSec int64 + sentBytesPerSec api.Bandwidth + + totalRecvMessages int64 + recvMessagesPerSec int64 + recvBytesPerSec api.Bandwidth +} + +func (s *Stats) getLogEntries(now time.Time) *statsLogEntries { + duration := now.Sub(s.start) + perSec := int64(duration / time.Second) + if perSec == 0 { + return nil + } + + totalSentMessages := s.numSentMessages.Load() + sentMessages := totalSentMessages - s.resetSentMessages + sentBytes := api.BandwidthFromBytes(s.numSentBytes.Load() - s.resetSentBytes) + totalRecvMessages := s.numRecvMessages.Load() + recvMessages := totalRecvMessages - s.resetRecvMessages + recvBytes := api.BandwidthFromBytes(s.numRecvBytes.Load() - s.resetRecvBytes) + + s.reset(now) + return &statsLogEntries{ + totalSentMessages: totalSentMessages, + sentMessagesPerSec: sentMessages / perSec, + sentBytesPerSec: sentBytes, + + totalRecvMessages: totalRecvMessages, + recvMessagesPerSec: recvMessages / perSec, + recvBytesPerSec: recvBytes, + } +} + +func (s *Stats) Log() { + now := time.Now() + if entries := s.getLogEntries(now); entries != nil { + log.Printf("Stats: sent=%d (%d/sec, %s), recv=%d (%d/sec, %s), delta=%d", + entries.totalSentMessages, entries.sentMessagesPerSec, entries.sentBytesPerSec, + entries.totalRecvMessages, entries.recvMessagesPerSec, entries.recvBytesPerSec, + entries.totalSentMessages-entries.totalRecvMessages) + } +} diff --git a/cmd/client/stats_test.go b/cmd/client/stats_test.go new file mode 100644 index 0000000..bb3e492 --- /dev/null +++ b/cmd/client/stats_test.go @@ -0,0 +1,80 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" +) + +func TestStats(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + var stats Stats + assert.Nil(stats.getLogEntries(time.Time{})) + now := time.Now() + if entries := stats.getLogEntries(now); assert.NotNil(entries) { + assert.EqualValues(0, entries.totalSentMessages) + assert.EqualValues(0, entries.sentMessagesPerSec) + assert.EqualValues(0, entries.sentBytesPerSec) + + assert.EqualValues(0, entries.totalRecvMessages) + assert.EqualValues(0, entries.recvMessagesPerSec) + assert.EqualValues(0, entries.recvBytesPerSec) + } + + stats.numSentMessages.Add(10) + stats.numSentBytes.Add((api.Bandwidth(20) * api.Kilobit).Bits()) + + stats.numRecvMessages.Add(30) + stats.numRecvBytes.Add((api.Bandwidth(40) * api.Kilobit).Bits()) + + if entries := stats.getLogEntries(now.Add(time.Second)); assert.NotNil(entries) { + assert.EqualValues(10, entries.totalSentMessages) + assert.EqualValues(10, entries.sentMessagesPerSec) + assert.EqualValues(20*1024*8, entries.sentBytesPerSec) + + assert.EqualValues(30, entries.totalRecvMessages) + assert.EqualValues(30, entries.recvMessagesPerSec) + assert.EqualValues(40*1024*8, entries.recvBytesPerSec) + } + + stats.numSentMessages.Add(100) + stats.numSentBytes.Add((api.Bandwidth(200) * api.Kilobit).Bits()) + + stats.numRecvMessages.Add(300) + stats.numRecvBytes.Add((api.Bandwidth(400) * api.Kilobit).Bits()) + + if entries := stats.getLogEntries(now.Add(2 * time.Second)); assert.NotNil(entries) { + assert.EqualValues(110, entries.totalSentMessages) + assert.EqualValues(100, entries.sentMessagesPerSec) + assert.EqualValues(200*1024*8, entries.sentBytesPerSec) + + assert.EqualValues(330, entries.totalRecvMessages) + assert.EqualValues(300, entries.recvMessagesPerSec) + assert.EqualValues(400*1024*8, entries.recvBytesPerSec) + } +} diff --git a/proxy/main.go b/cmd/proxy/main.go similarity index 62% rename from proxy/main.go rename to cmd/proxy/main.go index 18f7bf5..7d1ecfc 100644 --- a/proxy/main.go +++ b/cmd/proxy/main.go @@ -22,6 +22,7 @@ package main import ( + "context" "flag" "fmt" "log" @@ -30,14 +31,15 @@ import ( "os" "os/signal" "runtime" - "strings" "syscall" "time" "github.com/dlintw/goconf" "github.com/gorilla/mux" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + signalinglog "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) var ( @@ -65,49 +67,52 @@ func main() { } sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt) signal.Notify(sigChan, syscall.SIGHUP) signal.Notify(sigChan, syscall.SIGUSR1) - log.Printf("Starting up version %s/%s as pid %d", version, runtime.Version(), os.Getpid()) + stopCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() - config, err := goconf.ReadConfigFile(*configFlag) + logger := log.Default() + stopCtx = signalinglog.NewLoggerContext(stopCtx, logger) + + logger.Printf("Starting up version %s/%s as pid %d", version, runtime.Version(), os.Getpid()) + + cfg, err := goconf.ReadConfigFile(*configFlag) if err != nil { - log.Fatal("Could not read configuration: ", err) + logger.Fatal("Could not read configuration: ", err) } - cpus := runtime.NumCPU() - runtime.GOMAXPROCS(cpus) - log.Printf("Using a maximum of %d CPUs", cpus) + logger.Printf("Using a maximum of %d CPUs", runtime.GOMAXPROCS(0)) r := mux.NewRouter() - proxy, err := NewProxyServer(r, version, config) + proxy, err := NewProxyServer(stopCtx, r, version, cfg) if err != nil { - log.Fatal(err) + logger.Fatal(err) } - if err := proxy.Start(config); err != nil { - log.Fatal(err) + if err := proxy.Start(cfg); err != nil { + logger.Fatal(err) } defer proxy.Stop() - if addr, _ := signaling.GetStringOptionWithEnv(config, "http", "listen"); addr != "" { - readTimeout, _ := config.GetInt("http", "readtimeout") + if addr, _ := config.GetStringOptionWithEnv(cfg, "http", "listen"); addr != "" { + readTimeout, _ := cfg.GetInt("http", "readtimeout") if readTimeout <= 0 { readTimeout = defaultReadTimeout } - writeTimeout, _ := config.GetInt("http", "writetimeout") + writeTimeout, _ := cfg.GetInt("http", "writetimeout") if writeTimeout <= 0 { writeTimeout = defaultWriteTimeout } - for _, address := range strings.Split(addr, " ") { + for address := range internal.SplitEntries(addr, " ") { go func(address string) { - log.Println("Listening on", address) + logger.Println("Listening on", address) listener, err := net.Listen("tcp", address) if err != nil { - log.Fatal("Could not start listening: ", err) + logger.Fatal("Could not start listening: ", err) } srv := &http.Server{ Handler: r, @@ -117,7 +122,7 @@ func main() { WriteTimeout: time.Duration(writeTimeout) * time.Second, } if err := srv.Serve(listener); err != nil { - log.Fatal("Could not start server: ", err) + logger.Fatal("Could not start server: ", err) } }(address) } @@ -126,24 +131,24 @@ func main() { loop: for { select { + case <-stopCtx.Done(): + logger.Println("Interrupted") + break loop case sig := <-sigChan: switch sig { - case os.Interrupt: - log.Println("Interrupted") - break loop case syscall.SIGHUP: - log.Printf("Received SIGHUP, reloading %s", *configFlag) + logger.Printf("Received SIGHUP, reloading %s", *configFlag) if config, err := goconf.ReadConfigFile(*configFlag); err != nil { - log.Printf("Could not read configuration from %s: %s", *configFlag, err) + logger.Printf("Could not read configuration from %s: %s", *configFlag, err) } else { proxy.Reload(config) } case syscall.SIGUSR1: - log.Printf("Received SIGUSR1, scheduling server to shutdown") + logger.Printf("Received SIGUSR1, scheduling server to shutdown") proxy.ScheduleShutdown() } case <-proxy.ShutdownChannel(): - log.Printf("All clients disconnected, shutting down") + logger.Printf("All clients disconnected, shutting down") break loop } } diff --git a/proxy/proxy_client.go b/cmd/proxy/proxy_client.go similarity index 72% rename from proxy/proxy_client.go rename to cmd/proxy/proxy_client.go index 935a2b9..1a16670 100644 --- a/proxy/proxy_client.go +++ b/cmd/proxy/proxy_client.go @@ -27,25 +27,35 @@ import ( "time" "github.com/gorilla/websocket" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/client" ) type ProxyClient struct { - signaling.Client + client.Client proxy *ProxyServer session atomic.Pointer[ProxySession] } -func NewProxyClient(ctx context.Context, proxy *ProxyServer, conn *websocket.Conn, addr string) (*ProxyClient, error) { +func NewProxyClient(ctx context.Context, proxy *ProxyServer, conn *websocket.Conn, addr string, agent string) (*ProxyClient, error) { client := &ProxyClient{ proxy: proxy, } - client.SetConn(ctx, conn, addr, client) + client.SetConn(ctx, conn, addr, agent, false, client) return client, nil } +func (c *ProxyClient) GetSessionId() api.PublicSessionId { + if session := c.GetSession(); session != nil { + return session.PublicId() + } + + return "" +} + func (c *ProxyClient) GetSession() *ProxySession { return c.session.Load() } @@ -54,18 +64,18 @@ func (c *ProxyClient) SetSession(session *ProxySession) { c.session.Store(session) } -func (c *ProxyClient) OnClosed(client signaling.HandlerClient) { - if session := c.GetSession(); session != nil { +func (c *ProxyClient) OnClosed() { + if session := c.session.Swap(nil); session != nil { session.MarkUsed() } - c.proxy.clientClosed(&c.Client) + c.proxy.clientClosed(c) } -func (c *ProxyClient) OnMessageReceived(client signaling.HandlerClient, data []byte) { +func (c *ProxyClient) OnMessageReceived(data []byte) { c.proxy.processMessage(c, data) } -func (c *ProxyClient) OnRTTReceived(client signaling.HandlerClient, rtt time.Duration) { +func (c *ProxyClient) OnRTTReceived(rtt time.Duration) { if session := c.GetSession(); session != nil { session.MarkUsed() } diff --git a/proxy/proxy_remote.go b/cmd/proxy/proxy_remote.go similarity index 52% rename from proxy/proxy_remote.go rename to cmd/proxy/proxy_remote.go index 81a7bbf..d6092f3 100644 --- a/proxy/proxy_remote.go +++ b/cmd/proxy/proxy_remote.go @@ -27,7 +27,8 @@ import ( "crypto/tls" "encoding/json" "errors" - "log" + "math/rand/v2" + "net" "net/http" "net/url" "strconv" @@ -38,12 +39,15 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/gorilla/websocket" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/proxy" ) const ( initialReconnectInterval = 1 * time.Second - maxReconnectInterval = 32 * time.Second + maxReconnectInterval = 16 * time.Second // Time allowed to write a message to the peer. writeWait = 10 * time.Second @@ -56,41 +60,56 @@ const ( ) var ( - ErrNotConnected = errors.New("not connected") + ErrNotConnected = errors.New("not connected") // +checklocksignore: Global readonly variable. ) type RemoteConnection struct { + logger log.Logger mu sync.Mutex + p *ProxyServer url *url.URL - conn *websocket.Conn - closer *signaling.Closer - closed atomic.Bool + // +checklocks:mu + conn *websocket.Conn + closeCtx context.Context + closeFunc context.CancelFunc // +checklocksignore: Only written to from constructor. tokenId string tokenKey *rsa.PrivateKey tlsConfig *tls.Config + // +checklocks:mu connectedSince time.Time reconnectTimer *time.Timer reconnectInterval atomic.Int64 - msgId atomic.Int64 + msgId atomic.Int64 + // +checklocks:mu helloMsgId string - sessionId string + // +checklocks:mu + sessionId api.PublicSessionId + // +checklocks:mu + helloReceived bool - pendingMessages []*signaling.ProxyClientMessage - messageCallbacks map[string]chan *signaling.ProxyServerMessage + // +checklocks:mu + pendingMessages []*proxy.ClientMessage + // +checklocks:mu + messageCallbacks map[string]chan *proxy.ServerMessage } -func NewRemoteConnection(proxyUrl string, tokenId string, tokenKey *rsa.PrivateKey, tlsConfig *tls.Config) (*RemoteConnection, error) { +func NewRemoteConnection(p *ProxyServer, proxyUrl string, tokenId string, tokenKey *rsa.PrivateKey, tlsConfig *tls.Config) (*RemoteConnection, error) { u, err := url.Parse(proxyUrl) if err != nil { return nil, err } + closeCtx, closeFunc := context.WithCancel(context.Background()) + result := &RemoteConnection{ - url: u, - closer: signaling.NewCloser(), + logger: p.logger, + p: p, + url: u, + closeCtx: closeCtx, + closeFunc: closeFunc, tokenId: tokenId, tokenKey: tokenKey, @@ -98,7 +117,7 @@ func NewRemoteConnection(proxyUrl string, tokenId string, tokenKey *rsa.PrivateK reconnectTimer: time.NewTimer(0), - messageCallbacks: make(map[string]chan *signaling.ProxyServerMessage), + messageCallbacks: make(map[string]chan *proxy.ServerMessage), } result.reconnectInterval.Store(int64(initialReconnectInterval)) @@ -111,16 +130,23 @@ func (c *RemoteConnection) String() string { return c.url.String() } +func (c *RemoteConnection) SessionId() api.PublicSessionId { + c.mu.Lock() + defer c.mu.Unlock() + return c.sessionId +} + func (c *RemoteConnection) reconnect() { u, err := c.url.Parse("proxy") if err != nil { - log.Printf("Could not resolve url to proxy at %s: %s", c, err) + c.logger.Printf("Could not resolve url to proxy at %s: %s", c, err) c.scheduleReconnect() return } - if u.Scheme == "http" { + switch u.Scheme { + case "http": u.Scheme = "ws" - } else if u.Scheme == "https" { + case "https": u.Scheme = "wss" } @@ -129,58 +155,81 @@ func (c *RemoteConnection) reconnect() { TLSClientConfig: c.tlsConfig, } - conn, _, err := dialer.DialContext(context.TODO(), u.String(), nil) + conn, _, err := dialer.DialContext(c.closeCtx, u.String(), nil) if err != nil { - log.Printf("Error connecting to proxy at %s: %s", c, err) + c.logger.Printf("Error connecting to proxy at %s: %s", c, err) c.scheduleReconnect() return } - log.Printf("Connected to %s", c) - c.closed.Store(false) + c.logger.Printf("Connected to %s", c) c.mu.Lock() + if c.closeCtx.Err() != nil { + // Closed while waiting for lock. + c.mu.Unlock() + if err := conn.Close(); err != nil { + c.logger.Printf("Error closing connection to %s: %s", c, err) + } + return + } c.connectedSince = time.Now() c.conn = conn c.mu.Unlock() c.reconnectInterval.Store(int64(initialReconnectInterval)) - if err := c.sendHello(); err != nil { - log.Printf("Error sending hello request to proxy at %s: %s", c, err) + if !c.sendReconnectHello() || !c.sendPing() { c.scheduleReconnect() return } - if !c.sendPing() { - return - } - go c.readPump(conn) } -func (c *RemoteConnection) scheduleReconnect() { - if err := c.sendClose(); err != nil && err != ErrNotConnected { - log.Printf("Could not send close message to %s: %s", c, err) +func (c *RemoteConnection) sendReconnectHello() bool { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.sendHello(c.closeCtx); err != nil { + c.logger.Printf("Error sending hello request to proxy at %s: %s", c, err) + return false } - c.close() + + return true +} + +func (c *RemoteConnection) scheduleReconnect() { + c.mu.Lock() + defer c.mu.Unlock() + + c.scheduleReconnectLocked() +} + +// +checklocks:c.mu +func (c *RemoteConnection) scheduleReconnectLocked() { + if err := c.sendCloseLocked(); err != nil && err != ErrNotConnected { + c.logger.Printf("Could not send close message to %s: %s", c, err) + } + c.closeLocked() interval := c.reconnectInterval.Load() - c.reconnectTimer.Reset(time.Duration(interval)) + // Prevent all servers from reconnecting at the same time in case of an + // interrupted connection to the proxy or a restart. + jitter := rand.Int64N(interval) - (interval / 2) + c.reconnectTimer.Reset(time.Duration(interval + jitter)) - interval = interval * 2 - if interval > int64(maxReconnectInterval) { - interval = int64(maxReconnectInterval) - } + interval = min(interval*2, int64(maxReconnectInterval)) c.reconnectInterval.Store(interval) } -func (c *RemoteConnection) sendHello() error { +// +checklocks:c.mu +func (c *RemoteConnection) sendHello(ctx context.Context) error { c.helloMsgId = strconv.FormatInt(c.msgId.Add(1), 10) - msg := &signaling.ProxyClientMessage{ + msg := &proxy.ClientMessage{ Id: c.helloMsgId, Type: "hello", - Hello: &signaling.HelloProxyClientMessage{ + Hello: &proxy.HelloClientMessage{ Version: "1.0", }, } @@ -195,13 +244,11 @@ func (c *RemoteConnection) sendHello() error { msg.Hello.Token = tokenString } - return c.SendMessage(msg) + return c.sendMessageLocked(ctx, msg) } -func (c *RemoteConnection) sendClose() error { - c.mu.Lock() - defer c.mu.Unlock() - +// +checklocks:c.mu +func (c *RemoteConnection) sendCloseLocked() error { if c.conn == nil { return ErrNotConnected } @@ -214,24 +261,39 @@ func (c *RemoteConnection) close() { c.mu.Lock() defer c.mu.Unlock() + c.closeLocked() +} + +// +checklocks:c.mu +func (c *RemoteConnection) closeLocked() { if c.conn != nil { c.conn.Close() c.conn = nil } + c.connectedSince = time.Time{} + c.helloReceived = false } func (c *RemoteConnection) Close() error { c.mu.Lock() defer c.mu.Unlock() c.reconnectTimer.Stop() - if c.conn == nil { + + if c.closeCtx.Err() != nil { + // Already closed return nil } - c.sendClose() - err1 := c.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Time{}) - err2 := c.conn.Close() - c.conn = nil + c.closeFunc() + var err1 error + var err2 error + if c.conn != nil { + err1 = c.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Time{}) + err2 = c.conn.Close() + c.conn = nil + } + c.connectedSince = time.Time{} + c.helloReceived = false if err1 != nil { return err1 } @@ -239,7 +301,7 @@ func (c *RemoteConnection) Close() error { } func (c *RemoteConnection) createToken(subject string) (string, error) { - claims := &signaling.TokenClaims{ + claims := &proxy.TokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: c.tokenId, @@ -255,14 +317,15 @@ func (c *RemoteConnection) createToken(subject string) (string, error) { return tokenString, nil } -func (c *RemoteConnection) SendMessage(msg *signaling.ProxyClientMessage) error { +func (c *RemoteConnection) SendMessage(msg *proxy.ClientMessage) error { c.mu.Lock() defer c.mu.Unlock() - return c.sendMessageLocked(context.Background(), msg) + return c.sendMessageLocked(c.closeCtx, msg) } -func (c *RemoteConnection) deferMessage(ctx context.Context, msg *signaling.ProxyClientMessage) { +// +checklocks:c.mu +func (c *RemoteConnection) deferMessage(ctx context.Context, msg *proxy.ClientMessage) { c.pendingMessages = append(c.pendingMessages, msg) if ctx.Done() != nil { go func() { @@ -280,7 +343,8 @@ func (c *RemoteConnection) deferMessage(ctx context.Context, msg *signaling.Prox } } -func (c *RemoteConnection) sendMessageLocked(ctx context.Context, msg *signaling.ProxyClientMessage) error { +// +checklocks:c.mu +func (c *RemoteConnection) sendMessageLocked(ctx context.Context, msg *proxy.ClientMessage) error { if c.conn == nil { // Defer until connected. c.deferMessage(ctx, msg) @@ -299,7 +363,7 @@ func (c *RemoteConnection) sendMessageLocked(ctx context.Context, msg *signaling func (c *RemoteConnection) readPump(conn *websocket.Conn) { defer func() { - if !c.closed.Load() { + if c.closeCtx.Err() == nil { c.scheduleReconnect() } }() @@ -314,19 +378,21 @@ func (c *RemoteConnection) readPump(conn *websocket.Conn) { websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { - log.Printf("Error reading from %s: %v", c, err) + if !errors.Is(err, net.ErrClosed) || c.closeCtx.Err() == nil { + c.logger.Printf("Error reading from %s: %v", c, err) + } } break } if msgType != websocket.TextMessage { - log.Printf("unexpected message type %q (%s)", msgType, string(msg)) + c.logger.Printf("unexpected message type %q (%s)", msgType, string(msg)) continue } - var message signaling.ProxyServerMessage + var message proxy.ServerMessage if err := json.Unmarshal(msg, &message); err != nil { - log.Printf("could not decode message %s: %s", string(msg), err) + c.logger.Printf("could not decode message %s: %s", string(msg), err) continue } @@ -353,7 +419,7 @@ func (c *RemoteConnection) sendPing() bool { msg := strconv.FormatInt(now.UnixNano(), 10) c.conn.SetWriteDeadline(now.Add(writeWait)) // nolint if err := c.conn.WriteMessage(websocket.PingMessage, []byte(msg)); err != nil { - log.Printf("Could not send ping to proxy at %s: %v", c, err) + c.logger.Printf("Could not send ping to proxy at %s: %v", c, err) go c.scheduleReconnect() return false } @@ -374,44 +440,48 @@ func (c *RemoteConnection) writePump() { c.reconnect() case <-ticker.C: c.sendPing() - case <-c.closer.C: + case <-c.closeCtx.Done(): return } } } -func (c *RemoteConnection) processHello(msg *signaling.ProxyServerMessage) { +func (c *RemoteConnection) processHello(msg *proxy.ServerMessage) { + c.mu.Lock() + defer c.mu.Unlock() + c.helloMsgId = "" switch msg.Type { case "error": if msg.Error.Code == "no_such_session" { - log.Printf("Session %s could not be resumed on %s, registering new", c.sessionId, c) + c.logger.Printf("Session %s could not be resumed on %s, registering new", c.sessionId, c) c.sessionId = "" - if err := c.sendHello(); err != nil { - log.Printf("Could not send hello request to %s: %s", c, err) - c.scheduleReconnect() + if err := c.sendHello(c.closeCtx); err != nil { + c.logger.Printf("Could not send hello request to %s: %s", c, err) + c.scheduleReconnectLocked() } return } - log.Printf("Hello connection to %s failed with %+v, reconnecting", c, msg.Error) - c.scheduleReconnect() + c.logger.Printf("Hello connection to %s failed with %+v, reconnecting", c, msg.Error) + c.scheduleReconnectLocked() case "hello": resumed := c.sessionId == msg.Hello.SessionId c.sessionId = msg.Hello.SessionId - country := "" + c.helloReceived = true + var country geoip.Country if msg.Hello.Server != nil { - if country = msg.Hello.Server.Country; country != "" && !signaling.IsValidCountry(country) { - log.Printf("Proxy %s sent invalid country %s in hello response", c, country) + if country = msg.Hello.Server.Country; country != "" && !geoip.IsValidCountry(country) { + c.logger.Printf("Proxy %s sent invalid country %s in hello response", c, country) country = "" } } if resumed { - log.Printf("Resumed session %s on %s", c.sessionId, c) + c.logger.Printf("Resumed session %s on %s", c.sessionId, c) } else if country != "" { - log.Printf("Received session %s from %s (in %s)", c.sessionId, c, country) + c.logger.Printf("Received session %s from %s (in %s)", c.sessionId, c, country) } else { - log.Printf("Received session %s from %s", c.sessionId, c) + c.logger.Printf("Received session %s from %s", c.sessionId, c) } pending := c.pendingMessages @@ -421,60 +491,94 @@ func (c *RemoteConnection) processHello(msg *signaling.ProxyServerMessage) { continue } - if err := c.sendMessageLocked(context.Background(), m); err != nil { - log.Printf("Could not send pending message %+v to %s: %s", m, c, err) + if err := c.sendMessageLocked(c.closeCtx, m); err != nil { + c.logger.Printf("Could not send pending message %+v to %s: %s", m, c, err) } } default: - log.Printf("Received unsupported hello response %+v from %s, reconnecting", msg, c) - c.scheduleReconnect() + c.logger.Printf("Received unsupported hello response %+v from %s, reconnecting", msg, c) + c.scheduleReconnectLocked() } } -func (c *RemoteConnection) processMessage(msg *signaling.ProxyServerMessage) { - if msg.Id != "" { - c.mu.Lock() - ch, found := c.messageCallbacks[msg.Id] - if found { - delete(c.messageCallbacks, msg.Id) - c.mu.Unlock() - ch <- msg - return - } +func (c *RemoteConnection) handleCallback(msg *proxy.ServerMessage) bool { + if msg.Id == "" { + return false + } + + c.mu.Lock() + ch, found := c.messageCallbacks[msg.Id] + if !found { c.mu.Unlock() + return false + } + + delete(c.messageCallbacks, msg.Id) + c.mu.Unlock() + + ch <- msg + return true +} + +func (c *RemoteConnection) processMessage(msg *proxy.ServerMessage) { + if c.handleCallback(msg) { + return } switch msg.Type { case "event": c.processEvent(msg) + case "bye": + c.logger.Printf("Connection to %s was closed: %s", c, msg.Bye.Reason) + if msg.Bye.Reason == "session_expired" { + // Don't try to resume expired session. + c.mu.Lock() + c.sessionId = "" + c.mu.Unlock() + } + c.scheduleReconnect() default: - log.Printf("Received unsupported message %+v from %s", msg, c) + c.logger.Printf("Received unsupported message %+v from %s", msg, c) } } -func (c *RemoteConnection) processEvent(msg *signaling.ProxyServerMessage) { +func (c *RemoteConnection) processEvent(msg *proxy.ServerMessage) { switch msg.Event.Type { case "update-load": + // Ignore + case "publisher-closed": + c.logger.Printf("Remote publisher %s was closed on %s", msg.Event.ClientId, c) + c.p.RemotePublisherDeleted(api.PublicSessionId(msg.Event.ClientId)) default: - log.Printf("Received unsupported event %+v from %s", msg, c) + c.logger.Printf("Received unsupported event %+v from %s", msg, c) } } -func (c *RemoteConnection) RequestMessage(ctx context.Context, msg *signaling.ProxyClientMessage) (*signaling.ProxyServerMessage, error) { +func (c *RemoteConnection) sendMessageWithCallbackLocked(ctx context.Context, msg *proxy.ClientMessage) (string, <-chan *proxy.ServerMessage, error) { msg.Id = strconv.FormatInt(c.msgId.Add(1), 10) c.mu.Lock() defer c.mu.Unlock() - if err := c.sendMessageLocked(ctx, msg); err != nil { + msg.Id = "" + return "", nil, err + } + + ch := make(chan *proxy.ServerMessage, 1) + c.messageCallbacks[msg.Id] = ch + return msg.Id, ch, nil +} + +func (c *RemoteConnection) RequestMessage(ctx context.Context, msg *proxy.ClientMessage) (*proxy.ServerMessage, error) { + id, ch, err := c.sendMessageWithCallbackLocked(ctx, msg) + if err != nil { return nil, err } - ch := make(chan *signaling.ProxyServerMessage, 1) - c.messageCallbacks[msg.Id] = ch - c.mu.Unlock() + defer func() { c.mu.Lock() - delete(c.messageCallbacks, msg.Id) + defer c.mu.Unlock() + delete(c.messageCallbacks, id) }() select { @@ -488,3 +592,15 @@ func (c *RemoteConnection) RequestMessage(ctx context.Context, msg *signaling.Pr return response, nil } } + +func (c *RemoteConnection) SendBye() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn == nil { + return nil + } + + return c.sendMessageLocked(c.closeCtx, &proxy.ClientMessage{ + Type: "bye", + }) +} diff --git a/cmd/proxy/proxy_remote_test.go b/cmd/proxy/proxy_remote_test.go new file mode 100644 index 0000000..0b3f41b --- /dev/null +++ b/cmd/proxy/proxy_remote_test.go @@ -0,0 +1,216 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package main + +import ( + "context" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/proxy" +) + +func (c *RemoteConnection) WaitForConnection(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + // Only used in tests, so a busy-loop should be fine. + for c.conn == nil || c.connectedSince.IsZero() || !c.helloReceived { + if err := ctx.Err(); err != nil { + return err + } + + c.mu.Unlock() + time.Sleep(time.Nanosecond) + c.mu.Lock() + } + + return nil +} + +func (c *RemoteConnection) WaitForDisconnect(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + initial := c.conn + if initial == nil { + return nil + } + + // Only used in tests, so a busy-loop should be fine. + for c.conn == initial { + if err := ctx.Err(); err != nil { + return err + } + + c.mu.Unlock() + time.Sleep(time.Nanosecond) + c.mu.Lock() + } + return nil +} + +func Test_ProxyRemoteConnectionReconnect(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + server, key, httpserver := newProxyServerForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + conn, err := NewRemoteConnection(server, httpserver.URL, TokenIdForTest, key, nil) + require.NoError(err) + t.Cleanup(func() { + assert.NoError(conn.SendBye()) + assert.NoError(conn.Close()) + }) + + assert.NoError(conn.WaitForConnection(ctx)) + + // Closing the connection will reconnect automatically + conn.mu.Lock() + c := conn.conn + conn.mu.Unlock() + assert.NoError(c.Close()) + assert.NoError(conn.WaitForDisconnect(ctx)) + assert.NoError(conn.WaitForConnection(ctx)) +} + +func Test_ProxyRemoteConnectionReconnectUnknownSession(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + server, key, httpserver := newProxyServerForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + conn, err := NewRemoteConnection(server, httpserver.URL, TokenIdForTest, key, nil) + require.NoError(err) + t.Cleanup(func() { + assert.NoError(conn.SendBye()) + assert.NoError(conn.Close()) + }) + + assert.NoError(conn.WaitForConnection(ctx)) + + // Closing the connection will reconnect automatically + conn.mu.Lock() + c := conn.conn + sessionId := conn.sessionId + conn.mu.Unlock() + var sid uint64 + server.IterateSessions(func(session *ProxySession) { + if session.PublicId() == sessionId { + sid = session.Sid() + } + }) + require.NotEqualValues(0, sid) + server.DeleteSession(sid) + if err := c.Close(); err != nil { + // If an error occurs while closing, it may only be "use of closed network + // connection" because the "DeleteSession" might have already closed the + // socket. + assert.ErrorIs(err, net.ErrClosed) + } + assert.NoError(conn.WaitForDisconnect(ctx)) + assert.NoError(conn.WaitForConnection(ctx)) + assert.NotEqual(sessionId, conn.SessionId()) +} + +func Test_ProxyRemoteConnectionReconnectExpiredSession(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + server, key, httpserver := newProxyServerForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + conn, err := NewRemoteConnection(server, httpserver.URL, TokenIdForTest, key, nil) + require.NoError(err) + t.Cleanup(func() { + assert.NoError(conn.SendBye()) + assert.NoError(conn.Close()) + }) + + assert.NoError(conn.WaitForConnection(ctx)) + + // Closing the connection will reconnect automatically + conn.mu.Lock() + sessionId := conn.sessionId + conn.mu.Unlock() + var session *ProxySession + server.IterateSessions(func(sess *ProxySession) { + if sess.PublicId() == sessionId { + session = sess + } + }) + require.NotNil(session) + session.Close() + assert.NoError(conn.WaitForDisconnect(ctx)) + assert.NoError(conn.WaitForConnection(ctx)) + assert.NotEqual(sessionId, conn.SessionId()) +} + +func Test_ProxyRemoteConnectionCreatePublisher(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + server, key, httpserver := newProxyServerForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + conn, err := NewRemoteConnection(server, httpserver.URL, TokenIdForTest, key, nil) + require.NoError(err) + t.Cleanup(func() { + assert.NoError(conn.SendBye()) + assert.NoError(conn.Close()) + }) + + publisherId := "the-publisher" + hostname := "the-hostname" + port := 1234 + rtcpPort := 2345 + + _, err = conn.RequestMessage(ctx, &proxy.ClientMessage{ + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "publish-remote", + ClientId: publisherId, + Hostname: hostname, + Port: port, + RtcpPort: rtcpPort, + }, + }) + assert.ErrorContains(err, UnknownClient.Error()) +} diff --git a/proxy/proxy_server.go b/cmd/proxy/proxy_server.go similarity index 58% rename from proxy/proxy_server.go rename to cmd/proxy/proxy_server.go index b256d09..99d42b0 100644 --- a/proxy/proxy_server.go +++ b/cmd/proxy/proxy_server.go @@ -30,7 +30,6 @@ import ( "errors" "fmt" "io" - "log" "net" "net/http" "net/http/pprof" @@ -47,11 +46,21 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" "github.com/gorilla/websocket" - "github.com/notedit/janus-go" "github.com/prometheus/client_golang/prometheus/promhttp" - "google.golang.org/protobuf/types/known/timestamppb" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async" + "github.com/strukturag/nextcloud-spreed-signaling/v2/client" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/container" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/proxy" + "github.com/strukturag/nextcloud-spreed-signaling/v2/session" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus" + janusapi "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/janus" ) const ( @@ -80,6 +89,8 @@ const ( ) var ( + InvalidFormat = client.InvalidFormat + defaultProxyFeatures = []string{ ProxyFeatureRemoteStreams, } @@ -90,36 +101,42 @@ type ContextKey string var ( ContextKeySession = ContextKey("session") - TimeoutCreatingPublisher = signaling.NewError("timeout", "Timeout creating publisher.") - TimeoutCreatingSubscriber = signaling.NewError("timeout", "Timeout creating subscriber.") - TokenAuthFailed = signaling.NewError("auth_failed", "The token could not be authenticated.") - TokenExpired = signaling.NewError("token_expired", "The token is expired.") - TokenNotValidYet = signaling.NewError("token_not_valid_yet", "The token is not valid yet.") - UnknownClient = signaling.NewError("unknown_client", "Unknown client id given.") - UnsupportedCommand = signaling.NewError("bad_request", "Unsupported command received.") - UnsupportedMessage = signaling.NewError("bad_request", "Unsupported message received.") - UnsupportedPayload = signaling.NewError("unsupported_payload", "Unsupported payload type.") - ShutdownScheduled = signaling.NewError("shutdown_scheduled", "The server is scheduled to shutdown.") - RemoteSubscribersNotSupported = signaling.NewError("unsupported_subscriber", "Remote subscribers are not supported.") + // HelloExpected is returned if a client sends a message before the "hello" request. + HelloExpected = api.NewError("hello_expected", "Expected Hello request.") + // NoSuchSession is returned if the session to be resumed is unknown or expired. + NoSuchSession = api.NewError("no_such_session", "The session to resume does not exist.") + + TimeoutCreatingPublisher = api.NewError("timeout", "Timeout creating publisher.") + TimeoutCreatingSubscriber = api.NewError("timeout", "Timeout creating subscriber.") + TokenAuthFailed = api.NewError("auth_failed", "The token could not be authenticated.") + TokenExpired = api.NewError("token_expired", "The token is expired.") + TokenNotValidYet = api.NewError("token_not_valid_yet", "The token is not valid yet.") + UnknownClient = api.NewError("unknown_client", "Unknown client id given.") + UnsupportedCommand = api.NewError("bad_request", "Unsupported command received.") + UnsupportedMessage = api.NewError("bad_request", "Unsupported message received.") + UnsupportedPayload = api.NewError("unsupported_payload", "Unsupported payload type.") + ShutdownScheduled = api.NewError("shutdown_scheduled", "The server is scheduled to shutdown.") + RemoteSubscribersNotSupported = api.NewError("unsupported_subscriber", "Remote subscribers are not supported.") ) type ProxyServer struct { version string - country string + country geoip.Country welcomeMessage string - welcomeMsg *signaling.WelcomeServerMessage + welcomeMsg *api.WelcomeServerMessage config *goconf.ConfigFile mcuTimeout time.Duration + logger log.Logger url string - mcu signaling.Mcu + mcu sfu.SFU stopped atomic.Bool - load atomic.Int64 + load atomic.Uint64 - maxIncoming atomic.Int64 - currentIncoming atomic.Int64 - maxOutgoing atomic.Int64 - currentOutgoing atomic.Int64 + maxIncoming api.AtomicBandwidth + currentIncoming api.AtomicBandwidth + maxOutgoing api.AtomicBandwidth + currentOutgoing api.AtomicBandwidth shutdownChannel chan struct{} shutdownScheduled atomic.Bool @@ -127,31 +144,37 @@ type ProxyServer struct { upgrader websocket.Upgrader tokens ProxyTokens - statsAllowedIps atomic.Pointer[signaling.AllowedIps] - trustedProxies atomic.Pointer[signaling.AllowedIps] + statsAllowedIps atomic.Pointer[container.IPList] + trustedProxies atomic.Pointer[container.IPList] sid atomic.Uint64 - cookie *signaling.SessionIdCodec - sessions map[uint64]*ProxySession + cookie *session.SessionIdCodec sessionsLock sync.RWMutex + // +checklocks:sessionsLock + sessions map[uint64]*ProxySession - clients map[string]signaling.McuClient - clientIds map[string]string clientsLock sync.RWMutex + // +checklocks:clientsLock + clients map[string]sfu.Client + // +checklocks:clientsLock + clientIds map[string]string tokenId string tokenKey *rsa.PrivateKey - remoteTlsConfig *tls.Config + remoteTlsConfig *tls.Config // +checklocksignore: Only written to from constructor. remoteHostname string - remoteConnections map[string]*RemoteConnection remoteConnectionsLock sync.Mutex + // +checklocks:remoteConnectionsLock + remoteConnections map[string]*RemoteConnection + // +checklocks:remoteConnectionsLock + remotePublishers map[string]map[*proxyRemotePublisher]bool } -func IsPublicIP(IP net.IP) bool { - if IP.IsLoopback() || IP.IsLinkLocalMulticast() || IP.IsLinkLocalUnicast() { +func IsPublicIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() { return false } - if ip4 := IP.To4(); ip4 != nil { + if ip4 := ip.To4(); ip4 != nil { switch { case ip4[0] == 10: return false @@ -182,42 +205,50 @@ func GetLocalIP() (string, error) { return "", nil } -func getTargetBandwidths(config *goconf.ConfigFile) (int, int) { - maxIncoming, _ := config.GetInt("bandwidth", "incoming") - if maxIncoming < 0 { - maxIncoming = 0 +func getTargetBandwidths(logger log.Logger, config *goconf.ConfigFile) (api.Bandwidth, api.Bandwidth) { + maxIncomingValue, _ := config.GetInt("bandwidth", "incoming") + if maxIncomingValue < 0 { + maxIncomingValue = 0 } + maxIncoming := api.BandwidthFromMegabits(uint64(maxIncomingValue)) if maxIncoming > 0 { - log.Printf("Target bandwidth for incoming streams: %d MBit/s", maxIncoming) + logger.Printf("Target bandwidth for incoming streams: %s", maxIncoming) } else { - log.Printf("Target bandwidth for incoming streams: unlimited") + logger.Printf("Target bandwidth for incoming streams: unlimited") } - maxOutgoing, _ := config.GetInt("bandwidth", "outgoing") - if maxOutgoing < 0 { - maxOutgoing = 0 + + maxOutgoingValue, _ := config.GetInt("bandwidth", "outgoing") + if maxOutgoingValue < 0 { + maxOutgoingValue = 0 } - if maxIncoming > 0 { - log.Printf("Target bandwidth for outgoing streams: %d MBit/s", maxOutgoing) + maxOutgoing := api.BandwidthFromMegabits(uint64(maxOutgoingValue)) + if maxOutgoing > 0 { + logger.Printf("Target bandwidth for outgoing streams: %s", maxOutgoing) } else { - log.Printf("Target bandwidth for outgoing streams: unlimited") + logger.Printf("Target bandwidth for outgoing streams: unlimited") } return maxIncoming, maxOutgoing } -func NewProxyServer(r *mux.Router, version string, config *goconf.ConfigFile) (*ProxyServer, error) { +func NewProxyServer(ctx context.Context, r *mux.Router, version string, config *goconf.ConfigFile) (*ProxyServer, error) { + logger := log.LoggerFromContext(ctx) hashKey := make([]byte, 64) if _, err := rand.Read(hashKey); err != nil { - return nil, fmt.Errorf("Could not generate random hash key: %s", err) + return nil, fmt.Errorf("could not generate random hash key: %s", err) } blockKey := make([]byte, 32) if _, err := rand.Read(blockKey); err != nil { - return nil, fmt.Errorf("Could not generate random block key: %s", err) + return nil, fmt.Errorf("could not generate random block key: %s", err) + } + + sessionIds, err := session.NewSessionIdCodec(hashKey, blockKey) + if err != nil { + return nil, fmt.Errorf("error creating session id codec: %w", err) } var tokens ProxyTokens - var err error tokenType, _ := config.GetString("app", "tokentype") if tokenType == "" { tokenType = TokenTypeDefault @@ -225,61 +256,31 @@ func NewProxyServer(r *mux.Router, version string, config *goconf.ConfigFile) (* switch tokenType { case TokenTypeEtcd: - tokens, err = NewProxyTokensEtcd(config) + tokens, err = NewProxyTokensEtcd(logger, config) case TokenTypeStatic: - tokens, err = NewProxyTokensStatic(config) + tokens, err = NewProxyTokensStatic(logger, config) default: - return nil, fmt.Errorf("Unsupported token type configured: %s", tokenType) + return nil, fmt.Errorf("unsupported token type configured: %s", tokenType) } if err != nil { return nil, err } - statsAllowed, _ := config.GetString("stats", "allowed_ips") - statsAllowedIps, err := signaling.ParseAllowedIps(statsAllowed) - if err != nil { - return nil, err - } - - if !statsAllowedIps.Empty() { - log.Printf("Only allowing access to the stats endpoint from %s", statsAllowed) - } else { - log.Printf("No IPs configured for the stats endpoint, only allowing access from 127.0.0.1") - statsAllowedIps = signaling.DefaultAllowedIps() - } - - trustedProxies, _ := config.GetString("app", "trustedproxies") - trustedProxiesIps, err := signaling.ParseAllowedIps(trustedProxies) - if err != nil { - return nil, err - } - - if !trustedProxiesIps.Empty() { - log.Printf("Trusted proxies: %s", trustedProxiesIps) - } else { - trustedProxiesIps = signaling.DefaultTrustedProxies - log.Printf("No trusted proxies configured, only allowing for %s", trustedProxiesIps) - } - - country, _ := config.GetString("app", "country") - country = strings.ToUpper(country) - if signaling.IsValidCountry(country) { - log.Printf("Sending %s as country information", country) + countryString, _ := config.GetString("app", "country") + country := geoip.Country(strings.ToUpper(countryString)) + if geoip.IsValidCountry(country) { + logger.Printf("Sending %s as country information", country) } else if country != "" { - return nil, fmt.Errorf("Invalid country: %s", country) + return nil, fmt.Errorf("invalid country: %s", country) } else { - log.Printf("Not sending country information") + logger.Printf("Not sending country information") } welcome := map[string]string{ "nextcloud-spreed-signaling-proxy": "Welcome", "version": version, } - welcomeMessage, err := json.Marshal(welcome) - if err != nil { - // Should never happen. - return nil, err - } + welcomeMessage, _ := json.Marshal(welcome) tokenId, _ := config.GetString("app", "token_id") var tokenKey *rsa.PrivateKey @@ -288,17 +289,17 @@ func NewProxyServer(r *mux.Router, version string, config *goconf.ConfigFile) (* if tokenId != "" { tokenKeyFilename, _ := config.GetString("app", "token_key") if tokenKeyFilename == "" { - return nil, fmt.Errorf("No token key configured") + return nil, errors.New("no token key configured") } tokenKeyData, err := os.ReadFile(tokenKeyFilename) if err != nil { - return nil, fmt.Errorf("Could not read private key from %s: %s", tokenKeyFilename, err) + return nil, fmt.Errorf("could not read private key from %s: %s", tokenKeyFilename, err) } tokenKey, err = jwt.ParseRSAPrivateKeyFromPEM(tokenKeyData) if err != nil { - return nil, fmt.Errorf("Could not parse private key from %s: %s", tokenKeyFilename, err) + return nil, fmt.Errorf("could not parse private key from %s: %s", tokenKeyFilename, err) } - log.Printf("Using \"%s\" as token id for remote streams", tokenId) + logger.Printf("Using \"%s\" as token id for remote streams", tokenId) remoteHostname, _ = config.GetString("app", "hostname") if remoteHostname == "" { @@ -308,24 +309,22 @@ func NewProxyServer(r *mux.Router, version string, config *goconf.ConfigFile) (* } } if remoteHostname == "" { - log.Printf("WARNING: Could not determine hostname for remote streams, will be disabled. Please configure manually.") + logger.Printf("WARNING: Could not determine hostname for remote streams, will be disabled. Please configure manually.") } else { - log.Printf("Using \"%s\" as hostname for remote streams", remoteHostname) + logger.Printf("Using \"%s\" as hostname for remote streams", remoteHostname) } skipverify, _ := config.GetBool("backend", "skipverify") if skipverify { - log.Println("WARNING: Remote stream requests verification is disabled!") + logger.Println("WARNING: Remote stream requests verification is disabled!") remoteTlsConfig = &tls.Config{ InsecureSkipVerify: skipverify, } } } else { - log.Printf("No token id configured, remote streams will be disabled") + logger.Printf("No token id configured, remote streams will be disabled") } - maxIncoming, maxOutgoing := getTargetBandwidths(config) - mcuTimeoutSeconds, _ := config.GetInt("mcu", "timeout") if mcuTimeoutSeconds <= 0 { mcuTimeoutSeconds = defaultMcuTimeoutSeconds @@ -336,27 +335,31 @@ func NewProxyServer(r *mux.Router, version string, config *goconf.ConfigFile) (* version: version, country: country, welcomeMessage: string(welcomeMessage) + "\n", - welcomeMsg: &signaling.WelcomeServerMessage{ + welcomeMsg: &api.WelcomeServerMessage{ Version: version, Country: country, Features: defaultProxyFeatures, }, config: config, mcuTimeout: mcuTimeout, + logger: logger, shutdownChannel: make(chan struct{}), upgrader: websocket.Upgrader{ ReadBufferSize: websocketReadBufferSize, WriteBufferSize: websocketWriteBufferSize, + Subprotocols: []string{ + janus.EventsSubprotocol, + }, }, tokens: tokens, - cookie: signaling.NewSessionIdCodec(hashKey, blockKey), + cookie: sessionIds, sessions: make(map[uint64]*ProxySession), - clients: make(map[string]signaling.McuClient), + clients: make(map[string]sfu.Client), clientIds: make(map[string]string), tokenId: tokenId, @@ -364,24 +367,34 @@ func NewProxyServer(r *mux.Router, version string, config *goconf.ConfigFile) (* remoteTlsConfig: remoteTlsConfig, remoteHostname: remoteHostname, remoteConnections: make(map[string]*RemoteConnection), + remotePublishers: make(map[string]map[*proxyRemotePublisher]bool), + } + + if err := result.loadConfig(config, false); err != nil { + return nil, err } - result.maxIncoming.Store(int64(maxIncoming) * 1024 * 1024) - result.maxOutgoing.Store(int64(maxOutgoing) * 1024 * 1024) - result.statsAllowedIps.Store(statsAllowedIps) - result.trustedProxies.Store(trustedProxiesIps) result.upgrader.CheckOrigin = result.checkOrigin + statsLoadCurrent.Set(0) + if debug, _ := config.GetBool("app", "debug"); debug { - log.Println("Installing debug handlers in \"/debug/pprof\"") - r.Handle("/debug/pprof/", http.HandlerFunc(pprof.Index)) - r.Handle("/debug/pprof/cmdline", http.HandlerFunc(pprof.Cmdline)) - r.Handle("/debug/pprof/profile", http.HandlerFunc(pprof.Profile)) - r.Handle("/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol)) - r.Handle("/debug/pprof/trace", http.HandlerFunc(pprof.Trace)) + logger.Println("Installing debug handlers in \"/debug/pprof\"") + s := r.PathPrefix("/debug/pprof").Subrouter() + s.HandleFunc("", result.setCommonHeaders(result.validateStatsRequest(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/debug/pprof/", http.StatusTemporaryRedirect) + }))) + s.HandleFunc("/", result.setCommonHeaders(result.validateStatsRequest(pprof.Index))) + s.HandleFunc("/cmdline", result.setCommonHeaders(result.validateStatsRequest(pprof.Cmdline))) + s.HandleFunc("/profile", result.setCommonHeaders(result.validateStatsRequest(pprof.Profile))) + s.HandleFunc("/symbol", result.setCommonHeaders(result.validateStatsRequest(pprof.Symbol))) + s.HandleFunc("/trace", result.setCommonHeaders(result.validateStatsRequest(pprof.Trace))) for _, profile := range runtimepprof.Profiles() { name := profile.Name() - r.Handle("/debug/pprof/"+name, pprof.Handler(name)) + handler := pprof.Handler(name) + s.HandleFunc("/"+name, result.setCommonHeaders(result.validateStatsRequest(func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(w, r) + }))) } } @@ -397,18 +410,18 @@ func (s *ProxyServer) checkOrigin(r *http.Request) bool { return true } -func (s *ProxyServer) Start(config *goconf.ConfigFile) error { - s.url, _ = signaling.GetStringOptionWithEnv(config, "mcu", "url") +func (s *ProxyServer) Start(cfg *goconf.ConfigFile) error { + s.url, _ = config.GetStringOptionWithEnv(cfg, "mcu", "url") if s.url == "" { - return fmt.Errorf("No MCU server url configured") + return errors.New("no MCU server url configured") } - mcuType, _ := config.GetString("mcu", "type") + mcuType, _ := cfg.GetString("mcu", "type") if mcuType == "" { - mcuType = signaling.McuTypeDefault + mcuType = sfu.TypeDefault } - backoff, err := signaling.NewExponentialBackoff(initialMcuRetry, maxMcuRetry) + backoff, err := async.NewExponentialBackoff(initialMcuRetry, maxMcuRetry) if err != nil { return err } @@ -416,33 +429,33 @@ func (s *ProxyServer) Start(config *goconf.ConfigFile) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - var mcu signaling.Mcu + var mcu sfu.SFU for { switch mcuType { - case signaling.McuTypeJanus: - mcu, err = signaling.NewMcuJanus(ctx, s.url, config) + case sfu.TypeJanus: + mcu, err = janus.NewJanusSFU(ctx, s.url, cfg) if err == nil { - signaling.RegisterJanusMcuStats() + janus.RegisterStats() } default: - return fmt.Errorf("Unsupported MCU type: %s", mcuType) + return fmt.Errorf("unsupported MCU type: %s", mcuType) } if err == nil { mcu.SetOnConnected(s.onMcuConnected) mcu.SetOnDisconnected(s.onMcuDisconnected) err = mcu.Start(ctx) if err != nil { - log.Printf("Could not create %s MCU at %s: %s", mcuType, s.url, err) + s.logger.Printf("Could not create %s MCU at %s: %s", mcuType, s.url, err) } } if err == nil { break } - log.Printf("Could not initialize %s MCU at %s (%s) will retry in %s", mcuType, s.url, err, backoff.NextWait()) + s.logger.Printf("Could not initialize %s MCU at %s (%s) will retry in %s", mcuType, s.url, err, backoff.NextWait()) backoff.Wait(ctx) if ctx.Err() != nil { - return fmt.Errorf("Cancelled") + return errors.New("cancelled") } } @@ -473,18 +486,21 @@ loop: } } -func (s *ProxyServer) newLoadEvent(load int64, incoming int64, outgoing int64) *signaling.ProxyServerMessage { - msg := &signaling.ProxyServerMessage{ +func (s *ProxyServer) newLoadEvent(load uint64, incoming api.Bandwidth, outgoing api.Bandwidth) *proxy.ServerMessage { + msg := &proxy.ServerMessage{ Type: "event", - Event: &signaling.EventProxyServerMessage{ + Event: &proxy.EventServerMessage{ Type: "update-load", Load: load, }, } maxIncoming := s.maxIncoming.Load() maxOutgoing := s.maxOutgoing.Load() - if maxIncoming > 0 || maxOutgoing > 0 { - msg.Event.Bandwidth = &signaling.EventProxyServerBandwidth{} + if maxIncoming > 0 || maxOutgoing > 0 || incoming != 0 || outgoing != 0 { + msg.Event.Bandwidth = &proxy.EventServerBandwidth{ + Received: incoming, + Sent: outgoing, + } if maxIncoming > 0 { value := float64(incoming) / float64(maxIncoming) * 100 msg.Event.Bandwidth.Incoming = &value @@ -506,10 +522,11 @@ func (s *ProxyServer) updateLoad() { return } + statsLoadCurrent.Set(float64(load)) s.sendLoadToAll(load, incoming, outgoing) } -func (s *ProxyServer) sendLoadToAll(load int64, incoming int64, outgoing int64) { +func (s *ProxyServer) sendLoadToAll(load uint64, incoming api.Bandwidth, outgoing api.Bandwidth) { if s.shutdownScheduled.Load() { // Server is scheduled to shutdown, no need to update clients with current load. return @@ -545,7 +562,7 @@ func (s *ProxyServer) expireSessions() { continue } - log.Printf("Delete expired session %s", session.PublicId()) + s.logger.Printf("Delete expired session %s", session.PublicId()) s.deleteSessionLocked(session.Sid()) } } @@ -570,9 +587,9 @@ func (s *ProxyServer) ScheduleShutdown() { return } - msg := &signaling.ProxyServerMessage{ + msg := &proxy.ServerMessage{ Type: "event", - Event: &signaling.EventProxyServerMessage{ + Event: &proxy.EventServerMessage{ Type: "shutdown-scheduled", }, } @@ -585,41 +602,53 @@ func (s *ProxyServer) ScheduleShutdown() { } } -func (s *ProxyServer) Reload(config *goconf.ConfigFile) { +func (s *ProxyServer) loadConfig(config *goconf.ConfigFile, fromReload bool) error { statsAllowed, _ := config.GetString("stats", "allowed_ips") - if statsAllowedIps, err := signaling.ParseAllowedIps(statsAllowed); err == nil { + if statsAllowedIps, err := container.ParseIPList(statsAllowed); err == nil { if !statsAllowedIps.Empty() { - log.Printf("Only allowing access to the stats endpoint from %s", statsAllowed) + s.logger.Printf("Only allowing access to the stats endpoint from %s", statsAllowed) } else { - log.Printf("No IPs configured for the stats endpoint, only allowing access from 127.0.0.1") - statsAllowedIps = signaling.DefaultAllowedIps() + statsAllowedIps = container.DefaultAllowedIPs() + s.logger.Printf("No IPs configured for the stats endpoint, only allowing access from %s", statsAllowedIps) } s.statsAllowedIps.Store(statsAllowedIps) + } else if fromReload { + s.logger.Printf("Error parsing allowed stats ips from \"%s\": %s", statsAllowedIps, err) } else { - log.Printf("Error parsing allowed stats ips from \"%s\": %s", statsAllowedIps, err) + return fmt.Errorf("error parsing allowed stats ips from \"%s\": %w", statsAllowedIps, err) } trustedProxies, _ := config.GetString("app", "trustedproxies") - if trustedProxiesIps, err := signaling.ParseAllowedIps(trustedProxies); err == nil { + if trustedProxiesIps, err := container.ParseIPList(trustedProxies); err == nil { if !trustedProxiesIps.Empty() { - log.Printf("Trusted proxies: %s", trustedProxiesIps) + s.logger.Printf("Trusted proxies: %s", trustedProxiesIps) } else { - trustedProxiesIps = signaling.DefaultTrustedProxies - log.Printf("No trusted proxies configured, only allowing for %s", trustedProxiesIps) + trustedProxiesIps = client.DefaultTrustedProxies + s.logger.Printf("No trusted proxies configured, only allowing for %s", trustedProxiesIps) } s.trustedProxies.Store(trustedProxiesIps) + } else if fromReload { + s.logger.Printf("Error parsing trusted proxies from \"%s\": %s", trustedProxies, err) } else { - log.Printf("Error parsing trusted proxies from \"%s\": %s", trustedProxies, err) + return fmt.Errorf("error parsing trusted proxies ips from \"%s\": %w", trustedProxies, err) } - maxIncoming, maxOutgoing := getTargetBandwidths(config) - oldIncoming := s.maxIncoming.Swap(int64(maxIncoming)) - oldOutgoing := s.maxOutgoing.Swap(int64(maxOutgoing)) - if oldIncoming != int64(maxIncoming) || oldOutgoing != int64(maxOutgoing) { + maxIncoming, maxOutgoing := getTargetBandwidths(s.logger, config) + oldIncoming := s.maxIncoming.Swap(maxIncoming) + oldOutgoing := s.maxOutgoing.Swap(maxOutgoing) + if fromReload && (oldIncoming != maxIncoming || oldOutgoing != maxOutgoing) { // Notify sessions about updated load / bandwidth usage. go s.sendLoadToAll(s.load.Load(), s.currentIncoming.Load(), s.currentOutgoing.Load()) } + return nil +} + +func (s *ProxyServer) Reload(config *goconf.ConfigFile) { + if err := s.loadConfig(config, true); err != nil { + s.logger.Printf("Error reloading configuration: %s", err) + } + s.tokens.Reload(config) s.mcu.Reload(config) } @@ -627,6 +656,7 @@ func (s *ProxyServer) Reload(config *goconf.ConfigFile) { func (s *ProxyServer) setCommonHeaders(f func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Server", "nextcloud-spreed-signaling-proxy/"+s.version) + w.Header().Set("X-Spreed-Signaling-Features", strings.Join(s.welcomeMsg.Features, ", ")) f(w, r) } } @@ -638,19 +668,26 @@ func (s *ProxyServer) welcomeHandler(w http.ResponseWriter, r *http.Request) { } func (s *ProxyServer) proxyHandler(w http.ResponseWriter, r *http.Request) { - addr := signaling.GetRealUserIP(r, s.trustedProxies.Load()) + addr := client.GetRealUserIP(r, s.trustedProxies.Load()) header := http.Header{} header.Set("Server", "nextcloud-spreed-signaling-proxy/"+s.version) header.Set("X-Spreed-Signaling-Features", strings.Join(s.welcomeMsg.Features, ", ")) conn, err := s.upgrader.Upgrade(w, r, header) if err != nil { - log.Printf("Could not upgrade request from %s: %s", addr, err) + s.logger.Printf("Could not upgrade request from %s: %s", addr, err) return } - client, err := NewProxyClient(r.Context(), s, conn, addr) + agent := r.Header.Get("User-Agent") + ctx := log.NewLoggerContext(r.Context(), s.logger) + if conn.Subprotocol() == janus.EventsSubprotocol { + janus.RunEventsHandler(ctx, s.mcu, conn, addr, agent) + return + } + + client, err := NewProxyClient(ctx, s, conn, addr, agent) if err != nil { - log.Printf("Could not create client for %s: %s", addr, err) + s.logger.Printf("Could not create client for %s: %s", addr, err) return } @@ -658,15 +695,15 @@ func (s *ProxyServer) proxyHandler(w http.ResponseWriter, r *http.Request) { client.ReadPump() } -func (s *ProxyServer) clientClosed(client *signaling.Client) { - log.Printf("Connection from %s closed", client.RemoteAddr()) +func (s *ProxyServer) clientClosed(client *ProxyClient) { + s.logger.Printf("Connection from %s closed", client.RemoteAddr()) } func (s *ProxyServer) onMcuConnected() { - log.Printf("Connection to %s established", s.url) - msg := &signaling.ProxyServerMessage{ + s.logger.Printf("Connection to %s established", s.url) + msg := &proxy.ServerMessage{ Type: "event", - Event: &signaling.EventProxyServerMessage{ + Event: &proxy.EventServerMessage{ Type: "backend-connected", }, } @@ -682,10 +719,10 @@ func (s *ProxyServer) onMcuDisconnected() { return } - log.Printf("Connection to %s lost", s.url) - msg := &signaling.ProxyServerMessage{ + s.logger.Printf("Connection to %s lost", s.url) + msg := &proxy.ServerMessage{ Type: "event", - Event: &signaling.EventProxyServerMessage{ + Event: &proxy.EventServerMessage{ Type: "backend-disconnected", }, } @@ -702,9 +739,9 @@ func (s *ProxyServer) sendCurrentLoad(session *ProxySession) { } func (s *ProxyServer) sendShutdownScheduled(session *ProxySession) { - msg := &signaling.ProxyServerMessage{ + msg := &proxy.ServerMessage{ Type: "event", - Event: &signaling.EventProxyServerMessage{ + Event: &proxy.EventServerMessage{ Type: "shutdown-scheduled", }, } @@ -713,48 +750,48 @@ func (s *ProxyServer) sendShutdownScheduled(session *ProxySession) { func (s *ProxyServer) processMessage(client *ProxyClient, data []byte) { if proxyDebugMessages { - log.Printf("Message: %s", string(data)) + s.logger.Printf("Message: %s", string(data)) } - var message signaling.ProxyClientMessage + var message proxy.ClientMessage if err := message.UnmarshalJSON(data); err != nil { if session := client.GetSession(); session != nil { - log.Printf("Error decoding message from client %s: %v", session.PublicId(), err) + s.logger.Printf("Error decoding message from client %s: %v", session.PublicId(), err) } else { - log.Printf("Error decoding message from %s: %v", client.RemoteAddr(), err) + s.logger.Printf("Error decoding message from %s: %v", client.RemoteAddr(), err) } - client.SendError(signaling.InvalidFormat) + client.SendError(InvalidFormat) return } if err := message.CheckValid(); err != nil { if session := client.GetSession(); session != nil { - log.Printf("Invalid message %+v from client %s: %v", message, session.PublicId(), err) + s.logger.Printf("Invalid message %+v from client %s: %v", message, session.PublicId(), err) } else { - log.Printf("Invalid message %+v from %s: %v", message, client.RemoteAddr(), err) + s.logger.Printf("Invalid message %+v from %s: %v", message, client.RemoteAddr(), err) } - client.SendMessage(message.NewErrorServerMessage(signaling.InvalidFormat)) + client.SendMessage(message.NewErrorServerMessage(InvalidFormat)) return } session := client.GetSession() if session == nil { if message.Type != "hello" { - client.SendMessage(message.NewErrorServerMessage(signaling.HelloExpected)) + client.SendMessage(message.NewErrorServerMessage(HelloExpected)) return } var session *ProxySession - if resumeId := message.Hello.ResumeId; resumeId != "" { + if resumeId := api.PublicSessionId(message.Hello.ResumeId); resumeId != "" { if data, err := s.cookie.DecodePublic(resumeId); err == nil { session = s.GetSession(data.Sid) } if session == nil || resumeId != session.PublicId() { - client.SendMessage(message.NewErrorServerMessage(signaling.NoSuchSession)) + client.SendMessage(message.NewErrorServerMessage(NoSuchSession)) return } - log.Printf("Resumed session %s", session.PublicId()) + s.logger.Printf("Resumed session %s", session.PublicId()) session.MarkUsed() if s.shutdownScheduled.Load() { s.sendShutdownScheduled(session) @@ -765,7 +802,7 @@ func (s *ProxyServer) processMessage(client *ProxyClient, data []byte) { } else { var err error if session, err = s.NewSession(message.Hello); err != nil { - if e, ok := err.(*signaling.Error); ok { + if e, ok := err.(*api.Error); ok { client.SendMessage(message.NewErrorServerMessage(e)) } else { client.SendMessage(message.NewWrappedErrorServerMessage(err)) @@ -776,19 +813,19 @@ func (s *ProxyServer) processMessage(client *ProxyClient, data []byte) { prev := session.SetClient(client) if prev != nil { - msg := &signaling.ProxyServerMessage{ + msg := &proxy.ServerMessage{ Type: "bye", - Bye: &signaling.ByeProxyServerMessage{ + Bye: &proxy.ByeServerMessage{ Reason: "session_resumed", }, } prev.SendMessage(msg) } - response := &signaling.ProxyServerMessage{ + response := &proxy.ServerMessage{ Id: message.Id, Type: "hello", - Hello: &signaling.HelloProxyServerMessage{ - Version: signaling.HelloVersionV1, + Hello: &proxy.HelloServerMessage{ + Version: api.HelloVersionV1, SessionId: session.PublicId(), Server: s.welcomeMsg, }, @@ -819,7 +856,7 @@ func (s *ProxyServer) processMessage(client *ProxyClient, data []byte) { type emptyInitiator struct{} -func (i *emptyInitiator) Country() string { +func (i *emptyInitiator) Country() geoip.Country { return "" } @@ -827,24 +864,24 @@ type proxyRemotePublisher struct { proxy *ProxyServer remoteUrl string - publisherId string + publisherId api.PublicSessionId } -func (p *proxyRemotePublisher) PublisherId() string { +func (p *proxyRemotePublisher) PublisherId() api.PublicSessionId { return p.publisherId } -func (p *proxyRemotePublisher) StartPublishing(ctx context.Context, publisher signaling.McuRemotePublisherProperties) error { +func (p *proxyRemotePublisher) StartPublishing(ctx context.Context, publisher sfu.RemotePublisherProperties) error { conn, err := p.proxy.getRemoteConnection(p.remoteUrl) if err != nil { return err } - if _, err := conn.RequestMessage(ctx, &signaling.ProxyClientMessage{ + if _, err := conn.RequestMessage(ctx, &proxy.ClientMessage{ Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "publish-remote", - ClientId: p.publisherId, + ClientId: string(p.publisherId), Hostname: p.proxy.remoteHostname, Port: publisher.Port(), RtcpPort: publisher.RtcpPort(), @@ -856,17 +893,19 @@ func (p *proxyRemotePublisher) StartPublishing(ctx context.Context, publisher si return nil } -func (p *proxyRemotePublisher) StopPublishing(ctx context.Context, publisher signaling.McuRemotePublisherProperties) error { +func (p *proxyRemotePublisher) StopPublishing(ctx context.Context, publisher sfu.RemotePublisherProperties) error { + defer p.proxy.removeRemotePublisher(p) + conn, err := p.proxy.getRemoteConnection(p.remoteUrl) if err != nil { return err } - if _, err := conn.RequestMessage(ctx, &signaling.ProxyClientMessage{ + if _, err := conn.RequestMessage(ctx, &proxy.ClientMessage{ Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "unpublish-remote", - ClientId: p.publisherId, + ClientId: string(p.publisherId), Hostname: p.proxy.remoteHostname, Port: publisher.Port(), RtcpPort: publisher.RtcpPort(), @@ -878,17 +917,17 @@ func (p *proxyRemotePublisher) StopPublishing(ctx context.Context, publisher sig return nil } -func (p *proxyRemotePublisher) GetStreams(ctx context.Context) ([]signaling.PublisherStream, error) { +func (p *proxyRemotePublisher) GetStreams(ctx context.Context) ([]sfu.PublisherStream, error) { conn, err := p.proxy.getRemoteConnection(p.remoteUrl) if err != nil { return nil, err } - response, err := conn.RequestMessage(ctx, &signaling.ProxyClientMessage{ + response, err := conn.RequestMessage(ctx, &proxy.ClientMessage{ Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "get-publisher-streams", - ClientId: p.publisherId, + ClientId: string(p.publisherId), }, }) if err != nil { @@ -898,7 +937,54 @@ func (p *proxyRemotePublisher) GetStreams(ctx context.Context) ([]signaling.Publ return response.Command.Streams, nil } -func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, session *ProxySession, message *signaling.ProxyClientMessage) { +func (s *ProxyServer) addRemotePublisher(publisher *proxyRemotePublisher) { + s.remoteConnectionsLock.Lock() + defer s.remoteConnectionsLock.Unlock() + + publishers, found := s.remotePublishers[publisher.remoteUrl] + if !found { + publishers = make(map[*proxyRemotePublisher]bool) + s.remotePublishers[publisher.remoteUrl] = publishers + } + + publishers[publisher] = true + s.logger.Printf("Add remote publisher to %s", publisher.remoteUrl) +} + +func (s *ProxyServer) hasRemotePublishers() bool { + s.remoteConnectionsLock.Lock() + defer s.remoteConnectionsLock.Unlock() + + return len(s.remotePublishers) > 0 +} + +func (s *ProxyServer) removeRemotePublisher(publisher *proxyRemotePublisher) { + s.remoteConnectionsLock.Lock() + defer s.remoteConnectionsLock.Unlock() + + s.logger.Printf("Removing remote publisher to %s", publisher.remoteUrl) + publishers, found := s.remotePublishers[publisher.remoteUrl] + if !found { + return + } + + delete(publishers, publisher) + if len(publishers) > 0 { + return + } + + delete(s.remotePublishers, publisher.remoteUrl) + if conn, found := s.remoteConnections[publisher.remoteUrl]; found { + delete(s.remoteConnections, publisher.remoteUrl) + if err := conn.Close(); err != nil { + s.logger.Printf("Error closing remote connection to %s: %s", publisher.remoteUrl, err) + } else { + s.logger.Printf("Remote connection to %s closed", publisher.remoteUrl) + } + } +} + +func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, session *ProxySession, message *proxy.ClientMessage) { cmd := message.Command statsCommandMessagesTotal.WithLabelValues(cmd.Type).Inc() @@ -916,32 +1002,32 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s id := uuid.New().String() settings := cmd.PublisherSettings if settings == nil { - settings = &signaling.NewPublisherSettings{ + settings = &sfu.NewPublisherSettings{ Bitrate: cmd.Bitrate, // nolint MediaTypes: cmd.MediaTypes, // nolint } } - publisher, err := s.mcu.NewPublisher(ctx2, session, id, cmd.Sid, cmd.StreamType, *settings, &emptyInitiator{}) + publisher, err := s.mcu.NewPublisher(ctx2, session, api.PublicSessionId(id), cmd.Sid, cmd.StreamType, *settings, &emptyInitiator{}) if err == context.DeadlineExceeded { - log.Printf("Timeout while creating %s publisher %s for %s", cmd.StreamType, id, session.PublicId()) + s.logger.Printf("Timeout while creating %s publisher %s for %s", cmd.StreamType, id, session.PublicId()) session.sendMessage(message.NewErrorServerMessage(TimeoutCreatingPublisher)) return } else if err != nil { - log.Printf("Error while creating %s publisher %s for %s: %s", cmd.StreamType, id, session.PublicId(), err) + s.logger.Printf("Error while creating %s publisher %s for %s: %s", cmd.StreamType, id, session.PublicId(), err) session.sendMessage(message.NewWrappedErrorServerMessage(err)) return } - log.Printf("Created %s publisher %s as %s for %s", cmd.StreamType, publisher.Id(), id, session.PublicId()) + s.logger.Printf("Created %s publisher %s as %s for %s", cmd.StreamType, publisher.Id(), id, session.PublicId()) session.StorePublisher(ctx, id, publisher) s.StoreClient(id, publisher) - response := &signaling.ProxyServerMessage{ + response := &proxy.ServerMessage{ Id: message.Id, Type: "command", - Command: &signaling.CommandProxyServerMessage{ + Command: &proxy.CommandServerMessage{ Id: id, - Bitrate: int(publisher.MaxBitrate()), + Bitrate: publisher.MaxBitrate(), }, } session.sendMessage(response) @@ -950,20 +1036,20 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s case "create-subscriber": id := uuid.New().String() publisherId := cmd.PublisherId - var subscriber signaling.McuSubscriber + var subscriber sfu.Subscriber var err error handleCreateError := func(err error) { if err == context.DeadlineExceeded { - log.Printf("Timeout while creating %s subscriber on %s for %s", cmd.StreamType, publisherId, session.PublicId()) + s.logger.Printf("Timeout while creating %s subscriber on %s for %s", cmd.StreamType, publisherId, session.PublicId()) session.sendMessage(message.NewErrorServerMessage(TimeoutCreatingSubscriber)) return - } else if errors.Is(err, signaling.ErrRemoteStreamsNotSupported) { + } else if errors.Is(err, janus.ErrRemoteStreamsNotSupported) { session.sendMessage(message.NewErrorServerMessage(RemoteSubscribersNotSupported)) return } - log.Printf("Error while creating %s subscriber on %s for %s: %s", cmd.StreamType, publisherId, session.PublicId(), err) + s.logger.Printf("Error while creating %s subscriber on %s for %s: %s", cmd.StreamType, publisherId, session.PublicId(), err) session.sendMessage(message.NewWrappedErrorServerMessage(err)) } @@ -973,7 +1059,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s return } - remoteMcu, ok := s.mcu.(signaling.RemoteMcu) + remoteMcu, ok := s.mcu.(sfu.RemoteSfu) if !ok { session.sendMessage(message.NewErrorServerMessage(RemoteSubscribersNotSupported)) return @@ -981,7 +1067,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s claims, _, err := s.parseToken(cmd.RemoteToken) if err != nil { - if e, ok := err.(*signaling.Error); ok { + if e, ok := err.(*api.Error); ok { client.SendMessage(message.NewErrorServerMessage(e)) } else { client.SendMessage(message.NewWrappedErrorServerMessage(err)) @@ -989,7 +1075,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s return } - if claims.Subject != publisherId { + if claims.Subject != string(publisherId) { session.sendMessage(message.NewErrorServerMessage(TokenAuthFailed)) return } @@ -997,7 +1083,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s subCtx, cancel := context.WithTimeout(ctx, remotePublisherTimeout) defer cancel() - log.Printf("Creating remote subscriber for %s on %s", publisherId, cmd.RemoteUrl) + s.logger.Printf("Creating remote subscriber for %s on %s", publisherId, cmd.RemoteUrl) controller := &proxyRemotePublisher{ proxy: s, @@ -1005,7 +1091,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s publisherId: publisherId, } - var publisher signaling.McuRemotePublisher + var publisher sfu.RemotePublisher publisher, err = remoteMcu.NewRemotePublisher(subCtx, session, controller, cmd.StreamType) if err != nil { handleCreateError(err) @@ -1016,13 +1102,15 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s go publisher.Close(context.Background()) }() + s.addRemotePublisher(controller) + subscriber, err = remoteMcu.NewRemoteSubscriber(subCtx, session, publisher) if err != nil { handleCreateError(err) return } - log.Printf("Created remote %s subscriber %s as %s for %s on %s", cmd.StreamType, subscriber.Id(), id, session.PublicId(), cmd.RemoteUrl) + s.logger.Printf("Created remote %s subscriber %s as %s for %s on %s", cmd.StreamType, subscriber.Id(), id, session.PublicId(), cmd.RemoteUrl) } else { ctx2, cancel := context.WithTimeout(ctx, s.mcuTimeout) defer cancel() @@ -1033,16 +1121,16 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s return } - log.Printf("Created %s subscriber %s as %s for %s", cmd.StreamType, subscriber.Id(), id, session.PublicId()) + s.logger.Printf("Created %s subscriber %s as %s for %s", cmd.StreamType, subscriber.Id(), id, session.PublicId()) } session.StoreSubscriber(ctx, id, subscriber) s.StoreClient(id, subscriber) - response := &signaling.ProxyServerMessage{ + response := &proxy.ServerMessage{ Id: message.Id, Type: "command", - Command: &signaling.CommandProxyServerMessage{ + Command: &proxy.CommandServerMessage{ Id: id, Sid: subscriber.Sid(), }, @@ -1057,7 +1145,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s return } - publisher, ok := client.(signaling.McuPublisher) + publisher, ok := client.(sfu.Publisher) if !ok { session.sendMessage(message.NewErrorServerMessage(UnknownClient)) return @@ -1073,14 +1161,14 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s } go func() { - log.Printf("Closing %s publisher %s as %s", client.StreamType(), client.Id(), cmd.ClientId) + s.logger.Printf("Closing %s publisher %s as %s", client.StreamType(), client.Id(), cmd.ClientId) client.Close(context.Background()) }() - response := &signaling.ProxyServerMessage{ + response := &proxy.ServerMessage{ Id: message.Id, Type: "command", - Command: &signaling.CommandProxyServerMessage{ + Command: &proxy.CommandServerMessage{ Id: cmd.ClientId, }, } @@ -1092,7 +1180,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s return } - subscriber, ok := client.(signaling.McuSubscriber) + subscriber, ok := client.(sfu.Subscriber) if !ok { session.sendMessage(message.NewErrorServerMessage(UnknownClient)) return @@ -1108,14 +1196,14 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s } go func() { - log.Printf("Closing %s subscriber %s as %s", client.StreamType(), client.Id(), cmd.ClientId) + s.logger.Printf("Closing %s subscriber %s as %s", client.StreamType(), client.Id(), cmd.ClientId) client.Close(context.Background()) }() - response := &signaling.ProxyServerMessage{ + response := &proxy.ServerMessage{ Id: message.Id, Type: "command", - Command: &signaling.CommandProxyServerMessage{ + Command: &proxy.CommandServerMessage{ Id: cmd.ClientId, }, } @@ -1127,7 +1215,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s return } - publisher, ok := client.(signaling.McuPublisher) + publisher, ok := client.(sfu.RemoteAwarePublisher) if !ok { session.sendMessage(message.NewErrorServerMessage(UnknownClient)) return @@ -1137,9 +1225,8 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s defer cancel() if err := publisher.PublishRemote(ctx2, session.PublicId(), cmd.Hostname, cmd.Port, cmd.RtcpPort); err != nil { - var je *janus.ErrorMsg - if !errors.As(err, &je) || je.Err.Code != signaling.JANUS_VIDEOROOM_ERROR_ID_EXISTS { - log.Printf("Error publishing %s %s to remote %s (port=%d, rtcpPort=%d): %s", publisher.StreamType(), cmd.ClientId, cmd.Hostname, cmd.Port, cmd.RtcpPort, err) + if je, ok := internal.AsErrorType[*janusapi.ErrorMsg](err); !ok || je.Err.Code != janusapi.JANUS_VIDEOROOM_ERROR_ID_EXISTS { + s.logger.Printf("Error publishing %s %s to remote %s (port=%d, rtcpPort=%d): %s", publisher.StreamType(), cmd.ClientId, cmd.Hostname, cmd.Port, cmd.RtcpPort, err) session.sendMessage(message.NewWrappedErrorServerMessage(err)) return } @@ -1148,7 +1235,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s defer cancel() if err := publisher.UnpublishRemote(ctx2, session.PublicId(), cmd.Hostname, cmd.Port, cmd.RtcpPort); err != nil { - log.Printf("Error unpublishing old %s %s to remote %s (port=%d, rtcpPort=%d): %s", publisher.StreamType(), cmd.ClientId, cmd.Hostname, cmd.Port, cmd.RtcpPort, err) + s.logger.Printf("Error unpublishing old %s %s to remote %s (port=%d, rtcpPort=%d): %s", publisher.StreamType(), cmd.ClientId, cmd.Hostname, cmd.Port, cmd.RtcpPort, err) session.sendMessage(message.NewWrappedErrorServerMessage(err)) return } @@ -1157,17 +1244,17 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s defer cancel() if err := publisher.PublishRemote(ctx2, session.PublicId(), cmd.Hostname, cmd.Port, cmd.RtcpPort); err != nil { - log.Printf("Error publishing %s %s to remote %s (port=%d, rtcpPort=%d): %s", publisher.StreamType(), cmd.ClientId, cmd.Hostname, cmd.Port, cmd.RtcpPort, err) + s.logger.Printf("Error publishing %s %s to remote %s (port=%d, rtcpPort=%d): %s", publisher.StreamType(), cmd.ClientId, cmd.Hostname, cmd.Port, cmd.RtcpPort, err) session.sendMessage(message.NewWrappedErrorServerMessage(err)) return } } session.AddRemotePublisher(publisher, cmd.Hostname, cmd.Port, cmd.RtcpPort) - response := &signaling.ProxyServerMessage{ + response := &proxy.ServerMessage{ Id: message.Id, Type: "command", - Command: &signaling.CommandProxyServerMessage{ + Command: &proxy.CommandServerMessage{ Id: cmd.ClientId, }, } @@ -1179,7 +1266,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s return } - publisher, ok := client.(signaling.McuPublisher) + publisher, ok := client.(sfu.RemoteAwarePublisher) if !ok { session.sendMessage(message.NewErrorServerMessage(UnknownClient)) return @@ -1189,17 +1276,17 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s defer cancel() if err := publisher.UnpublishRemote(ctx2, session.PublicId(), cmd.Hostname, cmd.Port, cmd.RtcpPort); err != nil { - log.Printf("Error unpublishing %s %s from remote %s: %s", publisher.StreamType(), cmd.ClientId, cmd.Hostname, err) + s.logger.Printf("Error unpublishing %s %s from remote %s: %s", publisher.StreamType(), cmd.ClientId, cmd.Hostname, err) session.sendMessage(message.NewWrappedErrorServerMessage(err)) return } session.RemoveRemotePublisher(publisher, cmd.Hostname, cmd.Port, cmd.RtcpPort) - response := &signaling.ProxyServerMessage{ + response := &proxy.ServerMessage{ Id: message.Id, Type: "command", - Command: &signaling.CommandProxyServerMessage{ + Command: &proxy.CommandServerMessage{ Id: cmd.ClientId, }, } @@ -1211,7 +1298,7 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s return } - publisher, ok := client.(signaling.McuPublisher) + publisher, ok := client.(sfu.PublisherWithStreams) if !ok { session.sendMessage(message.NewErrorServerMessage(UnknownClient)) return @@ -1219,27 +1306,27 @@ func (s *ProxyServer) processCommand(ctx context.Context, client *ProxyClient, s streams, err := publisher.GetStreams(ctx) if err != nil { - log.Printf("Could not get streams of publisher %s: %s", publisher.Id(), err) + s.logger.Printf("Could not get streams of publisher %s: %s", publisher.Id(), err) session.sendMessage(message.NewWrappedErrorServerMessage(err)) return } - response := &signaling.ProxyServerMessage{ + response := &proxy.ServerMessage{ Id: message.Id, Type: "command", - Command: &signaling.CommandProxyServerMessage{ + Command: &proxy.CommandServerMessage{ Id: cmd.ClientId, Streams: streams, }, } session.sendMessage(response) default: - log.Printf("Unsupported command %+v", message.Command) + s.logger.Printf("Unsupported command %+v", message.Command) session.sendMessage(message.NewErrorServerMessage(UnsupportedCommand)) } } -func (s *ProxyServer) processPayload(ctx context.Context, client *ProxyClient, session *ProxySession, message *signaling.ProxyClientMessage) { +func (s *ProxyServer) processPayload(ctx context.Context, client *ProxyClient, session *ProxySession, message *proxy.ClientMessage) { payload := message.Payload mcuClient := s.GetClient(payload.ClientId) if mcuClient == nil { @@ -1249,7 +1336,7 @@ func (s *ProxyServer) processPayload(ctx context.Context, client *ProxyClient, s statsPayloadMessagesTotal.WithLabelValues(payload.Type).Inc() - var mcuData *signaling.MessageClientMessageData + var mcuData *api.MessageClientMessageData switch payload.Type { case "offer": fallthrough @@ -1258,7 +1345,7 @@ func (s *ProxyServer) processPayload(ctx context.Context, client *ProxyClient, s case "selectStream": fallthrough case "candidate": - mcuData = &signaling.MessageClientMessageData{ + mcuData = &api.MessageClientMessageData{ RoomType: string(mcuClient.StreamType()), Type: payload.Type, Sid: payload.Sid, @@ -1266,10 +1353,10 @@ func (s *ProxyServer) processPayload(ctx context.Context, client *ProxyClient, s } case "endOfCandidates": // Ignore but confirm, not passed along to Janus anyway. - session.sendMessage(&signaling.ProxyServerMessage{ + session.sendMessage(&proxy.ServerMessage{ Id: message.Id, Type: "payload", - Payload: &signaling.PayloadProxyServerMessage{ + Payload: &proxy.PayloadServerMessage{ Type: payload.Type, ClientId: payload.ClientId, }, @@ -1278,7 +1365,7 @@ func (s *ProxyServer) processPayload(ctx context.Context, client *ProxyClient, s case "requestoffer": fallthrough case "sendoffer": - mcuData = &signaling.MessageClientMessageData{ + mcuData = &api.MessageClientMessageData{ RoomType: string(mcuClient.StreamType()), Type: payload.Type, Sid: payload.Sid, @@ -1289,7 +1376,7 @@ func (s *ProxyServer) processPayload(ctx context.Context, client *ProxyClient, s } if err := mcuData.CheckValid(); err != nil { - log.Printf("Received invalid payload %+v for %s client %s: %s", mcuData, mcuClient.StreamType(), payload.ClientId, err) + s.logger.Printf("Received invalid payload %+v for %s client %s: %s", mcuData, mcuClient.StreamType(), payload.ClientId, err) session.sendMessage(message.NewErrorServerMessage(UnsupportedPayload)) return } @@ -1297,16 +1384,21 @@ func (s *ProxyServer) processPayload(ctx context.Context, client *ProxyClient, s ctx2, cancel := context.WithTimeout(ctx, s.mcuTimeout) defer cancel() - mcuClient.SendMessage(ctx2, nil, mcuData, func(err error, response map[string]interface{}) { - var responseMsg *signaling.ProxyServerMessage + mcuClient.SendMessage(ctx2, nil, mcuData, func(err error, response api.StringMap) { + var responseMsg *proxy.ServerMessage + if errors.Is(err, api.ErrCandidateFiltered) { + // Silently ignore filtered candidates. + err = nil + } + if err != nil { - log.Printf("Error sending %+v to %s client %s: %s", mcuData, mcuClient.StreamType(), payload.ClientId, err) + s.logger.Printf("Error sending %+v to %s client %s: %s", mcuData, mcuClient.StreamType(), payload.ClientId, err) responseMsg = message.NewWrappedErrorServerMessage(err) } else { - responseMsg = &signaling.ProxyServerMessage{ + responseMsg = &proxy.ServerMessage{ Id: message.Id, Type: "payload", - Payload: &signaling.PayloadProxyServerMessage{ + Payload: &proxy.PayloadServerMessage{ Type: payload.Type, ClientId: payload.ClientId, Payload: response, @@ -1318,39 +1410,39 @@ func (s *ProxyServer) processPayload(ctx context.Context, client *ProxyClient, s }) } -func (s *ProxyServer) processBye(ctx context.Context, client *ProxyClient, session *ProxySession, message *signaling.ProxyClientMessage) { - log.Printf("Closing session %s", session.PublicId()) +func (s *ProxyServer) processBye(ctx context.Context, client *ProxyClient, session *ProxySession, message *proxy.ClientMessage) { + s.logger.Printf("Closing session %s", session.PublicId()) s.DeleteSession(session.Sid()) } -func (s *ProxyServer) parseToken(tokenValue string) (*signaling.TokenClaims, string, error) { +func (s *ProxyServer) parseToken(tokenValue string) (*proxy.TokenClaims, string, error) { reason := "auth-failed" - token, err := jwt.ParseWithClaims(tokenValue, &signaling.TokenClaims{}, func(token *jwt.Token) (interface{}, error) { + token, err := jwt.ParseWithClaims(tokenValue, &proxy.TokenClaims{}, func(token *jwt.Token) (any, error) { // Don't forget to validate the alg is what you expect: if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { - log.Printf("Unexpected signing method: %v", token.Header["alg"]) + s.logger.Printf("Unexpected signing method: %v", token.Header["alg"]) reason = "unsupported-signing-method" - return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - claims, ok := token.Claims.(*signaling.TokenClaims) + claims, ok := token.Claims.(*proxy.TokenClaims) if !ok { - log.Printf("Unsupported claims type: %+v", token.Claims) + s.logger.Printf("Unsupported claims type: %+v", token.Claims) reason = "unsupported-claims" - return nil, fmt.Errorf("Unsupported claims type") + return nil, errors.New("unsupported claims type") } tokenKey, err := s.tokens.Get(claims.Issuer) if err != nil { - log.Printf("Could not get token for %s: %s", claims.Issuer, err) + s.logger.Printf("Could not get token for %s: %s", claims.Issuer, err) reason = "missing-issuer" return nil, err } if tokenKey == nil || tokenKey.key == nil { - log.Printf("Issuer %s is not supported", claims.Issuer) + s.logger.Printf("Issuer %s is not supported", claims.Issuer) reason = "unsupported-issuer" - return nil, fmt.Errorf("No key found for issuer") + return nil, errors.New("no key found for issuer") } return tokenKey.key, nil @@ -1369,7 +1461,7 @@ func (s *ProxyServer) parseToken(tokenValue string) (*signaling.TokenClaims, str return nil, reason, TokenAuthFailed } - claims, ok := token.Claims.(*signaling.TokenClaims) + claims, ok := token.Claims.(*proxy.TokenClaims) if !ok || !token.Valid { return nil, "auth-failed", TokenAuthFailed } @@ -1383,9 +1475,9 @@ func (s *ProxyServer) parseToken(tokenValue string) (*signaling.TokenClaims, str return claims, "", nil } -func (s *ProxyServer) NewSession(hello *signaling.HelloProxyClientMessage) (*ProxySession, error) { +func (s *ProxyServer) NewSession(hello *proxy.HelloClientMessage) (*ProxySession, error) { if proxyDebugMessages { - log.Printf("Hello: %+v", hello) + s.logger.Printf("Hello: %+v", hello) } claims, reason, err := s.parseToken(hello.Token) @@ -1399,9 +1491,9 @@ func (s *ProxyServer) NewSession(hello *signaling.HelloProxyClientMessage) (*Pro sid = s.sid.Add(1) } - sessionIdData := &signaling.SessionIdData{ + sessionIdData := &session.SessionIdData{ Sid: sid, - Created: timestamppb.Now(), + Created: time.Now().UnixMicro(), } encoded, err := s.cookie.EncodePublic(sessionIdData) @@ -1409,7 +1501,7 @@ func (s *ProxyServer) NewSession(hello *signaling.HelloProxyClientMessage) (*Pro return nil, err } - log.Printf("Created session %s for %+v", encoded, claims) + s.logger.Printf("Created session %s for %+v", encoded, claims) session := NewProxySession(s, sid, encoded) s.StoreSession(sid, session) statsSessionsCurrent.Inc() @@ -1450,6 +1542,7 @@ func (s *ProxyServer) DeleteSession(id uint64) { s.deleteSessionLocked(id) } +// +checklocks:s.sessionsLock func (s *ProxyServer) deleteSessionLocked(id uint64) { if session, found := s.sessions[id]; found { delete(s.sessions, id) @@ -1460,14 +1553,14 @@ func (s *ProxyServer) deleteSessionLocked(id uint64) { } } -func (s *ProxyServer) StoreClient(id string, client signaling.McuClient) { +func (s *ProxyServer) StoreClient(id string, client sfu.Client) { s.clientsLock.Lock() defer s.clientsLock.Unlock() s.clients[id] = client s.clientIds[client.Id()] = id } -func (s *ProxyServer) DeleteClient(id string, client signaling.McuClient) bool { +func (s *ProxyServer) DeleteClient(id string, client sfu.Client) bool { s.clientsLock.Lock() defer s.clientsLock.Unlock() if _, found := s.clients[id]; !found { @@ -1489,34 +1582,43 @@ func (s *ProxyServer) HasClients() bool { return len(s.clients) > 0 } -func (s *ProxyServer) GetClientsLoad() (load int64, incoming int64, outgoing int64) { +func (s *ProxyServer) GetClientsLoad() (load uint64, incoming api.Bandwidth, outgoing api.Bandwidth) { s.clientsLock.RLock() defer s.clientsLock.RUnlock() for _, c := range s.clients { - bitrate := int64(c.MaxBitrate()) - load += bitrate - if _, ok := c.(signaling.McuPublisher); ok { + // Use "current" bandwidth usage if supported. + if bw, ok := c.(sfu.ClientWithBandwidth); ok { + if bandwidth := bw.Bandwidth(); bandwidth != nil { + incoming += bandwidth.Received + outgoing += bandwidth.Sent + continue + } + } + + bitrate := c.MaxBitrate() + if _, ok := c.(sfu.Publisher); ok { incoming += bitrate - } else if _, ok := c.(signaling.McuSubscriber); ok { + } else if _, ok := c.(sfu.Subscriber); ok { outgoing += bitrate } } - load = load / 1024 + load = incoming.Bits() + outgoing.Bits() + load = min(uint64(len(s.clients)), load/1024) return } -func (s *ProxyServer) GetClient(id string) signaling.McuClient { +func (s *ProxyServer) GetClient(id string) sfu.Client { s.clientsLock.RLock() defer s.clientsLock.RUnlock() return s.clients[id] } -func (s *ProxyServer) GetPublisher(publisherId string) signaling.McuPublisher { +func (s *ProxyServer) GetPublisher(publisherId string) sfu.Publisher { s.clientsLock.RLock() defer s.clientsLock.RUnlock() for _, c := range s.clients { - pub, ok := c.(signaling.McuPublisher) + pub, ok := c.(sfu.Publisher) if !ok { continue } @@ -1528,14 +1630,14 @@ func (s *ProxyServer) GetPublisher(publisherId string) signaling.McuPublisher { return nil } -func (s *ProxyServer) GetClientId(client signaling.McuClient) string { +func (s *ProxyServer) GetClientId(client sfu.Client) string { s.clientsLock.RLock() defer s.clientsLock.RUnlock() return s.clientIds[client.Id()] } -func (s *ProxyServer) getStats() map[string]interface{} { - result := map[string]interface{}{ +func (s *ProxyServer) getStats() api.StringMap { + result := api.StringMap{ "sessions": s.GetSessionsCount(), "load": s.load.Load(), "mcu": s.mcu.GetStats(), @@ -1544,14 +1646,14 @@ func (s *ProxyServer) getStats() map[string]interface{} { } func (s *ProxyServer) allowStatsAccess(r *http.Request) bool { - addr := signaling.GetRealUserIP(r, s.trustedProxies.Load()) + addr := client.GetRealUserIP(r, s.trustedProxies.Load()) ip := net.ParseIP(addr) if len(ip) == 0 { return false } allowed := s.statsAllowedIps.Load() - return allowed != nil && allowed.Allowed(ip) + return allowed != nil && allowed.Contains(ip) } func (s *ProxyServer) validateStatsRequest(f func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { @@ -1569,7 +1671,7 @@ func (s *ProxyServer) statsHandler(w http.ResponseWriter, r *http.Request) { stats := s.getStats() statsData, err := json.MarshalIndent(stats, "", " ") if err != nil { - log.Printf("Could not serialize stats %+v: %s", stats, err) + s.logger.Printf("Could not serialize stats %+v: %s", stats, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -1594,7 +1696,7 @@ func (s *ProxyServer) getRemoteConnection(url string) (*RemoteConnection, error) return conn, nil } - conn, err := NewRemoteConnection(url, s.tokenId, s.tokenKey, s.remoteTlsConfig) + conn, err := NewRemoteConnection(s, url, s.tokenId, s.tokenKey, s.remoteTlsConfig) if err != nil { return nil, err } @@ -1603,7 +1705,7 @@ func (s *ProxyServer) getRemoteConnection(url string) (*RemoteConnection, error) return conn, nil } -func (s *ProxyServer) PublisherDeleted(publisher signaling.McuPublisher) { +func (s *ProxyServer) PublisherDeleted(publisher sfu.Publisher) { s.sessionsLock.RLock() defer s.sessionsLock.RUnlock() @@ -1611,3 +1713,12 @@ func (s *ProxyServer) PublisherDeleted(publisher signaling.McuPublisher) { session.OnPublisherDeleted(publisher) } } + +func (s *ProxyServer) RemotePublisherDeleted(publisherId api.PublicSessionId) { + s.sessionsLock.RLock() + defer s.sessionsLock.RUnlock() + + for _, session := range s.sessions { + session.OnRemotePublisherDeleted(publisherId) + } +} diff --git a/proxy/proxy_server_test.go b/cmd/proxy/proxy_server_test.go similarity index 50% rename from proxy/proxy_server_test.go rename to cmd/proxy/proxy_server_test.go index 973b6dc..305e9f3 100644 --- a/proxy/proxy_server_test.go +++ b/cmd/proxy/proxy_server_test.go @@ -36,6 +36,7 @@ import ( "sync" "sync/atomic" "testing" + "testing/synctest" "time" "github.com/dlintw/goconf" @@ -44,7 +45,15 @@ import ( "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/mock" + "github.com/strukturag/nextcloud-spreed-signaling/v2/proxy" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) const ( @@ -55,10 +64,10 @@ const ( ) func getWebsocketUrl(url string) string { - if strings.HasPrefix(url, "http://") { - return "ws://" + url[7:] + "/proxy" - } else if strings.HasPrefix(url, "https://") { - return "wss://" + url[8:] + "/proxy" + if url, found := strings.CutPrefix(url, "http://"); found { + return "ws://" + url + "/proxy" + } else if url, found := strings.CutPrefix(url, "https://"); found { + return "wss://" + url + "/proxy" } else { panic("Unsupported URL: " + url) } @@ -88,7 +97,7 @@ func WaitForProxyServer(ctx context.Context, t *testing.T, proxy *ProxyServer) { case <-ctx.Done(): proxy.clientsLock.Lock() proxy.remoteConnectionsLock.Lock() - assert.Fail(t, fmt.Sprintf("Error waiting for clients %+v / sessions %+v / remoteConnections %+v to terminate: %+v", proxy.clients, proxy.sessions, proxy.remoteConnections, ctx.Err())) + assert.Fail(t, "Error waiting for proxy to terminate", "clients %+v / sessions %+v / remoteConnections %+v: %+v", clients, sessions, remoteConnections, ctx.Err()) proxy.remoteConnectionsLock.Unlock() proxy.clientsLock.Unlock() return @@ -137,7 +146,9 @@ func newProxyServerForTest(t *testing.T) (*ProxyServer, *rsa.PrivateKey, *httpte config := goconf.NewConfigFile() config.AddOption("tokens", TokenIdForTest, pubkey.Name()) - proxy, err = NewProxyServer(r, "0.0", config) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + proxy, err = NewProxyServer(ctx, r, "0.0", config) require.NoError(err) server := httptest.NewServer(r) @@ -149,10 +160,10 @@ func newProxyServerForTest(t *testing.T) (*ProxyServer, *rsa.PrivateKey, *httpte } func TestTokenValid(t *testing.T) { - signaling.CatchLogForTest(t) - proxy, key, _ := newProxyServerForTest(t) + t.Parallel() + proxyServer, key, _ := newProxyServerForTest(t) - claims := &signaling.TokenClaims{ + claims := &proxy.TokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now().Add(-maxTokenAge / 2)), Issuer: TokenIdForTest, @@ -162,20 +173,20 @@ func TestTokenValid(t *testing.T) { tokenString, err := token.SignedString(key) require.NoError(t, err) - hello := &signaling.HelloProxyClientMessage{ + hello := &proxy.HelloClientMessage{ Version: "1.0", Token: tokenString, } - if session, err := proxy.NewSession(hello); assert.NoError(t, err) { - defer proxy.DeleteSession(session.Sid()) + if session, err := proxyServer.NewSession(hello); assert.NoError(t, err) { + defer proxyServer.DeleteSession(session.Sid()) } } func TestTokenNotSigned(t *testing.T) { - signaling.CatchLogForTest(t) - proxy, _, _ := newProxyServerForTest(t) + t.Parallel() + proxyServer, _, _ := newProxyServerForTest(t) - claims := &signaling.TokenClaims{ + claims := &proxy.TokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now().Add(-maxTokenAge / 2)), Issuer: TokenIdForTest, @@ -185,22 +196,22 @@ func TestTokenNotSigned(t *testing.T) { tokenString, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType) require.NoError(t, err) - hello := &signaling.HelloProxyClientMessage{ + hello := &proxy.HelloClientMessage{ Version: "1.0", Token: tokenString, } - if session, err := proxy.NewSession(hello); !assert.ErrorIs(t, err, TokenAuthFailed) { + if session, err := proxyServer.NewSession(hello); !assert.ErrorIs(t, err, TokenAuthFailed) { if session != nil { - defer proxy.DeleteSession(session.Sid()) + defer proxyServer.DeleteSession(session.Sid()) } } } func TestTokenUnknown(t *testing.T) { - signaling.CatchLogForTest(t) - proxy, key, _ := newProxyServerForTest(t) + t.Parallel() + proxyServer, key, _ := newProxyServerForTest(t) - claims := &signaling.TokenClaims{ + claims := &proxy.TokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now().Add(-maxTokenAge / 2)), Issuer: TokenIdForTest + "2", @@ -210,22 +221,22 @@ func TestTokenUnknown(t *testing.T) { tokenString, err := token.SignedString(key) require.NoError(t, err) - hello := &signaling.HelloProxyClientMessage{ + hello := &proxy.HelloClientMessage{ Version: "1.0", Token: tokenString, } - if session, err := proxy.NewSession(hello); !assert.ErrorIs(t, err, TokenAuthFailed) { + if session, err := proxyServer.NewSession(hello); !assert.ErrorIs(t, err, TokenAuthFailed) { if session != nil { - defer proxy.DeleteSession(session.Sid()) + defer proxyServer.DeleteSession(session.Sid()) } } } func TestTokenInFuture(t *testing.T) { - signaling.CatchLogForTest(t) - proxy, key, _ := newProxyServerForTest(t) + t.Parallel() + proxyServer, key, _ := newProxyServerForTest(t) - claims := &signaling.TokenClaims{ + claims := &proxy.TokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), Issuer: TokenIdForTest, @@ -235,22 +246,22 @@ func TestTokenInFuture(t *testing.T) { tokenString, err := token.SignedString(key) require.NoError(t, err) - hello := &signaling.HelloProxyClientMessage{ + hello := &proxy.HelloClientMessage{ Version: "1.0", Token: tokenString, } - if session, err := proxy.NewSession(hello); !assert.ErrorIs(t, err, TokenNotValidYet) { + if session, err := proxyServer.NewSession(hello); !assert.ErrorIs(t, err, TokenNotValidYet) { if session != nil { - defer proxy.DeleteSession(session.Sid()) + defer proxyServer.DeleteSession(session.Sid()) } } } func TestTokenExpired(t *testing.T) { - signaling.CatchLogForTest(t) - proxy, key, _ := newProxyServerForTest(t) + t.Parallel() + proxyServer, key, _ := newProxyServerForTest(t) - claims := &signaling.TokenClaims{ + claims := &proxy.TokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now().Add(-maxTokenAge * 2)), Issuer: TokenIdForTest, @@ -260,18 +271,19 @@ func TestTokenExpired(t *testing.T) { tokenString, err := token.SignedString(key) require.NoError(t, err) - hello := &signaling.HelloProxyClientMessage{ + hello := &proxy.HelloClientMessage{ Version: "1.0", Token: tokenString, } - if session, err := proxy.NewSession(hello); !assert.ErrorIs(t, err, TokenExpired) { + if session, err := proxyServer.NewSession(hello); !assert.ErrorIs(t, err, TokenExpired) { if session != nil { - defer proxy.DeleteSession(session.Sid()) + defer proxyServer.DeleteSession(session.Sid()) } } } func TestPublicIPs(t *testing.T) { + t.Parallel() assert := assert.New(t) public := []string{ "8.8.8.8", @@ -304,7 +316,7 @@ func TestPublicIPs(t *testing.T) { } func TestWebsocketFeatures(t *testing.T) { - signaling.CatchLogForTest(t) + t.Parallel() assert := assert.New(t) _, _, server := newProxyServerForTest(t) @@ -313,29 +325,26 @@ func TestWebsocketFeatures(t *testing.T) { defer conn.Close() // nolint if server := response.Header.Get("Server"); !strings.HasPrefix(server, "nextcloud-spreed-signaling-proxy/") { - assert.Fail("expected valid server header, got \"%s\"", server) + assert.Fail("expected valid server header", "received \"%s\"", server) } features := response.Header.Get("X-Spreed-Signaling-Features") featuresList := make(map[string]bool) - for _, f := range strings.Split(features, ",") { - f = strings.TrimSpace(f) - if f != "" { - if _, found := featuresList[f]; found { - assert.Fail("duplicate feature id \"%s\" in \"%s\"", f, features) - } - featuresList[f] = true + for f := range internal.SplitEntries(features, ",") { + if _, found := featuresList[f]; found { + assert.Fail("duplicate feature", "id \"%s\" in \"%s\"", f, features) } + featuresList[f] = true } assert.NotEmpty(featuresList, "expected valid features header, got \"%s\"", features) if _, found := featuresList["remote-streams"]; !found { - assert.Fail("expected feature \"remote-streams\", got \"%s\"", features) + assert.Fail("expected feature \"remote-streams\"", "received \"%s\"", features) } assert.NoError(conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Time{})) } func TestProxyCreateSession(t *testing.T) { - signaling.CatchLogForTest(t) + t.Parallel() assert := assert.New(t) require := require.New(t) _, key, server := newProxyServerForTest(t) @@ -360,6 +369,10 @@ type TestMCU struct { t *testing.T } +func (m *TestMCU) GetBandwidthLimits() (api.Bandwidth, api.Bandwidth) { + return 0, 0 +} + func (m *TestMCU) Start(ctx context.Context) error { return nil } @@ -376,25 +389,33 @@ func (m *TestMCU) SetOnConnected(f func()) { func (m *TestMCU) SetOnDisconnected(f func()) { } -func (m *TestMCU) GetStats() interface{} { +func (m *TestMCU) GetStats() any { return nil } -func (m *TestMCU) NewPublisher(ctx context.Context, listener signaling.McuListener, id string, sid string, streamType signaling.StreamType, settings signaling.NewPublisherSettings, initiator signaling.McuInitiator) (signaling.McuPublisher, error) { +func (m *TestMCU) GetServerInfoSfu() *talk.BackendServerInfoSfu { + return nil +} + +func (m *TestMCU) NewPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings, initiator sfu.Initiator) (sfu.Publisher, error) { return nil, errors.New("not implemented") } -func (m *TestMCU) NewSubscriber(ctx context.Context, listener signaling.McuListener, publisher string, streamType signaling.StreamType, initiator signaling.McuInitiator) (signaling.McuSubscriber, error) { +func (m *TestMCU) NewSubscriber(ctx context.Context, listener sfu.Listener, publisher api.PublicSessionId, streamType sfu.StreamType, initiator sfu.Initiator) (sfu.Subscriber, error) { return nil, errors.New("not implemented") } type TestMCUPublisher struct { - id string + id api.PublicSessionId sid string - streamType signaling.StreamType + streamType sfu.StreamType } func (p *TestMCUPublisher) Id() string { + return string(p.id) +} + +func (p *TestMCUPublisher) PublisherId() api.PublicSessionId { return p.id } @@ -402,40 +423,231 @@ func (p *TestMCUPublisher) Sid() string { return p.sid } -func (p *TestMCUPublisher) StreamType() signaling.StreamType { +func (p *TestMCUPublisher) StreamType() sfu.StreamType { return p.streamType } -func (p *TestMCUPublisher) MaxBitrate() int { +func (p *TestMCUPublisher) MaxBitrate() api.Bandwidth { return 0 } func (p *TestMCUPublisher) Close(ctx context.Context) { } -func (p *TestMCUPublisher) SendMessage(ctx context.Context, message *signaling.MessageClientMessage, data *signaling.MessageClientMessageData, callback func(error, map[string]interface{})) { +func (p *TestMCUPublisher) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { callback(errors.New("not implemented"), nil) } -func (p *TestMCUPublisher) HasMedia(signaling.MediaType) bool { +func (p *TestMCUPublisher) HasMedia(sfu.MediaType) bool { return false } -func (p *TestMCUPublisher) SetMedia(mediaTypes signaling.MediaType) { +func (p *TestMCUPublisher) SetMedia(mediaTypes sfu.MediaType) { } -func (p *TestMCUPublisher) GetStreams(ctx context.Context) ([]signaling.PublisherStream, error) { +func (p *TestMCUPublisher) GetStreams(ctx context.Context) ([]sfu.PublisherStream, error) { return nil, errors.New("not implemented") } -func (p *TestMCUPublisher) PublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error { +func (p *TestMCUPublisher) PublishRemote(ctx context.Context, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) error { return errors.New("not implemented") } -func (p *TestMCUPublisher) UnpublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error { +func (p *TestMCUPublisher) UnpublishRemote(ctx context.Context, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) error { return errors.New("not implemented") } +type PublisherTestMCU struct { + TestMCU +} + +type TestPublisherWithBandwidth struct { + TestMCUPublisher + + t *testing.T + bandwidth *sfu.ClientBandwidthInfo +} + +func (p *TestPublisherWithBandwidth) Bandwidth() *sfu.ClientBandwidthInfo { + return p.bandwidth +} + +func (p *TestPublisherWithBandwidth) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { + switch data.Type { + case "offer": + assert.Equal(p.t, mock.MockSdpOfferAudioAndVideo, data.Payload["sdp"]) + assert.NotNil(p.t, data.OfferSdp) + callback(nil, api.StringMap{ + "type": "answer", + "sdp": mock.MockSdpAnswerAudioAndVideo, + }) + case "requestoffer": + callback(nil, api.StringMap{ + "type": "offer", + "sdp": mock.MockSdpOfferAudioOnly, + }) + default: + callback(fmt.Errorf("type %s not implemented", data.Type), nil) + } +} + +func (m *PublisherTestMCU) NewPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings, initiator sfu.Initiator) (sfu.Publisher, error) { + publisher := &TestPublisherWithBandwidth{ + TestMCUPublisher: TestMCUPublisher{ + id: id, + sid: sid, + streamType: streamType, + }, + + t: m.t, + bandwidth: &sfu.ClientBandwidthInfo{ + Sent: api.BandwidthFromBytes(1000), + Received: api.BandwidthFromBytes(2000), + }, + } + return publisher, nil +} + +func NewPublisherTestMCU(t *testing.T) *PublisherTestMCU { + return &PublisherTestMCU{ + TestMCU: TestMCU{ + t: t, + }, + } +} + +func TestProxyPublisherBandwidth(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + proxyServer, key, server := newProxyServerForTest(t) + t.Cleanup(func() { + assert.EqualValues(0, proxyServer.GetSessionsCount()) + }) + + proxyServer.maxIncoming.Store(api.BandwidthFromBytes(10000)) + proxyServer.maxOutgoing.Store(api.BandwidthFromBytes(10000)) + + mcu := NewPublisherTestMCU(t) + proxyServer.mcu = mcu + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + assert.EqualValues(0, proxyServer.GetSessionsCount()) + + client := NewProxyTestClient(ctx, t, server.URL) + defer client.CloseWithBye() + + require.NoError(client.SendHello(key)) + + if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello) + } + + _, err := client.RunUntilLoad(ctx, 0) + assert.NoError(err) + + require.NoError(client.WriteJSON(&proxy.ClientMessage{ + Id: "2345", + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "create-publisher", + StreamType: sfu.StreamTypeVideo, + }, + })) + + var clientId string + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("2345", message.Id) + if err := checkMessageType(message, "command"); assert.NoError(err) { + assert.NotEmpty(message.Command.Id) + clientId = message.Command.Id + } + } + require.NotEmpty(clientId) + + if publisher := proxyServer.GetPublisher(clientId); assert.NotNil(publisher) { + assert.Equal(clientId, proxyServer.GetClientId(publisher)) + } + + proxyServer.updateLoad() + + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + if err := checkMessageType(message, "event"); assert.NoError(err) && assert.Equal("update-load", message.Event.Type) { + assert.EqualValues(1, message.Event.Load) + if bw := message.Event.Bandwidth; assert.NotNil(bw) { + if assert.NotNil(bw.Incoming) { + assert.InEpsilon(20, *bw.Incoming, 0.0001) + } + if assert.NotNil(bw.Outgoing) { + assert.InEpsilon(10, *bw.Outgoing, 0.0001) + } + } + } + } + + require.NoError(client.WriteJSON(&proxy.ClientMessage{ + Id: "3456", + Type: "payload", + Payload: &proxy.PayloadClientMessage{ + Type: "offer", + ClientId: clientId, + Payload: api.StringMap{ + "type": "offer", + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + }, + })) + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("3456", message.Id) + assert.Equal("payload", message.Type) + if payload := message.Payload; assert.NotNil(payload) { + assert.Equal(clientId, payload.ClientId) + assert.Equal("offer", payload.Type) + assert.Equal("answer", payload.Payload["type"]) + assert.Equal(mock.MockSdpAnswerAudioAndVideo, payload.Payload["sdp"]) + } + } + + require.NoError(client.WriteJSON(&proxy.ClientMessage{ + Id: "4567", + Type: "payload", + Payload: &proxy.PayloadClientMessage{ + Type: "endOfCandidates", + ClientId: clientId, + }, + })) + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("4567", message.Id) + assert.Equal("payload", message.Type) + if payload := message.Payload; assert.NotNil(payload) { + assert.Equal(clientId, payload.ClientId) + assert.Equal("endOfCandidates", payload.Type) + assert.Empty(payload.Payload) + } + } + + require.NoError(client.WriteJSON(&proxy.ClientMessage{ + Id: "5678", + Type: "payload", + Payload: &proxy.PayloadClientMessage{ + Type: "requestoffer", + ClientId: clientId, + }, + })) + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("5678", message.Id) + assert.Equal("payload", message.Type) + if payload := message.Payload; assert.NotNil(payload) { + assert.Equal(clientId, payload.ClientId) + assert.Equal("requestoffer", payload.Type) + assert.Equal("offer", payload.Payload["type"]) + assert.Equal(mock.MockSdpOfferAudioOnly, payload.Payload["sdp"]) + } + } +} + type HangingTestMCU struct { TestMCU ctx context.Context @@ -460,7 +672,7 @@ func NewHangingTestMCU(t *testing.T) *HangingTestMCU { } } -func (m *HangingTestMCU) NewPublisher(ctx context.Context, listener signaling.McuListener, id string, sid string, streamType signaling.StreamType, settings signaling.NewPublisherSettings, initiator signaling.McuInitiator) (signaling.McuPublisher, error) { +func (m *HangingTestMCU) NewPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings, initiator sfu.Initiator) (sfu.Publisher, error) { ctx2, cancel := context.WithTimeout(m.ctx, testTimeout*2) defer cancel() @@ -478,7 +690,7 @@ func (m *HangingTestMCU) NewPublisher(ctx context.Context, listener signaling.Mc } } -func (m *HangingTestMCU) NewSubscriber(ctx context.Context, listener signaling.McuListener, publisher string, streamType signaling.StreamType, initiator signaling.McuInitiator) (signaling.McuSubscriber, error) { +func (m *HangingTestMCU) NewSubscriber(ctx context.Context, listener sfu.Listener, publisher api.PublicSessionId, streamType sfu.StreamType, initiator sfu.Initiator) (sfu.Subscriber, error) { ctx2, cancel := context.WithTimeout(m.ctx, testTimeout*2) defer cancel() @@ -497,13 +709,13 @@ func (m *HangingTestMCU) NewSubscriber(ctx context.Context, listener signaling.M } func TestProxyCancelOnClose(t *testing.T) { - signaling.CatchLogForTest(t) + t.Parallel() assert := assert.New(t) require := require.New(t) - proxy, key, server := newProxyServerForTest(t) + proxyServer, key, server := newProxyServerForTest(t) mcu := NewHangingTestMCU(t) - proxy.mcu = mcu + proxyServer.mcu = mcu ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -520,19 +732,19 @@ func TestProxyCancelOnClose(t *testing.T) { _, err := client.RunUntilLoad(ctx, 0) assert.NoError(err) - require.NoError(client.WriteJSON(&signaling.ProxyClientMessage{ + require.NoError(client.WriteJSON(&proxy.ClientMessage{ Id: "2345", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "create-publisher", - StreamType: signaling.StreamTypeVideo, + StreamType: sfu.StreamTypeVideo, }, })) // Simulate expired session while request is still being processed. go func() { <-mcu.creating - if session := proxy.GetSession(1); assert.NotNil(session) { + if session := proxyServer.GetSession(1); assert.NotNil(session) { session.Close() } }() @@ -565,7 +777,7 @@ func NewCodecsTestMCU(t *testing.T) *CodecsTestMCU { } } -func (m *CodecsTestMCU) NewPublisher(ctx context.Context, listener signaling.McuListener, id string, sid string, streamType signaling.StreamType, settings signaling.NewPublisherSettings, initiator signaling.McuInitiator) (signaling.McuPublisher, error) { +func (m *CodecsTestMCU) NewPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings, initiator sfu.Initiator) (sfu.Publisher, error) { assert.Equal(m.t, "opus,g722", settings.AudioCodec) assert.Equal(m.t, "vp9,vp8,av1", settings.VideoCodec) return &TestMCUPublisher{ @@ -576,13 +788,13 @@ func (m *CodecsTestMCU) NewPublisher(ctx context.Context, listener signaling.Mcu } func TestProxyCodecs(t *testing.T) { - signaling.CatchLogForTest(t) + t.Parallel() assert := assert.New(t) require := require.New(t) - proxy, key, server := newProxyServerForTest(t) + proxyServer, key, server := newProxyServerForTest(t) mcu := NewCodecsTestMCU(t) - proxy.mcu = mcu + proxyServer.mcu = mcu ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -599,13 +811,13 @@ func TestProxyCodecs(t *testing.T) { _, err := client.RunUntilLoad(ctx, 0) assert.NoError(err) - require.NoError(client.WriteJSON(&signaling.ProxyClientMessage{ + require.NoError(client.WriteJSON(&proxy.ClientMessage{ Id: "2345", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "create-publisher", - StreamType: signaling.StreamTypeVideo, - PublisherSettings: &signaling.NewPublisherSettings{ + StreamType: sfu.StreamTypeVideo, + PublisherSettings: &sfu.NewPublisherSettings{ AudioCodec: "opus,g722", VideoCodec: "vp9,vp8,av1", }, @@ -620,6 +832,121 @@ func TestProxyCodecs(t *testing.T) { } } +type StreamTestMCU struct { + TestMCU + + streams []sfu.PublisherStream +} + +type StreamsTestPublisher struct { + TestMCUPublisher + + streams []sfu.PublisherStream +} + +func (m *StreamTestMCU) NewPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings, initiator sfu.Initiator) (sfu.Publisher, error) { + return &StreamsTestPublisher{ + TestMCUPublisher: TestMCUPublisher{ + id: id, + sid: sid, + streamType: streamType, + }, + + streams: m.streams, + }, nil +} + +func (p *StreamsTestPublisher) GetStreams(ctx context.Context) ([]sfu.PublisherStream, error) { + return p.streams, nil +} + +func NewStreamTestMCU(t *testing.T, streams []sfu.PublisherStream) *StreamTestMCU { + return &StreamTestMCU{ + TestMCU: TestMCU{ + t: t, + }, + + streams: streams, + } +} + +func TestProxyStreams(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + proxyServer, key, server := newProxyServerForTest(t) + + streams := []sfu.PublisherStream{ + { + Mid: "0", + Mindex: 0, + Type: "audio", + Codec: "opus", + }, + { + Mid: "1", + Mindex: 1, + Type: "video", + Codec: "vp8", + }, + } + + mcu := NewStreamTestMCU(t, streams) + proxyServer.mcu = mcu + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client := NewProxyTestClient(ctx, t, server.URL) + defer client.CloseWithBye() + + require.NoError(client.SendHello(key)) + + if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello) + } + + _, err := client.RunUntilLoad(ctx, 0) + assert.NoError(err) + + require.NoError(client.WriteJSON(&proxy.ClientMessage{ + Id: "2345", + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "create-publisher", + StreamType: sfu.StreamTypeVideo, + }, + })) + + var clientId string + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("2345", message.Id) + if err := checkMessageType(message, "command"); assert.NoError(err) { + if assert.NotEmpty(message.Command.Id) { + clientId = message.Command.Id + } + } + } + + require.NotEmpty(clientId, "should have received publisher id") + + require.NoError(client.WriteJSON(&proxy.ClientMessage{ + Id: "3456", + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "get-publisher-streams", + ClientId: clientId, + }, + })) + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("3456", message.Id) + if err := checkMessageType(message, "command"); assert.NoError(err) { + assert.Equal(clientId, message.Command.Id) + assert.Equal(streams, message.Command.Streams) + } + } +} + type RemoteSubscriberTestMCU struct { TestMCU @@ -638,35 +965,48 @@ func NewRemoteSubscriberTestMCU(t *testing.T) *RemoteSubscriberTestMCU { type TestRemotePublisher struct { t *testing.T - streamType signaling.StreamType + streamType sfu.StreamType refcnt atomic.Int32 closed context.Context closeFunc context.CancelFunc + listener sfu.Listener + controller sfu.RemotePublisherController } func (p *TestRemotePublisher) Id() string { return "id" } +func (p *TestRemotePublisher) PublisherId() api.PublicSessionId { + return "id" +} + func (p *TestRemotePublisher) Sid() string { return "sid" } -func (p *TestRemotePublisher) StreamType() signaling.StreamType { +func (p *TestRemotePublisher) StreamType() sfu.StreamType { return p.streamType } -func (p *TestRemotePublisher) MaxBitrate() int { +func (p *TestRemotePublisher) MaxBitrate() api.Bandwidth { return 0 } func (p *TestRemotePublisher) Close(ctx context.Context) { - if count := p.refcnt.Add(-1); assert.True(p.t, count >= 0) && count == 0 { + if count := p.refcnt.Add(-1); assert.GreaterOrEqual(p.t, int(count), 0) && count == 0 { p.closeFunc() + shortCtx, cancel := context.WithTimeout(ctx, time.Millisecond) + defer cancel() + // Won't be able to preform remote call to actually stop publishing. + if err := p.controller.StopPublishing(shortCtx, p); !errors.Is(err, context.DeadlineExceeded) { + assert.NoError(p.t, err) + } + p.listener.PublisherClosed(p) } } -func (p *TestRemotePublisher) SendMessage(ctx context.Context, message *signaling.MessageClientMessage, data *signaling.MessageClientMessageData, callback func(error, map[string]interface{})) { +func (p *TestRemotePublisher) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { callback(errors.New("not implemented"), nil) } @@ -678,7 +1018,14 @@ func (p *TestRemotePublisher) RtcpPort() int { return 2 } -func (m *RemoteSubscriberTestMCU) NewRemotePublisher(ctx context.Context, listener signaling.McuListener, controller signaling.RemotePublisherController, streamType signaling.StreamType) (signaling.McuRemotePublisher, error) { +func (p *TestRemotePublisher) SetMedia(mediaType sfu.MediaType) { +} + +func (p *TestRemotePublisher) HasMedia(mediaType sfu.MediaType) bool { + return false +} + +func (m *RemoteSubscriberTestMCU) NewRemotePublisher(ctx context.Context, listener sfu.Listener, controller sfu.RemotePublisherController, streamType sfu.StreamType) (sfu.RemotePublisher, error) { require.Nil(m.t, m.publisher) assert.EqualValues(m.t, "video", streamType) closeCtx, closeFunc := context.WithCancel(context.Background()) @@ -688,6 +1035,8 @@ func (m *RemoteSubscriberTestMCU) NewRemotePublisher(ctx context.Context, listen streamType: streamType, closed: closeCtx, closeFunc: closeFunc, + listener: listener, + controller: controller, } m.publisher.refcnt.Add(1) return m.publisher, nil @@ -709,11 +1058,11 @@ func (s *TestRemoteSubscriber) Sid() string { return "sid" } -func (s *TestRemoteSubscriber) StreamType() signaling.StreamType { +func (s *TestRemoteSubscriber) StreamType() sfu.StreamType { return s.publisher.StreamType() } -func (s *TestRemoteSubscriber) MaxBitrate() int { +func (s *TestRemoteSubscriber) MaxBitrate() api.Bandwidth { return 0 } @@ -722,15 +1071,15 @@ func (s *TestRemoteSubscriber) Close(ctx context.Context) { s.closeFunc() } -func (s *TestRemoteSubscriber) SendMessage(ctx context.Context, message *signaling.MessageClientMessage, data *signaling.MessageClientMessageData, callback func(error, map[string]interface{})) { +func (s *TestRemoteSubscriber) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { callback(errors.New("not implemented"), nil) } -func (s *TestRemoteSubscriber) Publisher() string { - return s.publisher.Id() +func (s *TestRemoteSubscriber) Publisher() api.PublicSessionId { + return api.PublicSessionId(s.publisher.Id()) } -func (m *RemoteSubscriberTestMCU) NewRemoteSubscriber(ctx context.Context, listener signaling.McuListener, publisher signaling.McuRemotePublisher) (signaling.McuRemoteSubscriber, error) { +func (m *RemoteSubscriberTestMCU) NewRemoteSubscriber(ctx context.Context, listener sfu.Listener, publisher sfu.RemotePublisher) (sfu.RemoteSubscriber, error) { require.Nil(m.t, m.subscriber) pub, ok := publisher.(*TestRemotePublisher) require.True(m.t, ok) @@ -747,17 +1096,17 @@ func (m *RemoteSubscriberTestMCU) NewRemoteSubscriber(ctx context.Context, liste } func TestProxyRemoteSubscriber(t *testing.T) { - signaling.CatchLogForTest(t) + t.Parallel() assert := assert.New(t) require := require.New(t) - proxy, key, server := newProxyServerForTest(t) + proxyServer, key, server := newProxyServerForTest(t) mcu := NewRemoteSubscriberTestMCU(t) - proxy.mcu = mcu + proxyServer.mcu = mcu // Unused but must be set so remote subscribing works - proxy.tokenId = "token" - proxy.tokenKey = key - proxy.remoteHostname = "test-hostname" + proxyServer.tokenId = "token" + proxyServer.tokenKey = key + proxyServer.remoteHostname = "test-hostname" ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -774,24 +1123,24 @@ func TestProxyRemoteSubscriber(t *testing.T) { _, err := client.RunUntilLoad(ctx, 0) assert.NoError(err) - publisherId := "the-publisher-id" - claims := &signaling.TokenClaims{ + publisherId := api.PublicSessionId("the-publisher-id") + claims := &proxy.TokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now().Add(-maxTokenAge / 2)), Issuer: TokenIdForTest, - Subject: publisherId, + Subject: string(publisherId), }, } token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) tokenString, err := token.SignedString(key) require.NoError(err) - require.NoError(client.WriteJSON(&signaling.ProxyClientMessage{ + require.NoError(client.WriteJSON(&proxy.ClientMessage{ Id: "2345", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "create-subscriber", - StreamType: signaling.StreamTypeVideo, + StreamType: sfu.StreamTypeVideo, PublisherId: publisherId, RemoteUrl: "https://remote-hostname", RemoteToken: tokenString, @@ -807,10 +1156,12 @@ func TestProxyRemoteSubscriber(t *testing.T) { } } - require.NoError(client.WriteJSON(&signaling.ProxyClientMessage{ + assert.True(proxyServer.hasRemotePublishers()) + + require.NoError(client.WriteJSON(&proxy.ClientMessage{ Id: "3456", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "delete-subscriber", ClientId: clientId, }, @@ -835,20 +1186,22 @@ func TestProxyRemoteSubscriber(t *testing.T) { assert.Fail("publisher was not closed") } } + + assert.False(proxyServer.hasRemotePublishers()) } func TestProxyCloseRemoteOnSessionClose(t *testing.T) { - signaling.CatchLogForTest(t) + t.Parallel() assert := assert.New(t) require := require.New(t) - proxy, key, server := newProxyServerForTest(t) + proxyServer, key, server := newProxyServerForTest(t) mcu := NewRemoteSubscriberTestMCU(t) - proxy.mcu = mcu + proxyServer.mcu = mcu // Unused but must be set so remote subscribing works - proxy.tokenId = "token" - proxy.tokenKey = key - proxy.remoteHostname = "test-hostname" + proxyServer.tokenId = "token" + proxyServer.tokenKey = key + proxyServer.remoteHostname = "test-hostname" ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -865,24 +1218,24 @@ func TestProxyCloseRemoteOnSessionClose(t *testing.T) { _, err := client.RunUntilLoad(ctx, 0) assert.NoError(err) - publisherId := "the-publisher-id" - claims := &signaling.TokenClaims{ + publisherId := api.PublicSessionId("the-publisher-id") + claims := &proxy.TokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now().Add(-maxTokenAge / 2)), Issuer: TokenIdForTest, - Subject: publisherId, + Subject: string(publisherId), }, } token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) tokenString, err := token.SignedString(key) require.NoError(err) - require.NoError(client.WriteJSON(&signaling.ProxyClientMessage{ + require.NoError(client.WriteJSON(&proxy.ClientMessage{ Id: "2345", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "create-subscriber", - StreamType: signaling.StreamTypeVideo, + StreamType: sfu.StreamTypeVideo, PublisherId: publisherId, RemoteUrl: "https://remote-hostname", RemoteToken: tokenString, @@ -930,14 +1283,16 @@ func NewUnpublishRemoteTestMCU(t *testing.T) *UnpublishRemoteTestMCU { type UnpublishRemoteTestPublisher struct { TestMCUPublisher - t *testing.T + t *testing.T // +checklocksignore: Only written to from constructor. - mu sync.RWMutex - remoteId string + mu sync.RWMutex + // +checklocks:mu + remoteId api.PublicSessionId + // +checklocks:mu remoteData *remotePublisherData } -func (m *UnpublishRemoteTestMCU) NewPublisher(ctx context.Context, listener signaling.McuListener, id string, sid string, streamType signaling.StreamType, settings signaling.NewPublisherSettings, initiator signaling.McuInitiator) (signaling.McuPublisher, error) { +func (m *UnpublishRemoteTestMCU) NewPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings, initiator sfu.Initiator) (sfu.Publisher, error) { publisher := &UnpublishRemoteTestPublisher{ TestMCUPublisher: TestMCUPublisher{ id: id, @@ -951,7 +1306,7 @@ func (m *UnpublishRemoteTestMCU) NewPublisher(ctx context.Context, listener sign return publisher, nil } -func (p *UnpublishRemoteTestPublisher) getRemoteId() string { +func (p *UnpublishRemoteTestPublisher) getRemoteId() api.PublicSessionId { p.mu.RLock() defer p.mu.RUnlock() return p.remoteId @@ -970,7 +1325,7 @@ func (p *UnpublishRemoteTestPublisher) clearRemote() { p.remoteData = nil } -func (p *UnpublishRemoteTestPublisher) PublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error { +func (p *UnpublishRemoteTestPublisher) PublishRemote(ctx context.Context, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) error { p.mu.Lock() defer p.mu.Unlock() if assert.Empty(p.t, p.remoteId) { @@ -984,14 +1339,14 @@ func (p *UnpublishRemoteTestPublisher) PublishRemote(ctx context.Context, remote return nil } -func (p *UnpublishRemoteTestPublisher) UnpublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error { +func (p *UnpublishRemoteTestPublisher) UnpublishRemote(ctx context.Context, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) error { p.mu.Lock() defer p.mu.Unlock() assert.Equal(p.t, remoteId, p.remoteId) if remoteData := p.remoteData; assert.NotNil(p.t, remoteData) && assert.Equal(p.t, remoteData.hostname, hostname) && - assert.EqualValues(p.t, remoteData.port, port) && - assert.EqualValues(p.t, remoteData.rtcpPort, rtcpPort) { + assert.Equal(p.t, remoteData.port, port) && + assert.Equal(p.t, remoteData.rtcpPort, rtcpPort) { p.remoteId = "" p.remoteData = nil } @@ -999,13 +1354,13 @@ func (p *UnpublishRemoteTestPublisher) UnpublishRemote(ctx context.Context, remo } func TestProxyUnpublishRemote(t *testing.T) { - signaling.CatchLogForTest(t) + t.Parallel() assert := assert.New(t) require := require.New(t) - proxy, key, server := newProxyServerForTest(t) + proxyServer, key, server := newProxyServerForTest(t) mcu := NewUnpublishRemoteTestMCU(t) - proxy.mcu = mcu + proxyServer.mcu = mcu ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -1022,18 +1377,18 @@ func TestProxyUnpublishRemote(t *testing.T) { _, err := client1.RunUntilLoad(ctx, 0) assert.NoError(err) - publisherId := "the-publisher-id" - require.NoError(client1.WriteJSON(&signaling.ProxyClientMessage{ + publisherId := api.PublicSessionId("the-publisher-id") + require.NoError(client1.WriteJSON(&proxy.ClientMessage{ Id: "2345", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "create-publisher", PublisherId: publisherId, Sid: "1234-abcd", - StreamType: signaling.StreamTypeVideo, - PublisherSettings: &signaling.NewPublisherSettings{ + StreamType: sfu.StreamTypeVideo, + PublisherSettings: &sfu.NewPublisherSettings{ Bitrate: 1234567, - MediaTypes: signaling.MediaTypeAudio | signaling.MediaTypeVideo, + MediaTypes: sfu.MediaTypeAudio | sfu.MediaTypeVideo, }, }, })) @@ -1060,12 +1415,12 @@ func TestProxyUnpublishRemote(t *testing.T) { _, err = client2.RunUntilLoad(ctx, 0) assert.NoError(err) - require.NoError(client2.WriteJSON(&signaling.ProxyClientMessage{ + require.NoError(client2.WriteJSON(&proxy.ClientMessage{ Id: "3456", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "publish-remote", - StreamType: signaling.StreamTypeVideo, + StreamType: sfu.StreamTypeVideo, ClientId: clientId, Hostname: "remote-host", Port: 10001, @@ -1084,17 +1439,17 @@ func TestProxyUnpublishRemote(t *testing.T) { assert.Equal(hello2.Hello.SessionId, publisher.getRemoteId()) if remoteData := publisher.getRemoteData(); assert.NotNil(remoteData) { assert.Equal("remote-host", remoteData.hostname) - assert.EqualValues(10001, remoteData.port) - assert.EqualValues(10002, remoteData.rtcpPort) + assert.Equal(10001, remoteData.port) + assert.Equal(10002, remoteData.rtcpPort) } } - require.NoError(client2.WriteJSON(&signaling.ProxyClientMessage{ + require.NoError(client2.WriteJSON(&proxy.ClientMessage{ Id: "4567", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "unpublish-remote", - StreamType: signaling.StreamTypeVideo, + StreamType: sfu.StreamTypeVideo, ClientId: clientId, Hostname: "remote-host", Port: 10001, @@ -1116,13 +1471,13 @@ func TestProxyUnpublishRemote(t *testing.T) { } func TestProxyUnpublishRemotePublisherClosed(t *testing.T) { - signaling.CatchLogForTest(t) + t.Parallel() assert := assert.New(t) require := require.New(t) - proxy, key, server := newProxyServerForTest(t) + proxyServer, key, server := newProxyServerForTest(t) mcu := NewUnpublishRemoteTestMCU(t) - proxy.mcu = mcu + proxyServer.mcu = mcu ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -1139,18 +1494,18 @@ func TestProxyUnpublishRemotePublisherClosed(t *testing.T) { _, err := client1.RunUntilLoad(ctx, 0) assert.NoError(err) - publisherId := "the-publisher-id" - require.NoError(client1.WriteJSON(&signaling.ProxyClientMessage{ + publisherId := api.PublicSessionId("the-publisher-id") + require.NoError(client1.WriteJSON(&proxy.ClientMessage{ Id: "2345", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "create-publisher", PublisherId: publisherId, Sid: "1234-abcd", - StreamType: signaling.StreamTypeVideo, - PublisherSettings: &signaling.NewPublisherSettings{ + StreamType: sfu.StreamTypeVideo, + PublisherSettings: &sfu.NewPublisherSettings{ Bitrate: 1234567, - MediaTypes: signaling.MediaTypeAudio | signaling.MediaTypeVideo, + MediaTypes: sfu.MediaTypeAudio | sfu.MediaTypeVideo, }, }, })) @@ -1177,12 +1532,12 @@ func TestProxyUnpublishRemotePublisherClosed(t *testing.T) { _, err = client2.RunUntilLoad(ctx, 0) assert.NoError(err) - require.NoError(client2.WriteJSON(&signaling.ProxyClientMessage{ + require.NoError(client2.WriteJSON(&proxy.ClientMessage{ Id: "3456", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "publish-remote", - StreamType: signaling.StreamTypeVideo, + StreamType: sfu.StreamTypeVideo, ClientId: clientId, Hostname: "remote-host", Port: 10001, @@ -1201,15 +1556,15 @@ func TestProxyUnpublishRemotePublisherClosed(t *testing.T) { assert.Equal(hello2.Hello.SessionId, publisher.getRemoteId()) if remoteData := publisher.getRemoteData(); assert.NotNil(remoteData) { assert.Equal("remote-host", remoteData.hostname) - assert.EqualValues(10001, remoteData.port) - assert.EqualValues(10002, remoteData.rtcpPort) + assert.Equal(10001, remoteData.port) + assert.Equal(10002, remoteData.rtcpPort) } } - require.NoError(client1.WriteJSON(&signaling.ProxyClientMessage{ + require.NoError(client1.WriteJSON(&proxy.ClientMessage{ Id: "4567", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "delete-publisher", ClientId: clientId, }, @@ -1227,14 +1582,14 @@ func TestProxyUnpublishRemotePublisherClosed(t *testing.T) { assert.Equal(hello2.Hello.SessionId, publisher.getRemoteId()) if remoteData := publisher.getRemoteData(); assert.NotNil(remoteData) { assert.Equal("remote-host", remoteData.hostname) - assert.EqualValues(10001, remoteData.port) - assert.EqualValues(10002, remoteData.rtcpPort) + assert.Equal(10001, remoteData.port) + assert.Equal(10002, remoteData.rtcpPort) } } // ...but the session no longer contains information on the remote publisher. - if data, err := proxy.cookie.DecodePublic(hello2.Hello.SessionId); assert.NoError(err) { - session := proxy.GetSession(data.Sid) + if data, err := proxyServer.cookie.DecodePublic(hello2.Hello.SessionId); assert.NoError(err) { + session := proxyServer.GetSession(data.Sid) if assert.NotNil(session) { session.remotePublishersLock.Lock() defer session.remotePublishersLock.Unlock() @@ -1248,13 +1603,13 @@ func TestProxyUnpublishRemotePublisherClosed(t *testing.T) { } func TestProxyUnpublishRemoteOnSessionClose(t *testing.T) { - signaling.CatchLogForTest(t) + t.Parallel() assert := assert.New(t) require := require.New(t) - proxy, key, server := newProxyServerForTest(t) + proxyServer, key, server := newProxyServerForTest(t) mcu := NewUnpublishRemoteTestMCU(t) - proxy.mcu = mcu + proxyServer.mcu = mcu ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -1271,18 +1626,18 @@ func TestProxyUnpublishRemoteOnSessionClose(t *testing.T) { _, err := client1.RunUntilLoad(ctx, 0) assert.NoError(err) - publisherId := "the-publisher-id" - require.NoError(client1.WriteJSON(&signaling.ProxyClientMessage{ + publisherId := api.PublicSessionId("the-publisher-id") + require.NoError(client1.WriteJSON(&proxy.ClientMessage{ Id: "2345", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "create-publisher", PublisherId: publisherId, Sid: "1234-abcd", - StreamType: signaling.StreamTypeVideo, - PublisherSettings: &signaling.NewPublisherSettings{ + StreamType: sfu.StreamTypeVideo, + PublisherSettings: &sfu.NewPublisherSettings{ Bitrate: 1234567, - MediaTypes: signaling.MediaTypeAudio | signaling.MediaTypeVideo, + MediaTypes: sfu.MediaTypeAudio | sfu.MediaTypeVideo, }, }, })) @@ -1309,12 +1664,12 @@ func TestProxyUnpublishRemoteOnSessionClose(t *testing.T) { _, err = client2.RunUntilLoad(ctx, 0) assert.NoError(err) - require.NoError(client2.WriteJSON(&signaling.ProxyClientMessage{ + require.NoError(client2.WriteJSON(&proxy.ClientMessage{ Id: "3456", Type: "command", - Command: &signaling.CommandProxyClientMessage{ + Command: &proxy.CommandClientMessage{ Type: "publish-remote", - StreamType: signaling.StreamTypeVideo, + StreamType: sfu.StreamTypeVideo, ClientId: clientId, Hostname: "remote-host", Port: 10001, @@ -1333,8 +1688,8 @@ func TestProxyUnpublishRemoteOnSessionClose(t *testing.T) { assert.Equal(hello2.Hello.SessionId, publisher.getRemoteId()) if remoteData := publisher.getRemoteData(); assert.NotNil(remoteData) { assert.Equal("remote-host", remoteData.hostname) - assert.EqualValues(10001, remoteData.port) - assert.EqualValues(10002, remoteData.rtcpPort) + assert.Equal(10001, remoteData.port) + assert.Equal(10002, remoteData.rtcpPort) } } @@ -1346,3 +1701,332 @@ func TestProxyUnpublishRemoteOnSessionClose(t *testing.T) { assert.Nil(publisher.getRemoteData()) } } + +func TestExpireSessions(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + proxyServer, key, server := newProxyServerForTest(t) + server.Close() + + // No-op + proxyServer.expireSessions() + + claims := &proxy.TokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now().Add(-maxTokenAge / 2)), + Issuer: TokenIdForTest, + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tokenString, err := token.SignedString(key) + require.NoError(err) + + hello := &proxy.HelloClientMessage{ + Version: "1.0", + Token: tokenString, + } + session, err := proxyServer.NewSession(hello) + require.NoError(err) + t.Cleanup(func() { + proxyServer.DeleteSession(session.Sid()) + }) + assert.Same(session, proxyServer.GetSession(session.Sid())) + + proxyServer.expireSessions() + assert.Same(session, proxyServer.GetSession(session.Sid())) + + time.Sleep(sessionExpirationTime) + proxyServer.expireSessions() + + assert.Nil(proxyServer.GetSession(session.Sid())) + }) +} + +func TestScheduleShutdownEmpty(t *testing.T) { + t.Parallel() + + proxyServer, _, _ := newProxyServerForTest(t) + + proxyServer.ScheduleShutdown() + <-proxyServer.ShutdownChannel() +} + +func TestScheduleShutdownNoClients(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + proxyServer, key, server := newProxyServerForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client := NewProxyTestClient(ctx, t, server.URL) + defer client.CloseWithBye() + + require.NoError(client.SendHello(key)) + + if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello) + } + + _, err := client.RunUntilLoad(ctx, 0) + assert.NoError(err) + + proxyServer.ScheduleShutdown() + + if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("event", msg.Type) + if event := msg.Event; assert.NotNil(event) { + assert.Equal("shutdown-scheduled", event.Type) + } + } + + <-proxyServer.ShutdownChannel() +} + +func TestScheduleShutdown(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + proxyServer, key, server := newProxyServerForTest(t) + + mcu := NewPublisherTestMCU(t) + proxyServer.mcu = mcu + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client := NewProxyTestClient(ctx, t, server.URL) + defer client.CloseWithBye() + + require.NoError(client.SendHello(key)) + + if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello) + } + + _, err := client.RunUntilLoad(ctx, 0) + assert.NoError(err) + + publisherId := api.PublicSessionId("the-publisher-id") + require.NoError(client.WriteJSON(&proxy.ClientMessage{ + Id: "2345", + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "create-publisher", + PublisherId: publisherId, + Sid: "1234-abcd", + StreamType: sfu.StreamTypeVideo, + PublisherSettings: &sfu.NewPublisherSettings{ + Bitrate: 1234567, + MediaTypes: sfu.MediaTypeAudio | sfu.MediaTypeVideo, + }, + }, + })) + + var clientId string + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("2345", message.Id) + if err := checkMessageType(message, "command"); assert.NoError(err) { + require.NotEmpty(message.Command.Id) + clientId = message.Command.Id + } + } + + readyChan := make(chan struct{}) + var readyReceived atomic.Bool + go func() { + for { + select { + case <-proxyServer.ShutdownChannel(): + return + case <-readyChan: + readyReceived.Store(true) + case <-ctx.Done(): + assert.NoError(ctx.Err()) + return + } + } + }() + + proxyServer.ScheduleShutdown() + + if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("event", msg.Type) + if event := msg.Event; assert.NotNil(event) { + assert.Equal("shutdown-scheduled", event.Type) + } + } + close(readyChan) + + proxyServer.ScheduleShutdown() + + select { + case <-proxyServer.ShutdownChannel(): + assert.Fail("should only shutdown after all clients closed") + default: + } + require.NoError(client.WriteJSON(&proxy.ClientMessage{ + Id: "4567", + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "delete-publisher", + ClientId: clientId, + }, + })) + + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("4567", message.Id) + if err := checkMessageType(message, "command"); assert.NoError(err) { + require.NotEmpty(message.Command.Id) + } + } + + <-proxyServer.ShutdownChannel() + assert.True(readyReceived.Load()) +} + +func TestScheduleShutdownOnResume(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + proxyServer, key, server := newProxyServerForTest(t) + + mcu := NewPublisherTestMCU(t) + proxyServer.mcu = mcu + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client := NewProxyTestClient(ctx, t, server.URL) + defer client.CloseWithBye() + + require.NoError(client.SendHello(key)) + + hello, err := client.RunUntilHello(ctx) + if assert.NoError(err) { + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello) + } + + _, err = client.RunUntilLoad(ctx, 0) + assert.NoError(err) + + publisherId := api.PublicSessionId("the-publisher-id") + require.NoError(client.WriteJSON(&proxy.ClientMessage{ + Id: "2345", + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "create-publisher", + PublisherId: publisherId, + Sid: "1234-abcd", + StreamType: sfu.StreamTypeVideo, + PublisherSettings: &sfu.NewPublisherSettings{ + Bitrate: 1234567, + MediaTypes: sfu.MediaTypeAudio | sfu.MediaTypeVideo, + }, + }, + })) + + var clientId string + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("2345", message.Id) + if err := checkMessageType(message, "command"); assert.NoError(err) { + require.NotEmpty(message.Command.Id) + clientId = message.Command.Id + } + } + + readyChan := make(chan struct{}) + var readyReceived atomic.Bool + go func() { + for { + select { + case <-proxyServer.ShutdownChannel(): + return + case <-readyChan: + readyReceived.Store(true) + case <-ctx.Done(): + assert.NoError(ctx.Err()) + return + } + } + }() + + client.Close() + + proxyServer.ScheduleShutdown() + + client = NewProxyTestClient(ctx, t, server.URL) + defer client.CloseWithBye() + + hello2 := &proxy.ClientMessage{ + Id: "1234", + Type: "hello", + Hello: &proxy.HelloClientMessage{ + Version: "1.0", + Features: []string{}, + ResumeId: hello.Hello.SessionId, + }, + } + require.NoError(client.WriteJSON(hello2)) + + if hello3, err := client.RunUntilHello(ctx); assert.NoError(err) { + assert.Equal(hello.Hello.SessionId, hello3.Hello.SessionId) + } + + if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("event", msg.Type) + if event := msg.Event; assert.NotNil(event) { + assert.Equal("shutdown-scheduled", event.Type) + } + } + + client2 := NewProxyTestClient(ctx, t, server.URL) + defer client2.CloseWithBye() + + require.NoError(client2.SendHello(key)) + + if hello, err := client2.RunUntilHello(ctx); assert.NoError(err) { + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello) + } + + if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("event", msg.Type) + if event := msg.Event; assert.NotNil(event) { + assert.Equal("shutdown-scheduled", event.Type) + } + } + close(readyChan) + + proxyServer.ScheduleShutdown() + + select { + case <-proxyServer.ShutdownChannel(): + assert.Fail("should only shutdown after all clients closed") + default: + } + require.NoError(client.WriteJSON(&proxy.ClientMessage{ + Id: "4567", + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "delete-publisher", + ClientId: clientId, + }, + })) + + if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + assert.Equal("4567", message.Id) + if err := checkMessageType(message, "command"); assert.NoError(err) { + require.NotEmpty(message.Command.Id) + } + } + + <-proxyServer.ShutdownChannel() + assert.True(readyReceived.Load()) +} diff --git a/proxy/proxy_session.go b/cmd/proxy/proxy_session.go similarity index 59% rename from proxy/proxy_session.go rename to cmd/proxy/proxy_session.go index de6645b..d5345b1 100644 --- a/proxy/proxy_session.go +++ b/cmd/proxy/proxy_session.go @@ -24,12 +24,14 @@ package main import ( "context" "fmt" - "log" "sync" "sync/atomic" "time" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/proxy" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" ) const ( @@ -38,49 +40,59 @@ const ( ) type remotePublisherData struct { + id api.PublicSessionId hostname string port int rtcpPort int } type ProxySession struct { + logger log.Logger proxy *ProxyServer - id string + id api.PublicSessionId sid uint64 lastUsed atomic.Int64 ctx context.Context closeFunc context.CancelFunc - clientLock sync.Mutex - client *ProxyClient - pendingMessages []*signaling.ProxyServerMessage + clientLock sync.Mutex + // +checklocks:clientLock + client *ProxyClient + // +checklocks:clientLock + pendingMessages []*proxy.ServerMessage publishersLock sync.Mutex - publishers map[string]signaling.McuPublisher - publisherIds map[signaling.McuPublisher]string + // +checklocks:publishersLock + publishers map[string]sfu.Publisher + // +checklocks:publishersLock + publisherIds map[sfu.Publisher]string subscribersLock sync.Mutex - subscribers map[string]signaling.McuSubscriber - subscriberIds map[signaling.McuSubscriber]string + // +checklocks:subscribersLock + subscribers map[string]sfu.Subscriber + // +checklocks:subscribersLock + subscriberIds map[sfu.Subscriber]string remotePublishersLock sync.Mutex - remotePublishers map[signaling.McuPublisher]map[string]*remotePublisherData + // +checklocks:remotePublishersLock + remotePublishers map[sfu.RemoteAwarePublisher]map[string]*remotePublisherData } -func NewProxySession(proxy *ProxyServer, sid uint64, id string) *ProxySession { +func NewProxySession(proxy *ProxyServer, sid uint64, id api.PublicSessionId) *ProxySession { ctx, closeFunc := context.WithCancel(context.Background()) result := &ProxySession{ + logger: proxy.logger, proxy: proxy, id: id, sid: sid, ctx: ctx, closeFunc: closeFunc, - publishers: make(map[string]signaling.McuPublisher), - publisherIds: make(map[signaling.McuPublisher]string), + publishers: make(map[string]sfu.Publisher), + publisherIds: make(map[sfu.Publisher]string), - subscribers: make(map[string]signaling.McuSubscriber), - subscriberIds: make(map[signaling.McuSubscriber]string), + subscribers: make(map[string]sfu.Subscriber), + subscriberIds: make(map[sfu.Subscriber]string), } result.MarkUsed() return result @@ -90,7 +102,7 @@ func (s *ProxySession) Context() context.Context { return s.ctx } -func (s *ProxySession) PublicId() string { +func (s *ProxySession) PublicId() api.PublicSessionId { return s.id } @@ -105,7 +117,7 @@ func (s *ProxySession) LastUsed() time.Time { func (s *ProxySession) IsExpired() bool { expiresAt := s.LastUsed().Add(sessionExpirationTime) - return expiresAt.Before(time.Now()) + return !expiresAt.After(time.Now()) } func (s *ProxySession) MarkUsed() { @@ -120,9 +132,9 @@ func (s *ProxySession) Close() { if s.IsExpired() { reason = "session_expired" } - prev.SendMessage(&signaling.ProxyServerMessage{ + prev.SendMessage(&proxy.ServerMessage{ Type: "bye", - Bye: &signaling.ByeProxyServerMessage{ + Bye: &proxy.ByeServerMessage{ Reason: reason, }, }) @@ -139,7 +151,7 @@ func (s *ProxySession) SetClient(client *ProxyClient) *ProxyClient { s.clientLock.Lock() prev := s.client s.client = client - var messages []*signaling.ProxyServerMessage + var messages []*proxy.ServerMessage if client != nil { messages, s.pendingMessages = s.pendingMessages, nil } @@ -157,19 +169,19 @@ func (s *ProxySession) SetClient(client *ProxyClient) *ProxyClient { return prev } -func (s *ProxySession) OnUpdateOffer(client signaling.McuClient, offer map[string]interface{}) { +func (s *ProxySession) OnUpdateOffer(client sfu.Client, offer api.StringMap) { id := s.proxy.GetClientId(client) if id == "" { - log.Printf("Received offer %+v from unknown %s client %s (%+v)", offer, client.StreamType(), client.Id(), client) + s.logger.Printf("Received offer %+v from unknown %s client %s (%+v)", offer, client.StreamType(), client.Id(), client) return } - msg := &signaling.ProxyServerMessage{ + msg := &proxy.ServerMessage{ Type: "payload", - Payload: &signaling.PayloadProxyServerMessage{ + Payload: &proxy.PayloadServerMessage{ Type: "offer", ClientId: id, - Payload: map[string]interface{}{ + Payload: api.StringMap{ "offer": offer, }, }, @@ -177,19 +189,19 @@ func (s *ProxySession) OnUpdateOffer(client signaling.McuClient, offer map[strin s.sendMessage(msg) } -func (s *ProxySession) OnIceCandidate(client signaling.McuClient, candidate interface{}) { +func (s *ProxySession) OnIceCandidate(client sfu.Client, candidate any) { id := s.proxy.GetClientId(client) if id == "" { - log.Printf("Received candidate %+v from unknown %s client %s (%+v)", candidate, client.StreamType(), client.Id(), client) + s.logger.Printf("Received candidate %+v from unknown %s client %s (%+v)", candidate, client.StreamType(), client.Id(), client) return } - msg := &signaling.ProxyServerMessage{ + msg := &proxy.ServerMessage{ Type: "payload", - Payload: &signaling.PayloadProxyServerMessage{ + Payload: &proxy.PayloadServerMessage{ Type: "candidate", ClientId: id, - Payload: map[string]interface{}{ + Payload: api.StringMap{ "candidate": candidate, }, }, @@ -197,7 +209,7 @@ func (s *ProxySession) OnIceCandidate(client signaling.McuClient, candidate inte s.sendMessage(msg) } -func (s *ProxySession) sendMessage(message *signaling.ProxyServerMessage) { +func (s *ProxySession) sendMessage(message *proxy.ServerMessage) { var client *ProxyClient s.clientLock.Lock() client = s.client @@ -210,16 +222,16 @@ func (s *ProxySession) sendMessage(message *signaling.ProxyServerMessage) { } } -func (s *ProxySession) OnIceCompleted(client signaling.McuClient) { +func (s *ProxySession) OnIceCompleted(client sfu.Client) { id := s.proxy.GetClientId(client) if id == "" { - log.Printf("Received ice completed event from unknown %s client %s (%+v)", client.StreamType(), client.Id(), client) + s.logger.Printf("Received ice completed event from unknown %s client %s (%+v)", client.StreamType(), client.Id(), client) return } - msg := &signaling.ProxyServerMessage{ + msg := &proxy.ServerMessage{ Type: "event", - Event: &signaling.EventProxyServerMessage{ + Event: &proxy.EventServerMessage{ Type: "ice-completed", ClientId: id, }, @@ -227,16 +239,16 @@ func (s *ProxySession) OnIceCompleted(client signaling.McuClient) { s.sendMessage(msg) } -func (s *ProxySession) SubscriberSidUpdated(subscriber signaling.McuSubscriber) { +func (s *ProxySession) SubscriberSidUpdated(subscriber sfu.Subscriber) { id := s.proxy.GetClientId(subscriber) if id == "" { - log.Printf("Received subscriber sid updated event from unknown %s subscriber %s (%+v)", subscriber.StreamType(), subscriber.Id(), subscriber) + s.logger.Printf("Received subscriber sid updated event from unknown %s subscriber %s (%+v)", subscriber.StreamType(), subscriber.Id(), subscriber) return } - msg := &signaling.ProxyServerMessage{ + msg := &proxy.ServerMessage{ Type: "event", - Event: &signaling.EventProxyServerMessage{ + Event: &proxy.EventServerMessage{ Type: "subscriber-sid-updated", ClientId: id, Sid: subscriber.Sid(), @@ -245,15 +257,15 @@ func (s *ProxySession) SubscriberSidUpdated(subscriber signaling.McuSubscriber) s.sendMessage(msg) } -func (s *ProxySession) PublisherClosed(publisher signaling.McuPublisher) { +func (s *ProxySession) PublisherClosed(publisher sfu.Publisher) { if id := s.DeletePublisher(publisher); id != "" { if s.proxy.DeleteClient(id, publisher) { statsPublishersCurrent.WithLabelValues(string(publisher.StreamType())).Dec() } - msg := &signaling.ProxyServerMessage{ + msg := &proxy.ServerMessage{ Type: "event", - Event: &signaling.EventProxyServerMessage{ + Event: &proxy.EventServerMessage{ Type: "publisher-closed", ClientId: id, }, @@ -262,15 +274,15 @@ func (s *ProxySession) PublisherClosed(publisher signaling.McuPublisher) { } } -func (s *ProxySession) SubscriberClosed(subscriber signaling.McuSubscriber) { +func (s *ProxySession) SubscriberClosed(subscriber sfu.Subscriber) { if id := s.DeleteSubscriber(subscriber); id != "" { if s.proxy.DeleteClient(id, subscriber) { statsSubscribersCurrent.WithLabelValues(string(subscriber.StreamType())).Dec() } - msg := &signaling.ProxyServerMessage{ + msg := &proxy.ServerMessage{ Type: "event", - Event: &signaling.EventProxyServerMessage{ + Event: &proxy.EventServerMessage{ Type: "subscriber-closed", ClientId: id, }, @@ -279,7 +291,7 @@ func (s *ProxySession) SubscriberClosed(subscriber signaling.McuSubscriber) { } } -func (s *ProxySession) StorePublisher(ctx context.Context, id string, publisher signaling.McuPublisher) { +func (s *ProxySession) StorePublisher(ctx context.Context, id string, publisher sfu.Publisher) { s.publishersLock.Lock() defer s.publishersLock.Unlock() @@ -287,7 +299,7 @@ func (s *ProxySession) StorePublisher(ctx context.Context, id string, publisher s.publisherIds[publisher] = id } -func (s *ProxySession) DeletePublisher(publisher signaling.McuPublisher) string { +func (s *ProxySession) DeletePublisher(publisher sfu.Publisher) string { s.publishersLock.Lock() defer s.publishersLock.Unlock() @@ -298,12 +310,16 @@ func (s *ProxySession) DeletePublisher(publisher signaling.McuPublisher) string delete(s.publishers, id) delete(s.publisherIds, publisher) - delete(s.remotePublishers, publisher) + if rp, ok := publisher.(sfu.RemoteAwarePublisher); ok { + s.remotePublishersLock.Lock() + defer s.remotePublishersLock.Unlock() + delete(s.remotePublishers, rp) + } go s.proxy.PublisherDeleted(publisher) return id } -func (s *ProxySession) StoreSubscriber(ctx context.Context, id string, subscriber signaling.McuSubscriber) { +func (s *ProxySession) StoreSubscriber(ctx context.Context, id string, subscriber sfu.Subscriber) { s.subscribersLock.Lock() defer s.subscribersLock.Unlock() @@ -311,7 +327,7 @@ func (s *ProxySession) StoreSubscriber(ctx context.Context, id string, subscribe s.subscriberIds[subscriber] = id } -func (s *ProxySession) DeleteSubscriber(subscriber signaling.McuSubscriber) string { +func (s *ProxySession) DeleteSubscriber(subscriber sfu.Subscriber) string { s.subscribersLock.Lock() defer s.subscribersLock.Unlock() @@ -329,7 +345,7 @@ func (s *ProxySession) clearPublishers() { s.publishersLock.Lock() defer s.publishersLock.Unlock() - go func(publishers map[string]signaling.McuPublisher) { + go func(publishers map[string]sfu.Publisher) { for id, publisher := range publishers { if s.proxy.DeleteClient(id, publisher) { statsPublishersCurrent.WithLabelValues(string(publisher.StreamType())).Dec() @@ -338,7 +354,7 @@ func (s *ProxySession) clearPublishers() { } }(s.publishers) // Can't use clear(...) here as the map is processed by the goroutine above. - s.publishers = make(map[string]signaling.McuPublisher) + s.publishers = make(map[string]sfu.Publisher) clear(s.publisherIds) } @@ -346,11 +362,11 @@ func (s *ProxySession) clearRemotePublishers() { s.remotePublishersLock.Lock() defer s.remotePublishersLock.Unlock() - go func(remotePublishers map[signaling.McuPublisher]map[string]*remotePublisherData) { + go func(remotePublishers map[sfu.RemoteAwarePublisher]map[string]*remotePublisherData) { for publisher, entries := range remotePublishers { for _, data := range entries { if err := publisher.UnpublishRemote(context.Background(), s.PublicId(), data.hostname, data.port, data.rtcpPort); err != nil { - log.Printf("Error unpublishing %s %s from remote %s: %s", publisher.StreamType(), publisher.Id(), data.hostname, err) + s.logger.Printf("Error unpublishing %s %s from remote %s: %s", publisher.StreamType(), publisher.Id(), data.hostname, err) } } } @@ -359,10 +375,10 @@ func (s *ProxySession) clearRemotePublishers() { } func (s *ProxySession) clearSubscribers() { - s.publishersLock.Lock() - defer s.publishersLock.Unlock() + s.subscribersLock.Lock() + defer s.subscribersLock.Unlock() - go func(subscribers map[string]signaling.McuSubscriber) { + go func(subscribers map[string]sfu.Subscriber) { for id, subscriber := range subscribers { if s.proxy.DeleteClient(id, subscriber) { statsSubscribersCurrent.WithLabelValues(string(subscriber.StreamType())).Dec() @@ -371,7 +387,7 @@ func (s *ProxySession) clearSubscribers() { } }(s.subscribers) // Can't use clear(...) here as the map is processed by the goroutine above. - s.subscribers = make(map[string]signaling.McuSubscriber) + s.subscribers = make(map[string]sfu.Subscriber) clear(s.subscriberIds) } @@ -381,7 +397,7 @@ func (s *ProxySession) NotifyDisconnected() { s.clearRemotePublishers() } -func (s *ProxySession) AddRemotePublisher(publisher signaling.McuPublisher, hostname string, port int, rtcpPort int) bool { +func (s *ProxySession) AddRemotePublisher(publisher sfu.RemoteAwarePublisher, hostname string, port int, rtcpPort int) bool { s.remotePublishersLock.Lock() defer s.remotePublishersLock.Unlock() @@ -389,7 +405,7 @@ func (s *ProxySession) AddRemotePublisher(publisher signaling.McuPublisher, host if !found { remote = make(map[string]*remotePublisherData) if s.remotePublishers == nil { - s.remotePublishers = make(map[signaling.McuPublisher]map[string]*remotePublisherData) + s.remotePublishers = make(map[sfu.RemoteAwarePublisher]map[string]*remotePublisherData) } s.remotePublishers[publisher] = remote } @@ -400,6 +416,7 @@ func (s *ProxySession) AddRemotePublisher(publisher signaling.McuPublisher, host } data := &remotePublisherData{ + id: publisher.PublisherId(), hostname: hostname, port: port, rtcpPort: rtcpPort, @@ -408,7 +425,7 @@ func (s *ProxySession) AddRemotePublisher(publisher signaling.McuPublisher, host return true } -func (s *ProxySession) RemoveRemotePublisher(publisher signaling.McuPublisher, hostname string, port int, rtcpPort int) { +func (s *ProxySession) RemoveRemotePublisher(publisher sfu.RemoteAwarePublisher, hostname string, port int, rtcpPort int) { s.remotePublishersLock.Lock() defer s.remotePublishersLock.Unlock() @@ -427,9 +444,43 @@ func (s *ProxySession) RemoveRemotePublisher(publisher signaling.McuPublisher, h } } -func (s *ProxySession) OnPublisherDeleted(publisher signaling.McuPublisher) { +func (s *ProxySession) OnPublisherDeleted(publisher sfu.Publisher) { + if publisher, ok := publisher.(sfu.RemoteAwarePublisher); ok { + s.OnRemoteAwarePublisherDeleted(publisher) + } +} + +func (s *ProxySession) OnRemoteAwarePublisherDeleted(publisher sfu.RemoteAwarePublisher) { s.remotePublishersLock.Lock() defer s.remotePublishersLock.Unlock() - delete(s.remotePublishers, publisher) + if entries, found := s.remotePublishers[publisher]; found { + delete(s.remotePublishers, publisher) + + for _, entry := range entries { + msg := &proxy.ServerMessage{ + Type: "event", + Event: &proxy.EventServerMessage{ + Type: "publisher-closed", + ClientId: string(entry.id), + }, + } + s.sendMessage(msg) + } + } +} + +func (s *ProxySession) OnRemotePublisherDeleted(publisherId api.PublicSessionId) { + s.subscribersLock.Lock() + defer s.subscribersLock.Unlock() + + for id, sub := range s.subscribers { + if sub.Publisher() == publisherId { + delete(s.subscribers, id) + delete(s.subscriberIds, sub) + + s.logger.Printf("Remote subscriber %s was closed, closing %s subscriber %s", publisherId, sub.StreamType(), sub.Id()) + go sub.Close(context.Background()) + } + } } diff --git a/proxy/proxy_stats_prometheus.go b/cmd/proxy/proxy_stats_prometheus.go similarity index 94% rename from proxy/proxy_stats_prometheus.go rename to cmd/proxy/proxy_stats_prometheus.go index 054ec2b..26e317c 100644 --- a/proxy/proxy_stats_prometheus.go +++ b/cmd/proxy/proxy_stats_prometheus.go @@ -86,6 +86,12 @@ var ( Name: "token_errors_total", Help: "The total number of token errors", }, []string{"reason"}) + statsLoadCurrent = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "proxy", + Name: "load", + Help: "The current load of the signaling proxy", + }) ) func init() { @@ -99,4 +105,5 @@ func init() { prometheus.MustRegister(statsCommandMessagesTotal) prometheus.MustRegister(statsPayloadMessagesTotal) prometheus.MustRegister(statsTokenErrorsTotal) + prometheus.MustRegister(statsLoadCurrent) } diff --git a/proxy/proxy_testclient_test.go b/cmd/proxy/proxy_testclient_test.go similarity index 85% rename from proxy/proxy_testclient_test.go rename to cmd/proxy/proxy_testclient_test.go index c08b5f3..0eae10a 100644 --- a/proxy/proxy_testclient_test.go +++ b/cmd/proxy/proxy_testclient_test.go @@ -34,7 +34,9 @@ import ( "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/proxy" ) var ( @@ -43,15 +45,16 @@ var ( type ProxyTestClient struct { t *testing.T - assert *assert.Assertions + assert *assert.Assertions // +checklocksignore: Only written to from constructor. require *require.Assertions - mu sync.Mutex + mu sync.Mutex + // +checklocks:mu conn *websocket.Conn messageChan chan []byte readErrorChan chan error - sessionId string + sessionId api.PublicSessionId } func NewProxyTestClient(ctx context.Context, t *testing.T, url string) *ProxyTestClient { @@ -117,16 +120,16 @@ loop: } func (c *ProxyTestClient) SendBye() error { - hello := &signaling.ProxyClientMessage{ + hello := &proxy.ClientMessage{ Id: "9876", Type: "bye", - Bye: &signaling.ByeProxyClientMessage{}, + Bye: &proxy.ByeClientMessage{}, } return c.WriteJSON(hello) } -func (c *ProxyTestClient) WriteJSON(data interface{}) error { - if msg, ok := data.(*signaling.ProxyClientMessage); ok { +func (c *ProxyTestClient) WriteJSON(data any) error { + if msg, ok := data.(*proxy.ClientMessage); ok { if err := msg.CheckValid(); err != nil { return err } @@ -137,11 +140,11 @@ func (c *ProxyTestClient) WriteJSON(data interface{}) error { return c.conn.WriteJSON(data) } -func (c *ProxyTestClient) RunUntilMessage(ctx context.Context) (message *signaling.ProxyServerMessage, err error) { +func (c *ProxyTestClient) RunUntilMessage(ctx context.Context) (message *proxy.ServerMessage, err error) { select { case err = <-c.readErrorChan: case msg := <-c.messageChan: - var m signaling.ProxyServerMessage + var m proxy.ServerMessage if err = json.Unmarshal(msg, &m); err == nil { message = &m } @@ -162,7 +165,7 @@ func checkUnexpectedClose(err error) error { return nil } -func checkMessageType(message *signaling.ProxyServerMessage, expectedType string) error { +func checkMessageType(message *proxy.ServerMessage, expectedType string) error { if message == nil { return ErrNoMessageReceived } @@ -188,8 +191,8 @@ func checkMessageType(message *signaling.ProxyServerMessage, expectedType string return nil } -func (c *ProxyTestClient) SendHello(key interface{}) error { - claims := &signaling.TokenClaims{ +func (c *ProxyTestClient) SendHello(key any) error { + claims := &proxy.TokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now().Add(-maxTokenAge / 2)), Issuer: TokenIdForTest, @@ -199,10 +202,10 @@ func (c *ProxyTestClient) SendHello(key interface{}) error { tokenString, err := token.SignedString(key) c.require.NoError(err) - hello := &signaling.ProxyClientMessage{ + hello := &proxy.ClientMessage{ Id: "1234", Type: "hello", - Hello: &signaling.HelloProxyClientMessage{ + Hello: &proxy.HelloClientMessage{ Version: "1.0", Features: []string{}, Token: tokenString, @@ -211,7 +214,7 @@ func (c *ProxyTestClient) SendHello(key interface{}) error { return c.WriteJSON(hello) } -func (c *ProxyTestClient) RunUntilHello(ctx context.Context) (message *signaling.ProxyServerMessage, err error) { +func (c *ProxyTestClient) RunUntilHello(ctx context.Context) (message *proxy.ServerMessage, err error) { if message, err = c.RunUntilMessage(ctx); err != nil { return nil, err } @@ -225,7 +228,7 @@ func (c *ProxyTestClient) RunUntilHello(ctx context.Context) (message *signaling return message, nil } -func (c *ProxyTestClient) RunUntilLoad(ctx context.Context, load int64) (message *signaling.ProxyServerMessage, err error) { +func (c *ProxyTestClient) RunUntilLoad(ctx context.Context, load uint64) (message *proxy.ServerMessage, err error) { if message, err = c.RunUntilMessage(ctx); err != nil { return nil, err } @@ -244,8 +247,8 @@ func (c *ProxyTestClient) RunUntilLoad(ctx context.Context, load int64) (message return message, nil } -func (c *ProxyTestClient) SendCommand(command *signaling.CommandProxyClientMessage) error { - message := &signaling.ProxyClientMessage{ +func (c *ProxyTestClient) SendCommand(command *proxy.CommandClientMessage) error { + message := &proxy.ClientMessage{ Id: "2345", Type: "command", Command: command, diff --git a/proxy/proxy_tokens.go b/cmd/proxy/proxy_tokens.go similarity index 100% rename from proxy/proxy_tokens.go rename to cmd/proxy/proxy_tokens.go diff --git a/proxy/proxy_tokens_etcd.go b/cmd/proxy/proxy_tokens_etcd.go similarity index 77% rename from proxy/proxy_tokens_etcd.go rename to cmd/proxy/proxy_tokens_etcd.go index 09dfc51..84dc66a 100644 --- a/proxy/proxy_tokens_etcd.go +++ b/cmd/proxy/proxy_tokens_etcd.go @@ -24,8 +24,8 @@ package main import ( "bytes" "context" + "errors" "fmt" - "log" "strings" "sync/atomic" "time" @@ -33,7 +33,9 @@ import ( "github.com/dlintw/goconf" "github.com/golang-jwt/jwt/v5" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + "github.com/strukturag/nextcloud-spreed-signaling/v2/container" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) const ( @@ -46,25 +48,27 @@ type tokenCacheEntry struct { } type tokensEtcd struct { - client *signaling.EtcdClient + logger log.Logger + client etcd.Client tokenFormats atomic.Value - tokenCache *signaling.LruCache + tokenCache *container.LruCache[*tokenCacheEntry] } -func NewProxyTokensEtcd(config *goconf.ConfigFile) (ProxyTokens, error) { - client, err := signaling.NewEtcdClient(config, "tokens") +func NewProxyTokensEtcd(logger log.Logger, config *goconf.ConfigFile) (ProxyTokens, error) { + client, err := etcd.NewClient(logger, config, "tokens") if err != nil { return nil, err } if !client.IsConfigured() { - return nil, fmt.Errorf("No etcd endpoints configured") + return nil, errors.New("no etcd endpoints configured") } result := &tokensEtcd{ + logger: logger, client: client, - tokenCache: signaling.NewLruCache(tokenCacheSize), + tokenCache: container.NewLruCache[*tokenCacheEntry](tokenCacheSize), } if err := result.load(config, false); err != nil { return nil, err @@ -94,11 +98,11 @@ func (t *tokensEtcd) getByKey(id string, key string) (*ProxyToken, error) { if len(resp.Kvs) == 0 { return nil, nil } else if len(resp.Kvs) > 1 { - log.Printf("Received multiple keys for %s, using last", key) + t.logger.Printf("Received multiple keys for %s, using last", key) } keyValue := resp.Kvs[len(resp.Kvs)-1].Value - cached, _ := t.tokenCache.Get(key).(*tokenCacheEntry) + cached := t.tokenCache.Get(key) if cached == nil || !bytes.Equal(cached.keyValue, keyValue) { // Parsed public keys are cached to avoid the parse overhead. publicKey, err := jwt.ParseRSAPublicKeyFromPEM(keyValue) @@ -123,7 +127,7 @@ func (t *tokensEtcd) Get(id string) (*ProxyToken, error) { for _, k := range t.getKeys(id) { token, err := t.getByKey(id, k) if err != nil { - log.Printf("Could not get public key from %s for %s: %s", k, id, err) + t.logger.Printf("Could not get public key from %s for %s: %s", k, id, err) continue } else if token == nil { continue @@ -151,18 +155,18 @@ func (t *tokensEtcd) load(config *goconf.ConfigFile, ignoreErrors bool) error { } t.tokenFormats.Store(tokenFormats) - log.Printf("Using %v as token formats", tokenFormats) + t.logger.Printf("Using %v as token formats", tokenFormats) return nil } func (t *tokensEtcd) Reload(config *goconf.ConfigFile) { if err := t.load(config, true); err != nil { - log.Printf("Error reloading etcd tokens: %s", err) + t.logger.Printf("Error reloading etcd tokens: %s", err) } } func (t *tokensEtcd) Close() { if err := t.client.Close(); err != nil { - log.Printf("Error while closing etcd client: %s", err) + t.logger.Printf("Error while closing etcd client: %s", err) } } diff --git a/proxy/proxy_tokens_etcd_test.go b/cmd/proxy/proxy_tokens_etcd_test.go similarity index 73% rename from proxy/proxy_tokens_etcd_test.go rename to cmd/proxy/proxy_tokens_etcd_test.go index 957f7d5..a278801 100644 --- a/proxy/proxy_tokens_etcd_test.go +++ b/cmd/proxy/proxy_tokens_etcd_test.go @@ -27,13 +27,10 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" - "errors" "net" "net/url" "os" - "runtime" "strconv" - "syscall" "testing" "github.com/dlintw/goconf" @@ -41,38 +38,23 @@ import ( "github.com/stretchr/testify/require" "go.etcd.io/etcd/server/v3/embed" "go.etcd.io/etcd/server/v3/lease" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" ) var ( etcdListenUrl = "http://localhost:8080" ) -func isErrorAddressAlreadyInUse(err error) bool { - var eOsSyscall *os.SyscallError - if !errors.As(err, &eOsSyscall) { - return false - } - var errErrno syscall.Errno // doesn't need a "*" (ptr) because it's already a ptr (uintptr) - if !errors.As(eOsSyscall, &errErrno) { - return false - } - if errErrno == syscall.EADDRINUSE { - return true - } - const WSAEADDRINUSE = 10048 - if runtime.GOOS == "windows" && errErrno == WSAEADDRINUSE { - return true - } - return false -} - func newEtcdForTesting(t *testing.T) *embed.Etcd { cfg := embed.NewConfig() cfg.Dir = t.TempDir() os.Chmod(cfg.Dir, 0700) // nolint cfg.LogLevel = "warn" + cfg.ZapLoggerBuilder = embed.NewZapLoggerBuilder(zaptest.NewLogger(t, zaptest.Level(zap.WarnLevel))) u, err := url.Parse(etcdListenUrl) require.NoError(t, err) @@ -89,7 +71,7 @@ func newEtcdForTesting(t *testing.T) *embed.Etcd { peerListener.Host = net.JoinHostPort("localhost", strconv.Itoa(port+2)) cfg.ListenPeerUrls = []url.URL{*peerListener} etcd, err = embed.StartEtcd(cfg) - if isErrorAddressAlreadyInUse(err) { + if test.IsErrorAddressAlreadyInUse(err) { continue } @@ -115,7 +97,8 @@ func newTokensEtcdForTesting(t *testing.T) (*tokensEtcd, *embed.Etcd) { cfg.AddOption("etcd", "endpoints", etcd.Config().ListenClientUrls[0].String()) cfg.AddOption("tokens", "keyformat", "/%s, /testing/%s/key") - tokens, err := NewProxyTokensEtcd(cfg) + logger := logtest.NewLoggerForTest(t) + tokens, err := NewProxyTokensEtcd(logger, cfg) require.NoError(t, err) t.Cleanup(func() { tokens.Close() @@ -132,7 +115,7 @@ func storeKey(t *testing.T, etcd *embed.Etcd, key string, pubkey crypto.PublicKe data, err = x509.MarshalPKIXPublicKey(&pubkey) require.NoError(t, err) default: - require.Fail(t, "unknown key type %T in %+v", pubkey, pubkey) + require.Fail(t, "unknown key type", "type %T in %+v", pubkey, pubkey) } data = pem.EncodeToMemory(&pem.Block{ @@ -155,7 +138,7 @@ func generateAndSaveKey(t *testing.T, etcd *embed.Etcd, name string) *rsa.Privat } func TestProxyTokensEtcd(t *testing.T) { - signaling.CatchLogForTest(t) + t.Parallel() assert := assert.New(t) tokens, etcd := newTokensEtcdForTesting(t) @@ -170,3 +153,34 @@ func TestProxyTokensEtcd(t *testing.T) { assert.True(key2.PublicKey.Equal(token.key)) } } + +func TestProxyTokensEtcdReload(t *testing.T) { + t.Parallel() + assert := assert.New(t) + tokens, etcd := newTokensEtcdForTesting(t) + + key1 := generateAndSaveKey(t, etcd, "/foo") + + if token, err := tokens.Get("foo"); assert.NoError(err) && assert.NotNil(token) { + assert.True(key1.PublicKey.Equal(token.key)) + } + + if token, err := tokens.Get("bar"); assert.NoError(err) { + assert.Nil(token) + } + + cfg := goconf.NewConfigFile() + cfg.AddOption("etcd", "endpoints", etcd.Config().ListenClientUrls[0].String()) + cfg.AddOption("tokens", "keyformat", "/reload/%s/key") + + tokens.Reload(cfg) + key2 := generateAndSaveKey(t, etcd, "/reload/bar/key") + + if token, err := tokens.Get("foo"); assert.NoError(err) { + assert.Nil(token) + } + + if token, err := tokens.Get("bar"); assert.NoError(err) && assert.NotNil(token) { + assert.True(key2.PublicKey.Equal(token.key)) + } +} diff --git a/proxy/proxy_tokens_static.go b/cmd/proxy/proxy_tokens_static.go similarity index 69% rename from proxy/proxy_tokens_static.go rename to cmd/proxy/proxy_tokens_static.go index 8de255a..6e68509 100644 --- a/proxy/proxy_tokens_static.go +++ b/cmd/proxy/proxy_tokens_static.go @@ -23,23 +23,26 @@ package main import ( "fmt" - "log" "os" - "sort" + "slices" "sync/atomic" "github.com/dlintw/goconf" "github.com/golang-jwt/jwt/v5" - signaling "github.com/strukturag/nextcloud-spreed-signaling" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) type tokensStatic struct { + logger log.Logger tokenKeys atomic.Value } -func NewProxyTokensStatic(config *goconf.ConfigFile) (ProxyTokens, error) { - result := &tokensStatic{} +func NewProxyTokensStatic(logger log.Logger, config *goconf.ConfigFile) (ProxyTokens, error) { + result := &tokensStatic{ + logger: logger, + } if err := result.load(config, false); err != nil { return nil, err } @@ -61,8 +64,8 @@ func (t *tokensStatic) Get(id string) (*ProxyToken, error) { return token, nil } -func (t *tokensStatic) load(config *goconf.ConfigFile, ignoreErrors bool) error { - options, err := signaling.GetStringOptions(config, "tokens", ignoreErrors) +func (t *tokensStatic) load(cfg *goconf.ConfigFile, ignoreErrors bool) error { + options, err := config.GetStringOptions(cfg, "tokens", ignoreErrors) if err != nil { return err } @@ -71,29 +74,29 @@ func (t *tokensStatic) load(config *goconf.ConfigFile, ignoreErrors bool) error for id, filename := range options { if filename == "" { if !ignoreErrors { - return fmt.Errorf("No filename given for token %s", id) + return fmt.Errorf("no filename given for token %s", id) } - log.Printf("No filename given for token %s, ignoring", id) + t.logger.Printf("No filename given for token %s, ignoring", id) continue } keyData, err := os.ReadFile(filename) if err != nil { if !ignoreErrors { - return fmt.Errorf("Could not read public key from %s: %s", filename, err) + return fmt.Errorf("could not read public key from %s: %w", filename, err) } - log.Printf("Could not read public key from %s, ignoring: %s", filename, err) + t.logger.Printf("Could not read public key from %s, ignoring: %s", filename, err) continue } key, err := jwt.ParseRSAPublicKeyFromPEM(keyData) if err != nil { if !ignoreErrors { - return fmt.Errorf("Could not parse public key from %s: %s", filename, err) + return fmt.Errorf("could not parse public key from %s: %w", filename, err) } - log.Printf("Could not parse public key from %s, ignoring: %s", filename, err) + t.logger.Printf("Could not parse public key from %s, ignoring: %s", filename, err) continue } @@ -104,14 +107,14 @@ func (t *tokensStatic) load(config *goconf.ConfigFile, ignoreErrors bool) error } if len(tokenKeys) == 0 { - log.Printf("No token keys loaded") + t.logger.Printf("No token keys loaded") } else { var keyIds []string for k := range tokenKeys { keyIds = append(keyIds, k) } - sort.Strings(keyIds) - log.Printf("Enabled token keys: %v", keyIds) + slices.Sort(keyIds) + t.logger.Printf("Enabled token keys: %v", keyIds) } t.setTokenKeys(tokenKeys) return nil @@ -119,7 +122,7 @@ func (t *tokensStatic) load(config *goconf.ConfigFile, ignoreErrors bool) error func (t *tokensStatic) Reload(config *goconf.ConfigFile) { if err := t.load(config, true); err != nil { - log.Printf("Error reloading static tokens: %s", err) + t.logger.Printf("Error reloading static tokens: %s", err) } } diff --git a/cmd/proxy/proxy_tokens_static_test.go b/cmd/proxy/proxy_tokens_static_test.go new file mode 100644 index 0000000..a7bc0fa --- /dev/null +++ b/cmd/proxy/proxy_tokens_static_test.go @@ -0,0 +1,185 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "os" + "path" + "testing" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" +) + +func TestStaticTokens(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + filename := path.Join(t.TempDir(), "token.pub") + + key1, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + require.NoError(internal.WritePublicKey(&key1.PublicKey, filename)) + + logger := logtest.NewLoggerForTest(t) + config := goconf.NewConfigFile() + config.AddOption("tokens", "foo", filename) + + tokens, err := NewProxyTokensStatic(logger, config) + require.NoError(err) + + defer tokens.Close() + + if token, err := tokens.Get("foo"); assert.NoError(err) { + assert.Equal("foo", token.id) + assert.True(key1.PublicKey.Equal(token.key)) + } + + key2, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + require.NoError(internal.WritePublicKey(&key2.PublicKey, filename)) + + tokens.Reload(config) + + if token, err := tokens.Get("foo"); assert.NoError(err) { + assert.Equal("foo", token.id) + assert.True(key2.PublicKey.Equal(token.key)) + } +} + +func testStaticTokensMissing(t *testing.T, reload bool) { + require := require.New(t) + assert := assert.New(t) + + filename := path.Join(t.TempDir(), "token.pub") + + logger := logtest.NewLoggerForTest(t) + config := goconf.NewConfigFile() + if !reload { + config.AddOption("tokens", "foo", filename) + } + + tokens, err := NewProxyTokensStatic(logger, config) + if !reload { + assert.ErrorIs(err, os.ErrNotExist) + return + } + + require.NoError(err) + defer tokens.Close() + + config.AddOption("tokens", "foo", filename) + tokens.Reload(config) +} + +func TestStaticTokensMissing(t *testing.T) { + t.Parallel() + + testStaticTokensMissing(t, false) +} + +func TestStaticTokensMissingReload(t *testing.T) { + t.Parallel() + + testStaticTokensMissing(t, true) +} + +func testStaticTokensEmpty(t *testing.T, reload bool) { + require := require.New(t) + assert := assert.New(t) + + logger := logtest.NewLoggerForTest(t) + config := goconf.NewConfigFile() + if !reload { + config.AddOption("tokens", "foo", "") + } + + tokens, err := NewProxyTokensStatic(logger, config) + if !reload { + assert.ErrorContains(err, "no filename given") + return + } + + require.NoError(err) + defer tokens.Close() + + config.AddOption("tokens", "foo", "") + tokens.Reload(config) +} + +func TestStaticTokensEmpty(t *testing.T) { + t.Parallel() + + testStaticTokensEmpty(t, false) +} + +func TestStaticTokensEmptyReload(t *testing.T) { + t.Parallel() + + testStaticTokensEmpty(t, true) +} + +func testStaticTokensInvalidData(t *testing.T, reload bool) { + require := require.New(t) + assert := assert.New(t) + + filename := path.Join(t.TempDir(), "token.pub") + require.NoError(os.WriteFile(filename, []byte("invalid-key-data"), 0600)) + + logger := logtest.NewLoggerForTest(t) + config := goconf.NewConfigFile() + if !reload { + config.AddOption("tokens", "foo", filename) + } + + tokens, err := NewProxyTokensStatic(logger, config) + if !reload { + assert.ErrorContains(err, "could not parse public key") + return + } + + require.NoError(err) + defer tokens.Close() + + config.AddOption("tokens", "foo", filename) + tokens.Reload(config) +} + +func TestStaticTokensInvalidData(t *testing.T) { + t.Parallel() + + testStaticTokensInvalidData(t, false) +} + +func TestStaticTokensInvalidDataReload(t *testing.T) { + t.Parallel() + + testStaticTokensInvalidData(t, true) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..cd93789 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,437 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package main + +import ( + "context" + "crypto/tls" + "errors" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "runtime" + runtimepprof "runtime/pprof" + "sync" + "syscall" + "time" + + "github.com/dlintw/goconf" + "github.com/gorilla/mux" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + signalinglog "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" + "github.com/strukturag/nextcloud-spreed-signaling/v2/server" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/proxy" +) + +var ( + version = "unreleased" + + configFlag = flag.String("config", "server.conf", "config file to use") + + cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") + + memprofile = flag.String("memprofile", "", "write memory profile to file") + + showVersion = flag.Bool("version", false, "show version and quit") +) + +const ( + defaultReadTimeout = 15 + defaultWriteTimeout = 30 + + initialMcuRetry = time.Second + maxMcuRetry = time.Second * 16 + + dnsMonitorInterval = time.Second +) + +func createListener(addr string) (net.Listener, error) { + if addr[0] == '/' { + os.Remove(addr) + return net.Listen("unix", addr) + } + + return net.Listen("tcp", addr) +} + +func createTLSListener(addr string, certFile, keyFile string) (net.Listener, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + config := tls.Config{ + Certificates: []tls.Certificate{cert}, + } + if addr[0] == '/' { + os.Remove(addr) + return tls.Listen("unix", addr, &config) + } + + return tls.Listen("tcp", addr, &config) +} + +type Listeners struct { + logger signalinglog.Logger // +checklocksignore + mu sync.Mutex + // +checklocks:mu + listeners []net.Listener +} + +func (l *Listeners) Add(listener net.Listener) { + l.mu.Lock() + defer l.mu.Unlock() + + l.listeners = append(l.listeners, listener) +} + +func (l *Listeners) Close() { + l.mu.Lock() + defer l.mu.Unlock() + + for _, listener := range l.listeners { + if err := listener.Close(); err != nil { + l.logger.Printf("Error closing listener %s: %s", listener.Addr(), err) + } + } +} + +func main() { + log.SetFlags(log.Lshortfile) + flag.Parse() + + if *showVersion { + fmt.Printf("nextcloud-spreed-signaling version %s/%s\n", version, runtime.Version()) + os.Exit(0) + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGHUP) + signal.Notify(sigChan, syscall.SIGUSR1) + + stopCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + logger := log.Default() + stopCtx = signalinglog.NewLoggerContext(stopCtx, logger) + + if *cpuprofile != "" { + f, err := os.Create(*cpuprofile) + if err != nil { + logger.Fatal(err) + } + + if err := runtimepprof.StartCPUProfile(f); err != nil { + logger.Fatalf("Error writing CPU profile to %s: %s", *cpuprofile, err) + } + logger.Printf("Writing CPU profile to %s ...", *cpuprofile) + defer runtimepprof.StopCPUProfile() + } + + if *memprofile != "" { + f, err := os.Create(*memprofile) + if err != nil { + logger.Fatal(err) + } + + defer func() { + logger.Printf("Writing Memory profile to %s ...", *memprofile) + runtime.GC() + if err := runtimepprof.WriteHeapProfile(f); err != nil { + logger.Printf("Error writing Memory profile to %s: %s", *memprofile, err) + } + }() + } + + logger.Printf("Starting up version %s/%s as pid %d", version, runtime.Version(), os.Getpid()) + + cfg, err := goconf.ReadConfigFile(*configFlag) + if err != nil { + logger.Fatal("Could not read configuration: ", err) + } + + logger.Printf("Using a maximum of %d CPUs", runtime.GOMAXPROCS(0)) + + server.RegisterStats() + + natsUrl, _ := config.GetStringOptionWithEnv(cfg, "nats", "url") + if natsUrl == "" { + natsUrl = nats.DefaultURL + } + + events, err := events.NewAsyncEvents(stopCtx, natsUrl) + if err != nil { + logger.Fatal("Could not create async events client: ", err) + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := events.Close(ctx); err != nil { + logger.Printf("Error closing events handler: %s", err) + } + }() + + dnsMonitor, err := dns.NewMonitor(logger, dnsMonitorInterval, nil) + if err != nil { + logger.Fatal("Could not create DNS monitor: ", err) + } + if err := dnsMonitor.Start(); err != nil { + logger.Fatal("Could not start DNS monitor: ", err) + } + defer dnsMonitor.Stop() + + etcdClient, err := etcd.NewClient(logger, cfg, "mcu") + if err != nil { + logger.Fatalf("Could not create etcd client: %s", err) + } + defer func() { + if err := etcdClient.Close(); err != nil { + logger.Printf("Error while closing etcd client: %s", err) + } + }() + + rpcServer, err := grpc.NewServer(stopCtx, cfg, version) + if err != nil { + logger.Fatalf("Could not create RPC server: %s", err) + } + go func() { + if err := rpcServer.Run(); err != nil { + logger.Fatalf("Could not start RPC server: %s", err) + } + }() + defer rpcServer.Close() + + rpcClients, err := grpc.NewClients(stopCtx, cfg, etcdClient, dnsMonitor, version) + if err != nil { + logger.Fatalf("Could not create RPC clients: %s", err) + } + defer rpcClients.Close() + + r := mux.NewRouter() + hub, err := server.NewHub(stopCtx, cfg, events, rpcServer, rpcClients, etcdClient, r, version) + if err != nil { + logger.Fatal("Could not create hub: ", err) + } + + mcuUrl, _ := config.GetStringOptionWithEnv(cfg, "mcu", "url") + mcuType, _ := cfg.GetString("mcu", "type") + if mcuType == "" && mcuUrl != "" { + logger.Printf("WARNING: Old-style MCU configuration detected with url but no type, defaulting to type %s", sfu.TypeJanus) + mcuType = sfu.TypeJanus + } else if mcuType == sfu.TypeJanus && mcuUrl == "" { + logger.Printf("WARNING: Old-style MCU configuration detected with type but no url, disabling") + mcuType = "" + } + + if mcuType != "" { + var mcu sfu.SFU + mcuRetry := initialMcuRetry + mcuRetryTimer := time.NewTimer(mcuRetry) + mcuTypeLoop: + for { + // Context should be cancelled on signals but need a way to differentiate later. + ctx := context.TODO() + switch mcuType { + case sfu.TypeJanus: + mcu, err = janus.NewJanusSFU(ctx, mcuUrl, cfg) + proxy.UnregisterStats() + janus.RegisterStats() + case sfu.TypeProxy: + mcu, err = proxy.NewProxySFU(ctx, cfg, etcdClient, rpcClients, dnsMonitor) + janus.UnregisterStats() + proxy.RegisterStats() + default: + logger.Fatal("Unsupported MCU type: ", mcuType) + } + if err == nil { + err = mcu.Start(ctx) + if err != nil { + logger.Printf("Could not create %s MCU: %s", mcuType, err) + } + } + if err == nil { + break + } + + logger.Printf("Could not initialize %s MCU (%s) will retry in %s", mcuType, err, mcuRetry) + mcuRetryTimer.Reset(mcuRetry) + select { + case <-stopCtx.Done(): + logger.Fatalf("Cancelled") + case sig := <-sigChan: + switch sig { + case syscall.SIGHUP: + logger.Printf("Received SIGHUP, reloading %s", *configFlag) + if cfg, err = goconf.ReadConfigFile(*configFlag); err != nil { + logger.Printf("Could not read configuration from %s: %s", *configFlag, err) + } else { + mcuUrl, _ = config.GetStringOptionWithEnv(cfg, "mcu", "url") + mcuType, _ = cfg.GetString("mcu", "type") + if mcuType == "" && mcuUrl != "" { + logger.Printf("WARNING: Old-style MCU configuration detected with url but no type, defaulting to type %s", sfu.TypeJanus) + mcuType = sfu.TypeJanus + } else if mcuType == sfu.TypeJanus && mcuUrl == "" { + logger.Printf("WARNING: Old-style MCU configuration detected with type but no url, disabling") + mcuType = "" + break mcuTypeLoop + } + } + } + case <-mcuRetryTimer.C: + // Retry connection + mcuRetry = min(mcuRetry*2, maxMcuRetry) + } + } + if mcu != nil { + defer mcu.Stop() + + logger.Printf("Using %s MCU", mcuType) + hub.SetMcu(mcu) + } + } + + go hub.Run() + defer hub.Stop() + + server, err := server.NewBackendServer(stopCtx, cfg, hub, version) + if err != nil { + logger.Fatal("Could not create backend server: ", err) + } + if err := server.Start(r); err != nil { + logger.Fatal("Could not start backend server: ", err) + } + + listeners := Listeners{ + logger: logger, + } + + if saddr, _ := config.GetStringOptionWithEnv(cfg, "https", "listen"); saddr != "" { + cert, _ := cfg.GetString("https", "certificate") + key, _ := cfg.GetString("https", "key") + if cert == "" || key == "" { + logger.Fatal("Need a certificate and key for the HTTPS listener") + } + + readTimeout, _ := cfg.GetInt("https", "readtimeout") + if readTimeout <= 0 { + readTimeout = defaultReadTimeout + } + writeTimeout, _ := cfg.GetInt("https", "writetimeout") + if writeTimeout <= 0 { + writeTimeout = defaultWriteTimeout + } + for address := range internal.SplitEntries(saddr, " ") { + go func(address string) { + logger.Println("Listening on", address) + listener, err := createTLSListener(address, cert, key) + if err != nil { + logger.Fatal("Could not start listening: ", err) + } + srv := &http.Server{ + Handler: r, + + ReadTimeout: time.Duration(readTimeout) * time.Second, + WriteTimeout: time.Duration(writeTimeout) * time.Second, + } + listeners.Add(listener) + if err := srv.Serve(listener); err != nil { + if !hub.IsShutdownScheduled() || !errors.Is(err, net.ErrClosed) { + logger.Fatal("Could not start server: ", err) + } + } + }(address) + } + } + + if addr, _ := config.GetStringOptionWithEnv(cfg, "http", "listen"); addr != "" { + readTimeout, _ := cfg.GetInt("http", "readtimeout") + if readTimeout <= 0 { + readTimeout = defaultReadTimeout + } + writeTimeout, _ := cfg.GetInt("http", "writetimeout") + if writeTimeout <= 0 { + writeTimeout = defaultWriteTimeout + } + + for address := range internal.SplitEntries(addr, " ") { + go func(address string) { + logger.Println("Listening on", address) + listener, err := createListener(address) + if err != nil { + logger.Fatal("Could not start listening: ", err) + } + srv := &http.Server{ + Handler: r, + Addr: addr, + + ReadTimeout: time.Duration(readTimeout) * time.Second, + WriteTimeout: time.Duration(writeTimeout) * time.Second, + } + listeners.Add(listener) + if err := srv.Serve(listener); err != nil { + if !hub.IsShutdownScheduled() || !errors.Is(err, net.ErrClosed) { + logger.Fatal("Could not start server: ", err) + } + } + }(address) + } + } + +loop: + for { + select { + case <-stopCtx.Done(): + logger.Println("Interrupted") + break loop + case sig := <-sigChan: + switch sig { + case syscall.SIGHUP: + logger.Printf("Received SIGHUP, reloading %s", *configFlag) + if config, err := goconf.ReadConfigFile(*configFlag); err != nil { + logger.Printf("Could not read configuration from %s: %s", *configFlag, err) + } else { + hub.Reload(stopCtx, config) + server.Reload(config) + } + case syscall.SIGUSR1: + logger.Printf("Received SIGUSR1, scheduling server to shutdown") + hub.ScheduleShutdown() + listeners.Close() + } + case <-hub.ShutdownChannel(): + logger.Printf("All clients disconnected, shutting down") + break loop + } + } +} diff --git a/config.go b/config/config.go similarity index 92% rename from config.go rename to config/config.go index c3a006e..719cc67 100644 --- a/config.go +++ b/config/config.go @@ -19,14 +19,15 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package config import ( - "errors" "os" "regexp" "github.com/dlintw/goconf" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" ) var ( @@ -71,8 +72,7 @@ func GetStringOptions(config *goconf.ConfigFile, section string, ignoreErrors bo continue } - var ge goconf.GetError - if errors.As(err, &ge) && ge.Reason == goconf.OptionNotFound { + if ge, ok := internal.AsErrorType[goconf.GetError](err); ok && ge.Reason == goconf.OptionNotFound { // Skip options from "default" section. continue } diff --git a/config_test.go b/config/config_test.go similarity index 99% rename from config_test.go rename to config/config_test.go index f0cc619..7727138 100644 --- a/config_test.go +++ b/config/config_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package config import ( "testing" diff --git a/concurrentmap.go b/container/concurrentmap.go similarity index 65% rename from concurrentmap.go rename to container/concurrentmap.go index 1a4da0d..8170446 100644 --- a/concurrentmap.go +++ b/container/concurrentmap.go @@ -19,47 +19,48 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package container import ( "sync" ) -type ConcurrentStringStringMap struct { - sync.Mutex - d map[string]string +type ConcurrentMap[K comparable, V any] struct { + mu sync.RWMutex + // +checklocks:mu + d map[K]V } -func (m *ConcurrentStringStringMap) Set(key, value string) { - m.Lock() - defer m.Unlock() +func (m *ConcurrentMap[K, V]) Set(key K, value V) { + m.mu.Lock() + defer m.mu.Unlock() if m.d == nil { - m.d = make(map[string]string) + m.d = make(map[K]V) } m.d[key] = value } -func (m *ConcurrentStringStringMap) Get(key string) (string, bool) { - m.Lock() - defer m.Unlock() +func (m *ConcurrentMap[K, V]) Get(key K) (V, bool) { + m.mu.RLock() + defer m.mu.RUnlock() s, found := m.d[key] return s, found } -func (m *ConcurrentStringStringMap) Del(key string) { - m.Lock() - defer m.Unlock() +func (m *ConcurrentMap[K, V]) Del(key K) { + m.mu.Lock() + defer m.mu.Unlock() delete(m.d, key) } -func (m *ConcurrentStringStringMap) Len() int { - m.Lock() - defer m.Unlock() +func (m *ConcurrentMap[K, V]) Len() int { + m.mu.RLock() + defer m.mu.RUnlock() return len(m.d) } -func (m *ConcurrentStringStringMap) Clear() { - m.Lock() - defer m.Unlock() +func (m *ConcurrentMap[K, V]) Clear() { + m.mu.Lock() + defer m.mu.Unlock() m.d = nil } diff --git a/concurrentmap_test.go b/container/concurrentmap_test.go similarity index 83% rename from concurrentmap_test.go rename to container/concurrentmap_test.go index cca1d29..98b126d 100644 --- a/concurrentmap_test.go +++ b/container/concurrentmap_test.go @@ -19,9 +19,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package container import ( + "crypto/rand" "strconv" "sync" "testing" @@ -30,8 +31,9 @@ import ( ) func TestConcurrentStringStringMap(t *testing.T) { + t.Parallel() assert := assert.New(t) - var m ConcurrentStringStringMap + var m ConcurrentMap[string, string] assert.Equal(0, m.Len()) v, found := m.Get("foo") assert.False(found, "Expected missing entry, got %s", v) @@ -76,21 +78,22 @@ func TestConcurrentStringStringMap(t *testing.T) { var wg sync.WaitGroup concurrency := 100 count := 1000 - for x := 0; x < concurrency; x++ { - wg.Add(1) - go func(x int) { - defer wg.Done() - + for x := range concurrency { + wg.Go(func() { key := "key-" + strconv.Itoa(x) - for y := 0; y < count; y = y + 1 { - value := newRandomString(32) + rnd := rand.Text() + for y := range count { + value := rnd + "-" + strconv.Itoa(y) m.Set(key, value) - if v, found := m.Get(key); !assert.True(found, "Expected entry for key %s", key) || - !assert.Equal(value, v, "Unexpected value for key %s", key) { + if v, found := m.Get(key); !found { + assert.True(found, "Expected entry for key %s", key) + return + } else if v != value { + assert.Equal(value, v, "Unexpected value for key %s", key) return } } - }(x) + }) } wg.Wait() assert.Equal(concurrency, m.Len()) diff --git a/allowed_ips.go b/container/ip_list.go similarity index 69% rename from allowed_ips.go rename to container/ip_list.go index f401ec6..ce6ed24 100644 --- a/allowed_ips.go +++ b/container/ip_list.go @@ -19,23 +19,26 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package container import ( "bytes" "fmt" "net" + "slices" "strings" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" ) -type AllowedIps struct { - allowed []*net.IPNet +type IPList struct { + ips []*net.IPNet } -func (a *AllowedIps) String() string { +func (a *IPList) String() string { var b bytes.Buffer b.WriteString("[") - for idx, n := range a.allowed { + for idx, n := range a.ips { if idx > 0 { b.WriteString(", ") } @@ -45,18 +48,14 @@ func (a *AllowedIps) String() string { return b.String() } -func (a *AllowedIps) Empty() bool { - return len(a.allowed) == 0 +func (a *IPList) Empty() bool { + return len(a.ips) == 0 } -func (a *AllowedIps) Allowed(ip net.IP) bool { - for _, i := range a.allowed { - if i.Contains(ip) { - return true - } - } - - return false +func (a *IPList) Contains(ip net.IP) bool { + return slices.ContainsFunc(a.ips, func(n *net.IPNet) bool { + return n.Contains(ip) + }) } func parseIPNet(s string) (*net.IPNet, error) { @@ -81,35 +80,36 @@ func parseIPNet(s string) (*net.IPNet, error) { return ipnet, nil } -func ParseAllowedIps(allowed string) (*AllowedIps, error) { +func ParseIPList(allowed string) (*IPList, error) { var allowedIps []*net.IPNet - for _, ip := range strings.Split(allowed, ",") { - ip = strings.TrimSpace(ip) - if ip != "" { - i, err := parseIPNet(ip) - if err != nil { - return nil, err - } - allowedIps = append(allowedIps, i) + for ip := range internal.SplitEntries(allowed, ",") { + i, err := parseIPNet(ip) + if err != nil { + return nil, err } + allowedIps = append(allowedIps, i) } - result := &AllowedIps{ - allowed: allowedIps, + result := &IPList{ + ips: allowedIps, } return result, nil } -func DefaultAllowedIps() *AllowedIps { +func DefaultAllowedIPs() *IPList { allowedIps := []*net.IPNet{ { IP: net.ParseIP("127.0.0.1"), Mask: net.CIDRMask(32, 32), }, + { + IP: net.ParseIP("::1"), + Mask: net.CIDRMask(128, 128), + }, } - result := &AllowedIps{ - allowed: allowedIps, + result := &IPList{ + ips: allowedIps, } return result } @@ -118,6 +118,7 @@ var ( privateIpNets = []string{ // Loopback addresses. "127.0.0.0/8", + "::1", // Private addresses. "10.0.0.0/8", "172.16.0.0/12", @@ -125,8 +126,8 @@ var ( } ) -func DefaultPrivateIps() *AllowedIps { - allowed, err := ParseAllowedIps(strings.Join(privateIpNets, ",")) +func DefaultPrivateIPs() *IPList { + allowed, err := ParseIPList(strings.Join(privateIpNets, ",")) if err != nil { panic(fmt.Errorf("could not parse private ips %+v: %w", privateIpNets, err)) } diff --git a/allowed_ips_test.go b/container/ip_list_test.go similarity index 56% rename from allowed_ips_test.go rename to container/ip_list_test.go index da4f49b..73e6a2e 100644 --- a/allowed_ips_test.go +++ b/container/ip_list_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package container import ( "net" @@ -29,39 +29,67 @@ import ( "github.com/stretchr/testify/require" ) -func TestAllowedIps(t *testing.T) { +func TestIPList(t *testing.T) { + t.Parallel() require := require.New(t) - a, err := ParseAllowedIps("127.0.0.1, 192.168.0.1, 192.168.1.1/24") + a, err := ParseIPList("127.0.0.1, 192.168.0.1, 192.168.1.1/24") require.NoError(err) require.False(a.Empty()) require.Equal(`[127.0.0.1/32, 192.168.0.1/32, 192.168.1.0/24]`, a.String()) - allowed := []string{ + contained := []string{ "127.0.0.1", "192.168.0.1", "192.168.1.1", "192.168.1.100", } - notAllowed := []string{ + notContained := []string{ "192.168.0.2", "10.1.2.3", } - for _, addr := range allowed { + for _, addr := range contained { t.Run(addr, func(t *testing.T) { + t.Parallel() assert := assert.New(t) if ip := net.ParseIP(addr); assert.NotNil(ip, "error parsing %s", addr) { - assert.True(a.Allowed(ip), "should allow %s", addr) + assert.True(a.Contains(ip), "should contain %s", addr) } }) } - for _, addr := range notAllowed { + for _, addr := range notContained { t.Run(addr, func(t *testing.T) { + t.Parallel() assert := assert.New(t) if ip := net.ParseIP(addr); assert.NotNil(ip, "error parsing %s", addr) { - assert.False(a.Allowed(ip), "should not allow %s", addr) + assert.False(a.Contains(ip), "should not contain %s", addr) } }) } } + +func TestDefaultAllowedIPs(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + ips := DefaultAllowedIPs() + assert.True(ips.Contains(net.ParseIP("127.0.0.1"))) + assert.False(ips.Contains(net.ParseIP("127.1.0.1"))) + assert.True(ips.Contains(net.ParseIP("::1"))) + assert.False(ips.Contains(net.ParseIP("1.1.1.1"))) +} + +func TestDefaultPrivateIPs(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + ips := DefaultPrivateIPs() + assert.True(ips.Contains(net.ParseIP("127.0.0.1"))) + assert.True(ips.Contains(net.ParseIP("127.1.0.1"))) + assert.True(ips.Contains(net.ParseIP("::1"))) + assert.True(ips.Contains(net.ParseIP("10.1.2.3"))) + assert.True(ips.Contains(net.ParseIP("172.16.17.18"))) + assert.True(ips.Contains(net.ParseIP("192.168.10.20"))) + assert.False(ips.Contains(net.ParseIP("1.1.1.1"))) +} diff --git a/lru.go b/container/lru.go similarity index 66% rename from lru.go rename to container/lru.go index 1563d01..2c7992e 100644 --- a/lru.go +++ b/container/lru.go @@ -19,43 +19,45 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package container import ( "container/list" "sync" ) -type cacheEntry struct { +type cacheEntry[T any] struct { key string - value interface{} + value T } -type LruCache struct { - size int - mu sync.Mutex +type LruCache[T any] struct { + size int // +checklocksignore: Only written to from constructor. + mu sync.Mutex + // +checklocks:mu entries *list.List - data map[string]*list.Element + // +checklocks:mu + data map[string]*list.Element } -func NewLruCache(size int) *LruCache { - return &LruCache{ +func NewLruCache[T any](size int) *LruCache[T] { + return &LruCache[T]{ size: size, entries: list.New(), data: make(map[string]*list.Element), } } -func (c *LruCache) Set(key string, value interface{}) { +func (c *LruCache[T]) Set(key string, value T) { c.mu.Lock() if v, found := c.data[key]; found { c.entries.MoveToFront(v) - v.Value.(*cacheEntry).value = value + v.Value.(*cacheEntry[T]).value = value c.mu.Unlock() return } - v := c.entries.PushFront(&cacheEntry{ + v := c.entries.PushFront(&cacheEntry[T]{ key: key, value: value, }) @@ -66,20 +68,21 @@ func (c *LruCache) Set(key string, value interface{}) { c.mu.Unlock() } -func (c *LruCache) Get(key string) interface{} { +func (c *LruCache[T]) Get(key string) T { c.mu.Lock() if v, found := c.data[key]; found { c.entries.MoveToFront(v) - value := v.Value.(*cacheEntry).value + value := v.Value.(*cacheEntry[T]).value c.mu.Unlock() return value } c.mu.Unlock() - return nil + var defaultValue T + return defaultValue } -func (c *LruCache) Remove(key string) { +func (c *LruCache[T]) Remove(key string) { c.mu.Lock() if v, found := c.data[key]; found { c.removeElement(v) @@ -87,26 +90,28 @@ func (c *LruCache) Remove(key string) { c.mu.Unlock() } -func (c *LruCache) removeOldestLocked() { +// +checklocks:c.mu +func (c *LruCache[T]) removeOldestLocked() { v := c.entries.Back() if v != nil { c.removeElement(v) } } -func (c *LruCache) RemoveOldest() { +func (c *LruCache[T]) RemoveOldest() { c.mu.Lock() c.removeOldestLocked() c.mu.Unlock() } -func (c *LruCache) removeElement(e *list.Element) { +// +checklocks:c.mu +func (c *LruCache[T]) removeElement(e *list.Element) { c.entries.Remove(e) - entry := e.Value.(*cacheEntry) + entry := e.Value.(*cacheEntry[T]) delete(c.data, entry.key) } -func (c *LruCache) Len() int { +func (c *LruCache[T]) Len() int { c.mu.Lock() defer c.mu.Unlock() return c.entries.Len() diff --git a/lru_test.go b/container/lru_test.go similarity index 56% rename from lru_test.go rename to container/lru_test.go index 98fbb66..9446e86 100644 --- a/lru_test.go +++ b/container/lru_test.go @@ -19,109 +19,101 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package container import ( - "fmt" + "strconv" "testing" "github.com/stretchr/testify/assert" ) func TestLruUnbound(t *testing.T) { + t.Parallel() assert := assert.New(t) - lru := NewLruCache(0) + lru := NewLruCache[int](0) count := 10 - for i := 0; i < count; i++ { - key := fmt.Sprintf("%d", i) + for i := range count { + key := strconv.Itoa(i) lru.Set(key, i) } assert.Equal(count, lru.Len()) - for i := 0; i < count; i++ { - key := fmt.Sprintf("%d", i) - if value := lru.Get(key); assert.NotNil(value, "No value found for %s", key) { - assert.EqualValues(i, value) - } + for i := range count { + key := strconv.Itoa(i) + value := lru.Get(key) + assert.Equal(i, value, "Failed for %s", key) } // The first key ("0") is now the oldest. lru.RemoveOldest() assert.Equal(count-1, lru.Len()) - for i := 0; i < count; i++ { - key := fmt.Sprintf("%d", i) + for i := range count { + key := strconv.Itoa(i) value := lru.Get(key) - if i == 0 { - assert.Nil(value, "The value for key %s should have been removed", key) - continue - } else if assert.NotNil(value, "No value found for %s", key) { - assert.EqualValues(i, value) - } + assert.Equal(i, value, "Failed for %s", key) } // NOTE: Key "0" no longer exists below, so make sure to not set it again. // Using the same keys will update the ordering. for i := count - 1; i >= 1; i-- { - key := fmt.Sprintf("%d", i) + key := strconv.Itoa(i) lru.Set(key, i) } assert.Equal(count-1, lru.Len()) // NOTE: The same ordering as the Set calls above. for i := count - 1; i >= 1; i-- { - key := fmt.Sprintf("%d", i) - if value := lru.Get(key); assert.NotNil(value, "No value found for %s", key) { - assert.EqualValues(i, value) - } + key := strconv.Itoa(i) + value := lru.Get(key) + assert.Equal(i, value, "Failed for %s", key) } // The last key ("9") is now the oldest. lru.RemoveOldest() assert.Equal(count-2, lru.Len()) - for i := 0; i < count; i++ { - key := fmt.Sprintf("%d", i) + for i := range count { + key := strconv.Itoa(i) value := lru.Get(key) if i == 0 || i == count-1 { - assert.Nil(value, "The value for key %s should have been removed", key) - continue - } else if assert.NotNil(value, "No value found for %s", key) { - assert.EqualValues(i, value) + assert.Equal(0, value, "The value for key %s should have been removed", key) + } else { + assert.Equal(i, value, "Failed for %s", key) } } // Remove an arbitrary key from the cache - key := fmt.Sprintf("%d", count/2) + key := strconv.Itoa(count / 2) lru.Remove(key) assert.Equal(count-3, lru.Len()) - for i := 0; i < count; i++ { - key := fmt.Sprintf("%d", i) + for i := range count { + key := strconv.Itoa(i) value := lru.Get(key) if i == 0 || i == count-1 || i == count/2 { - assert.Nil(value, "The value for key %s should have been removed", key) - continue - } else if assert.NotNil(value, "No value found for %s", key) { - assert.EqualValues(i, value) + assert.Equal(0, value, "The value for key %s should have been removed", key) + } else { + assert.Equal(i, value, "Failed for %s", key) } } } func TestLruBound(t *testing.T) { + t.Parallel() assert := assert.New(t) size := 2 - lru := NewLruCache(size) + lru := NewLruCache[int](size) count := 10 - for i := 0; i < count; i++ { - key := fmt.Sprintf("%d", i) + for i := range count { + key := strconv.Itoa(i) lru.Set(key, i) } assert.Equal(size, lru.Len()) // Only the last "size" entries have been stored. - for i := 0; i < count; i++ { - key := fmt.Sprintf("%d", i) + for i := range count { + key := strconv.Itoa(i) value := lru.Get(key) if i < count-size { - assert.Nil(value, "The value for key %s should have been removed", key) - continue - } else if assert.NotNil(value, "No value found for %s", key) { - assert.EqualValues(i, value) + assert.Equal(0, value, "The value for key %s should have been removed", key) + } else { + assert.Equal(i, value, "Failed for %s", key) } } } diff --git a/dist/init/systemd/signaling.service b/dist/init/systemd/signaling.service index cdf67fa..21d75c3 100644 --- a/dist/init/systemd/signaling.service +++ b/dist/init/systemd/signaling.service @@ -12,7 +12,7 @@ ConfigurationDirectory=signaling # Hardening - see systemd.exec(5) CapabilityBoundingSet= -ExecPaths=/usr/bin/signaling /usr/lib +ExecPaths=/usr/bin/signaling /usr/lib /usr/lib64 LockPersonality=yes MemoryDenyWriteExecute=yes NoExecPaths=/ diff --git a/dns/internal/mock_lookup.go b/dns/internal/mock_lookup.go new file mode 100644 index 0000000..be7fcdf --- /dev/null +++ b/dns/internal/mock_lookup.go @@ -0,0 +1,71 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "net" + "sync" +) + +type MockLookup struct { + sync.RWMutex + + // +checklocks:RWMutex + ips map[string][]net.IP +} + +func NewMockLookup() *MockLookup { + mock := &MockLookup{ + ips: make(map[string][]net.IP), + } + return mock +} + +func (m *MockLookup) Set(host string, ips []net.IP) { + m.Lock() + defer m.Unlock() + + m.ips[host] = ips +} + +func (m *MockLookup) Get(host string) []net.IP { + m.Lock() + defer m.Unlock() + + return m.ips[host] +} + +func (m *MockLookup) Lookup(host string) ([]net.IP, error) { + m.RLock() + defer m.RUnlock() + + ips, found := m.ips[host] + if !found { + return nil, &net.DNSError{ + Err: "could not resolve " + host, + Name: host, + IsNotFound: true, + } + } + + return append([]net.IP{}, ips...), nil +} diff --git a/channel_waiter_test.go b/dns/internal/mock_lookup_test.go similarity index 54% rename from channel_waiter_test.go rename to dns/internal/mock_lookup_test.go index 9141642..9bcf7c8 100644 --- a/channel_waiter_test.go +++ b/dns/internal/mock_lookup_test.go @@ -1,6 +1,6 @@ /** * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2023 struktur AG + * Copyright (C) 2026 struktur AG * * @author Joachim Bauch * @@ -19,50 +19,40 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package internal import ( + "net" "testing" "github.com/stretchr/testify/assert" ) -func TestChannelWaiters(t *testing.T) { - var waiters ChannelWaiters +func TestMockLookup(t *testing.T) { + t.Parallel() - ch1 := make(chan struct{}, 1) - id1 := waiters.Add(ch1) - defer waiters.Remove(id1) + assert := assert.New(t) - ch2 := make(chan struct{}, 1) - id2 := waiters.Add(ch2) - defer waiters.Remove(id2) + host1 := "domain1.invalid" + host2 := "domain2.invalid" - waiters.Wakeup() - <-ch1 - <-ch2 + lookup := NewMockLookup() + assert.Empty(lookup.Get(host1)) + assert.Empty(lookup.Get(host2)) - select { - case <-ch1: - assert.Fail(t, "should have not received another event") - case <-ch2: - assert.Fail(t, "should have not received another event") - default: + ips := []net.IP{ + net.ParseIP("1.2.3.4"), } + lookup.Set(host1, ips) + assert.Equal(ips, lookup.Get(host1)) + assert.Empty(lookup.Get(host2)) - ch3 := make(chan struct{}, 1) - id3 := waiters.Add(ch3) - waiters.Remove(id3) - - // Multiple wakeups work even without processing. - waiters.Wakeup() - waiters.Wakeup() - waiters.Wakeup() - <-ch1 - <-ch2 - select { - case <-ch3: - assert.Fail(t, "should have not received another event") - default: + if resolved, err := lookup.Lookup(host1); assert.NoError(err) { + assert.Equal(ips, resolved) + } + var de *net.DNSError + if resolved, err := lookup.Lookup(host2); assert.ErrorAs(err, &de, "expected error, got %+v", resolved) { + assert.True(de.IsNotFound) + assert.Equal(host2, de.Name) } } diff --git a/dns_monitor.go b/dns/monitor.go similarity index 65% rename from dns_monitor.go rename to dns/monitor.go index 072e01c..05e458c 100644 --- a/dns_monitor.go +++ b/dns/monitor.go @@ -19,49 +19,64 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package dns import ( "context" - "log" "net" "net/url" + "slices" "strings" "sync" "sync/atomic" "time" -) -var ( - lookupDnsMonitorIP = net.LookupIP + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) const ( - defaultDnsMonitorInterval = time.Second + defaultMonitorInterval = time.Second ) -type DnsMonitorCallback = func(entry *DnsMonitorEntry, all []net.IP, add []net.IP, keep []net.IP, remove []net.IP) +type MonitorCallback = func(entry *MonitorEntry, all []net.IP, add []net.IP, keep []net.IP, remove []net.IP) -type DnsMonitorEntry struct { - entry atomic.Pointer[dnsMonitorEntry] +type MonitorEntry struct { + entry atomic.Pointer[monitorEntry] url string - callback DnsMonitorCallback + callback MonitorCallback } -func (e *DnsMonitorEntry) URL() string { +func (e *MonitorEntry) URL() string { return e.url } -type dnsMonitorEntry struct { +type monitorEntry struct { hostname string hostIP net.IP - mu sync.Mutex - ips []net.IP - entries map[*DnsMonitorEntry]bool + mu sync.Mutex + // +checklocks:mu + ips []net.IP + // +checklocks:mu + entries map[*MonitorEntry]bool } -func (e *dnsMonitorEntry) setIPs(ips []net.IP, fromIP bool) { +func (e *monitorEntry) clearRemoved() bool { + e.mu.Lock() + defer e.mu.Unlock() + + deleted := false + for entry := range e.entries { + if entry.entry.Load() == nil { + delete(e.entries, entry) + deleted = true + } + } + + return deleted && len(e.entries) == 0 +} + +func (e *monitorEntry) setIPs(ips []net.IP, fromIP bool) { e.mu.Lock() defer e.mu.Unlock() @@ -94,7 +109,7 @@ func (e *dnsMonitorEntry) setIPs(ips []net.IP, fromIP bool) { found := false for idx, newIP := range ips { if oldIP.Equal(newIP) { - ips = append(ips[:idx], ips[idx+1:]...) + ips = slices.Delete(ips, idx, idx+1) found = true keepIPs = append(keepIPs, oldIP) newIPs = append(newIPs, oldIP) @@ -118,14 +133,14 @@ func (e *dnsMonitorEntry) setIPs(ips []net.IP, fromIP bool) { } } -func (e *dnsMonitorEntry) addEntry(entry *DnsMonitorEntry) { +func (e *monitorEntry) addEntry(entry *MonitorEntry) { e.mu.Lock() defer e.mu.Unlock() e.entries[entry] = true } -func (e *dnsMonitorEntry) removeEntry(entry *DnsMonitorEntry) bool { +func (e *monitorEntry) removeEntry(entry *MonitorEntry) bool { e.mu.Lock() defer e.mu.Unlock() @@ -133,14 +148,19 @@ func (e *dnsMonitorEntry) removeEntry(entry *DnsMonitorEntry) bool { return len(e.entries) == 0 } -func (e *dnsMonitorEntry) runCallbacks(all []net.IP, add []net.IP, keep []net.IP, remove []net.IP) { +// +checklocks:e.mu +func (e *monitorEntry) runCallbacks(all []net.IP, add []net.IP, keep []net.IP, remove []net.IP) { for entry := range e.entries { entry.callback(entry, all, add, keep, remove) } } -type DnsMonitor struct { - interval time.Duration +type MonitorLookupFunc func(hostname string) ([]net.IP, error) + +type Monitor struct { + logger log.Logger + interval time.Duration + lookupFunc MonitorLookupFunc stopCtx context.Context stopFunc func() @@ -148,46 +168,48 @@ type DnsMonitor struct { mu sync.RWMutex cond *sync.Cond - hostnames map[string]*dnsMonitorEntry + hostnames map[string]*monitorEntry - hasRemoved atomic.Bool - - // Can be overwritten from tests. - checkHostnames func() + tickerWaiting atomic.Bool + hasRemoved atomic.Bool } -func NewDnsMonitor(interval time.Duration) (*DnsMonitor, error) { +func NewMonitor(logger log.Logger, interval time.Duration, lookupFunc MonitorLookupFunc) (*Monitor, error) { if interval < 0 { - interval = defaultDnsMonitorInterval + interval = defaultMonitorInterval + } + if lookupFunc == nil { + lookupFunc = net.LookupIP } stopCtx, stopFunc := context.WithCancel(context.Background()) - monitor := &DnsMonitor{ - interval: interval, + monitor := &Monitor{ + logger: logger, + interval: interval, + lookupFunc: lookupFunc, stopCtx: stopCtx, stopFunc: stopFunc, stopped: make(chan struct{}), - hostnames: make(map[string]*dnsMonitorEntry), + hostnames: make(map[string]*monitorEntry), } monitor.cond = sync.NewCond(&monitor.mu) - monitor.checkHostnames = monitor.doCheckHostnames return monitor, nil } -func (m *DnsMonitor) Start() error { +func (m *Monitor) Start() error { go m.run() return nil } -func (m *DnsMonitor) Stop() { +func (m *Monitor) Stop() { m.stopFunc() m.cond.Signal() <-m.stopped } -func (m *DnsMonitor) Add(target string, callback DnsMonitorCallback) (*DnsMonitorEntry, error) { +func (m *Monitor) Add(target string, callback MonitorCallback) (*MonitorEntry, error) { var hostname string if strings.Contains(target, "://") { // Full URL passed. @@ -207,17 +229,17 @@ func (m *DnsMonitor) Add(target string, callback DnsMonitorCallback) (*DnsMonito m.mu.Lock() defer m.mu.Unlock() - e := &DnsMonitorEntry{ + e := &MonitorEntry{ url: target, callback: callback, } entry, found := m.hostnames[hostname] if !found { - entry = &dnsMonitorEntry{ + entry = &monitorEntry{ hostname: hostname, hostIP: net.ParseIP(hostname), - entries: make(map[*DnsMonitorEntry]bool), + entries: make(map[*MonitorEntry]bool), } m.hostnames[hostname] = entry } @@ -227,7 +249,7 @@ func (m *DnsMonitor) Add(target string, callback DnsMonitorCallback) (*DnsMonito return e, nil } -func (m *DnsMonitor) Remove(entry *DnsMonitorEntry) { +func (m *Monitor) Remove(entry *MonitorEntry) { oldEntry := entry.entry.Swap(nil) if oldEntry == nil { // Already removed. @@ -245,7 +267,7 @@ func (m *DnsMonitor) Remove(entry *DnsMonitorEntry) { m.hasRemoved.Store(true) return } - defer m.mu.Unlock() + defer m.mu.Unlock() // +checklocksforce: only executed if the TryLock above succeeded. e, found := m.hostnames[oldEntry.hostname] if !found { @@ -257,7 +279,7 @@ func (m *DnsMonitor) Remove(entry *DnsMonitorEntry) { } } -func (m *DnsMonitor) clearRemoved() { +func (m *Monitor) clearRemoved() { if !m.hasRemoved.CompareAndSwap(true, false) { return } @@ -266,21 +288,13 @@ func (m *DnsMonitor) clearRemoved() { defer m.mu.Unlock() for hostname, entry := range m.hostnames { - deleted := false - for e := range entry.entries { - if e.entry.Load() == nil { - delete(entry.entries, e) - deleted = true - } - } - - if deleted && len(entry.entries) == 0 { + if entry.clearRemoved() { delete(m.hostnames, hostname) } } } -func (m *DnsMonitor) waitForEntries() (waited bool) { +func (m *Monitor) waitForEntries() (waited bool) { m.mu.Lock() defer m.mu.Unlock() @@ -291,7 +305,7 @@ func (m *DnsMonitor) waitForEntries() (waited bool) { return } -func (m *DnsMonitor) run() { +func (m *Monitor) run() { ticker := time.NewTicker(m.interval) defer ticker.Stop() defer close(m.stopped) @@ -302,21 +316,22 @@ func (m *DnsMonitor) run() { if m.stopCtx.Err() == nil { // Initial check when a new entry was added. More checks will be // triggered by the Ticker. - m.checkHostnames() + m.CheckHostnames() continue } } + m.tickerWaiting.Store(true) select { case <-m.stopCtx.Done(): return case <-ticker.C: - m.checkHostnames() + m.CheckHostnames() } } } -func (m *DnsMonitor) doCheckHostnames() { +func (m *Monitor) CheckHostnames() { m.clearRemoved() m.mu.RLock() @@ -327,17 +342,27 @@ func (m *DnsMonitor) doCheckHostnames() { } } -func (m *DnsMonitor) checkHostname(entry *dnsMonitorEntry) { +func (m *Monitor) checkHostname(entry *monitorEntry) { if len(entry.hostIP) > 0 { entry.setIPs([]net.IP{entry.hostIP}, true) return } - ips, err := lookupDnsMonitorIP(entry.hostname) + ips, err := m.lookupFunc(entry.hostname) if err != nil { - log.Printf("Could not lookup %s: %s", entry.hostname, err) + m.logger.Printf("Could not lookup %s: %s", entry.hostname, err) return } entry.setIPs(ips, false) } + +func (m *Monitor) WaitForTicker(ctx context.Context) error { + for !m.tickerWaiting.Load() { + time.Sleep(time.Millisecond) + if err := ctx.Err(); err != nil { + return err + } + } + return nil +} diff --git a/dns_monitor_test.go b/dns/monitor_test.go similarity index 65% rename from dns_monitor_test.go rename to dns/monitor_test.go index ee10772..d92868d 100644 --- a/dns_monitor_test.go +++ b/dns/monitor_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package dns import ( "context" @@ -33,61 +33,20 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns/internal" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" ) -type mockDnsLookup struct { - sync.RWMutex - - ips map[string][]net.IP -} - -func newMockDnsLookupForTest(t *testing.T) *mockDnsLookup { - mock := &mockDnsLookup{ - ips: make(map[string][]net.IP), - } - prev := lookupDnsMonitorIP - t.Cleanup(func() { - lookupDnsMonitorIP = prev - }) - lookupDnsMonitorIP = mock.lookup - return mock -} - -func (m *mockDnsLookup) Set(host string, ips []net.IP) { - m.Lock() - defer m.Unlock() - - m.ips[host] = ips -} - -func (m *mockDnsLookup) Get(host string) []net.IP { - m.Lock() - defer m.Unlock() - - return m.ips[host] -} - -func (m *mockDnsLookup) lookup(host string) ([]net.IP, error) { - m.RLock() - defer m.RUnlock() - - ips, found := m.ips[host] - if !found { - return nil, &net.DNSError{ - Err: fmt.Sprintf("could not resolve %s", host), - Name: host, - IsNotFound: true, - } - } - - return append([]net.IP{}, ips...), nil -} - -func newDnsMonitorForTest(t *testing.T, interval time.Duration) *DnsMonitor { +func NewMonitorForTest(t *testing.T, interval time.Duration, lookup *internal.MockLookup) *Monitor { t.Helper() require := require.New(t) - monitor, err := NewDnsMonitor(interval) + logger := logtest.NewLoggerForTest(t) + var lookupFunc MonitorLookupFunc + if lookup != nil { + lookupFunc = lookup.Lookup + } + monitor, err := NewMonitor(logger, interval, lookupFunc) require.NoError(err) t.Cleanup(func() { @@ -98,46 +57,48 @@ func newDnsMonitorForTest(t *testing.T, interval time.Duration) *DnsMonitor { return monitor } -type dnsMonitorReceiverRecord struct { +type monitorReceiverRecord struct { all []net.IP add []net.IP keep []net.IP remove []net.IP } -func (r *dnsMonitorReceiverRecord) Equal(other *dnsMonitorReceiverRecord) bool { +func (r *monitorReceiverRecord) Equal(other *monitorReceiverRecord) bool { return r == other || (reflect.DeepEqual(r.add, other.add) && reflect.DeepEqual(r.keep, other.keep) && reflect.DeepEqual(r.remove, other.remove)) } -func (r *dnsMonitorReceiverRecord) String() string { +func (r *monitorReceiverRecord) String() string { return fmt.Sprintf("all=%v, add=%v, keep=%v, remove=%v", r.all, r.add, r.keep, r.remove) } var ( - expectNone = &dnsMonitorReceiverRecord{} + expectNone = &monitorReceiverRecord{} // +checklocksignore: Global readonly variable. ) -type dnsMonitorReceiver struct { +type monitorReceiver struct { sync.Mutex - t *testing.T - expected *dnsMonitorReceiverRecord - received *dnsMonitorReceiverRecord + t *testing.T + // +checklocks:Mutex + expected *monitorReceiverRecord + // +checklocks:Mutex + received *monitorReceiverRecord } -func newDnsMonitorReceiverForTest(t *testing.T) *dnsMonitorReceiver { - return &dnsMonitorReceiver{ +func newMonitorReceiverForTest(t *testing.T) *monitorReceiver { + return &monitorReceiver{ t: t, } } -func (r *dnsMonitorReceiver) OnLookup(entry *DnsMonitorEntry, all, add, keep, remove []net.IP) { +func (r *monitorReceiver) OnLookup(entry *MonitorEntry, all, add, keep, remove []net.IP) { r.Lock() defer r.Unlock() - received := &dnsMonitorReceiverRecord{ + received := &monitorReceiverRecord{ all: all, add: add, keep: keep, @@ -147,13 +108,13 @@ func (r *dnsMonitorReceiver) OnLookup(entry *DnsMonitorEntry, all, add, keep, re expected := r.expected r.expected = nil if expected == expectNone { - assert.Fail(r.t, "expected no event, got %v", received) + assert.Fail(r.t, "expected no event", "received %v", received) return } if expected == nil { if r.received != nil && !r.received.Equal(received) { - assert.Fail(r.t, "already received %v, got %v", r.received, received) + assert.Fail(r.t, "unexpected message", "already received %v, got %v", r.received, received) } return } @@ -163,7 +124,7 @@ func (r *dnsMonitorReceiver) OnLookup(entry *DnsMonitorEntry, all, add, keep, re r.expected = nil } -func (r *dnsMonitorReceiver) WaitForExpected(ctx context.Context) { +func (r *monitorReceiver) WaitForExpected(ctx context.Context) { r.t.Helper() r.Lock() defer r.Unlock() @@ -182,16 +143,16 @@ func (r *dnsMonitorReceiver) WaitForExpected(ctx context.Context) { } } -func (r *dnsMonitorReceiver) Expect(all, add, keep, remove []net.IP) { +func (r *monitorReceiver) Expect(all, add, keep, remove []net.IP) { r.t.Helper() r.Lock() defer r.Unlock() if r.expected != nil && r.expected != expectNone { - assert.Fail(r.t, "didn't get previously expected %v", r.expected) + assert.Fail(r.t, "didn't get previous message", "expected %v", r.expected) } - expected := &dnsMonitorReceiverRecord{ + expected := &monitorReceiverRecord{ all: all, add: add, keep: keep, @@ -205,25 +166,26 @@ func (r *dnsMonitorReceiver) Expect(all, add, keep, remove []net.IP) { r.expected = expected } -func (r *dnsMonitorReceiver) ExpectNone() { +func (r *monitorReceiver) ExpectNone() { r.t.Helper() r.Lock() defer r.Unlock() if r.expected != nil && r.expected != expectNone { - assert.Fail(r.t, "didn't get previously expected %v", r.expected) + assert.Fail(r.t, "didn't get previous message", "expected %v", r.expected) } r.expected = expectNone } -func TestDnsMonitor(t *testing.T) { - lookup := newMockDnsLookupForTest(t) +func TestMonitor(t *testing.T) { + t.Parallel() + lookup := internal.NewMockLookup() ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() interval := time.Millisecond - monitor := newDnsMonitorForTest(t, interval) + monitor := NewMonitorForTest(t, interval, lookup) ip1 := net.ParseIP("192.168.0.1") ip2 := net.ParseIP("192.168.1.1") @@ -234,7 +196,7 @@ func TestDnsMonitor(t *testing.T) { } lookup.Set("foo", ips1) - rec1 := newDnsMonitorReceiverForTest(t) + rec1 := newMonitorReceiverForTest(t) rec1.Expect(ips1, ips1, nil, nil) entry1, err := monitor.Add("https://foo:12345", rec1.OnLookup) @@ -285,19 +247,20 @@ func TestDnsMonitor(t *testing.T) { time.Sleep(5 * interval) } -func TestDnsMonitorIP(t *testing.T) { +func TestMonitorIP(t *testing.T) { + t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() interval := time.Millisecond - monitor := newDnsMonitorForTest(t, interval) + monitor := NewMonitorForTest(t, interval, nil) ip := "192.168.0.1" ips := []net.IP{ net.ParseIP(ip), } - rec1 := newDnsMonitorReceiverForTest(t) + rec1 := newMonitorReceiverForTest(t) rec1.Expect(ips, ips, nil, nil) entry, err := monitor.Add(ip+":12345", rec1.OnLookup) @@ -310,14 +273,15 @@ func TestDnsMonitorIP(t *testing.T) { time.Sleep(5 * interval) } -func TestDnsMonitorNoLookupIfEmpty(t *testing.T) { +func TestMonitorNoLookupIfEmpty(t *testing.T) { + t.Parallel() interval := time.Millisecond - monitor := newDnsMonitorForTest(t, interval) + monitor := NewMonitorForTest(t, interval, nil) var checked atomic.Bool - monitor.checkHostnames = func() { + monitor.lookupFunc = func(hostname string) ([]net.IP, error) { checked.Store(true) - monitor.doCheckHostnames() + return net.LookupIP(hostname) } time.Sleep(10 * interval) @@ -326,18 +290,20 @@ func TestDnsMonitorNoLookupIfEmpty(t *testing.T) { type deadlockMonitorReceiver struct { t *testing.T - monitor *DnsMonitor + monitor *Monitor // +checklocksignore: Only written to from constructor. mu sync.RWMutex - wg sync.WaitGroup + wg sync.WaitGroup // +checklocksignore: Only written to from constructor. - entry *DnsMonitorEntry - started chan struct{} + // +checklocks:mu + entry *MonitorEntry + started chan struct{} + // +checklocks:mu triggered bool closed atomic.Bool } -func newDeadlockMonitorReceiver(t *testing.T, monitor *DnsMonitor) *deadlockMonitorReceiver { +func newDeadlockMonitorReceiver(t *testing.T, monitor *Monitor) *deadlockMonitorReceiver { return &deadlockMonitorReceiver{ t: t, monitor: monitor, @@ -345,7 +311,7 @@ func newDeadlockMonitorReceiver(t *testing.T, monitor *DnsMonitor) *deadlockMoni } } -func (r *deadlockMonitorReceiver) OnLookup(entry *DnsMonitorEntry, all []net.IP, add []net.IP, keep []net.IP, remove []net.IP) { +func (r *deadlockMonitorReceiver) OnLookup(entry *MonitorEntry, all []net.IP, add []net.IP, keep []net.IP, remove []net.IP) { if !assert.False(r.t, r.closed.Load(), "received lookup after closed") { return } @@ -358,16 +324,13 @@ func (r *deadlockMonitorReceiver) OnLookup(entry *DnsMonitorEntry, all []net.IP, } r.triggered = true - r.wg.Add(1) - go func() { - defer r.wg.Done() - + r.wg.Go(func() { r.mu.RLock() defer r.mu.RUnlock() close(r.started) time.Sleep(50 * time.Millisecond) - }() + }) } func (r *deadlockMonitorReceiver) Start() { @@ -393,14 +356,15 @@ func (r *deadlockMonitorReceiver) Close() { r.wg.Wait() } -func TestDnsMonitorDeadlock(t *testing.T) { - lookup := newMockDnsLookupForTest(t) +func TestMonitorDeadlock(t *testing.T) { + t.Parallel() + lookup := internal.NewMockLookup() ip1 := net.ParseIP("192.168.0.1") ip2 := net.ParseIP("192.168.0.2") lookup.Set("foo", []net.IP{ip1}) interval := time.Millisecond - monitor := newDnsMonitorForTest(t, interval) + monitor := NewMonitorForTest(t, interval, lookup) r := newDeadlockMonitorReceiver(t, monitor) r.Start() diff --git a/certificate_reloader_test.go b/dns/test/dns.go similarity index 52% rename from certificate_reloader_test.go rename to dns/test/dns.go index a180a9d..dd71a99 100644 --- a/certificate_reloader_test.go +++ b/dns/test/dns.go @@ -1,6 +1,6 @@ /** * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG + * Copyright (C) 2025 struktur AG * * @author Joachim Bauch * @@ -19,44 +19,41 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package test import ( - "context" "testing" "time" + + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns" + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns/internal" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" ) -func UpdateCertificateCheckIntervalForTest(t *testing.T, interval time.Duration) { +type MockLookup = internal.MockLookup + +func NewMockLookup() *MockLookup { + return internal.NewMockLookup() +} + +func NewMonitorForTest(t *testing.T, interval time.Duration, lookup *MockLookup) *dns.Monitor { t.Helper() - // Make sure test is not executed with "t.Parallel()" - t.Setenv("PARALLEL_CHECK", "1") - old := deduplicateWatchEvents.Load() + require := require.New(t) + + logger := logtest.NewLoggerForTest(t) + var lookupFunc dns.MonitorLookupFunc + if lookup != nil { + lookupFunc = lookup.Lookup + } + monitor, err := dns.NewMonitor(logger, interval, lookupFunc) + require.NoError(err) + t.Cleanup(func() { - deduplicateWatchEvents.Store(old) + monitor.Stop() }) - deduplicateWatchEvents.Store(int64(interval)) -} - -func (r *CertificateReloader) WaitForReload(ctx context.Context) error { - counter := r.GetReloadCounter() - for counter == r.GetReloadCounter() { - if err := ctx.Err(); err != nil { - return err - } - time.Sleep(time.Millisecond) - } - return nil -} - -func (r *CertPoolReloader) WaitForReload(ctx context.Context) error { - counter := r.GetReloadCounter() - for counter == r.GetReloadCounter() { - if err := ctx.Err(); err != nil { - return err - } - time.Sleep(time.Millisecond) - } - return nil + require.NoError(monitor.Start()) + return monitor } diff --git a/dns/test/dns_test.go b/dns/test/dns_test.go new file mode 100644 index 0000000..c743c02 --- /dev/null +++ b/dns/test/dns_test.go @@ -0,0 +1,68 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns" +) + +func TestDnsMonitor(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + lookup := NewMockLookup() + ips := []net.IP{ + net.ParseIP("1.2.3.4"), + } + lookup.Set("domain.invalid", ips) + monitor := NewMonitorForTest(t, time.Second, lookup) + + called := make(chan struct{}) + callback1 := func(entry *dns.MonitorEntry, all []net.IP, add []net.IP, keep []net.IP, remove []net.IP) { + defer func() { + called <- struct{}{} + }() + + assert.Equal(ips, all) + assert.Equal(ips, add) + assert.Empty(keep) + assert.Empty(remove) + } + + entry1, err := monitor.Add("domain.invalid", callback1) + require.NoError(err) + + t.Cleanup(func() { + monitor.Remove(entry1) + }) + + <-called +} diff --git a/docker/README.md b/docker/README.md index b4003e4..83760cd 100644 --- a/docker/README.md +++ b/docker/README.md @@ -15,7 +15,11 @@ The running container can be configured through different environment variables: - `CONFIG`: Optional name of configuration file to use. - `HTTP_LISTEN`: Address of HTTP listener. +- `HTTP_READ_TIMEOUT`: HTTP socket read timeout in seconds. +- `HTTP_WRITE_TIMEOUT`: HTTP socket write timeout in seconds. - `HTTPS_LISTEN`: Address of HTTPS listener. +- `HTTPS_READ_TIMEOUT`: HTTPS socket read timeout in seconds. +- `HTTPS_WRITE_TIMEOUT`: HTTPS socket write timeout in seconds. - `HTTPS_CERTIFICATE`: Name of certificate file for the HTTPS listener. - `HTTPS_KEY`: Name of private key file for the HTTPS listener. - `HASH_KEY`: Secret value used to generate checksums of sessions (32 or 64 bytes). @@ -24,11 +28,15 @@ The running container can be configured through different environment variables: - `BACKENDS_ALLOWALL`: Allow all backends. Extremly insecure - use only for development! - `BACKENDS_ALLOWALL_SECRET`: Secret when `BACKENDS_ALLOWALL` is enabled. - `BACKENDS`: Space-separated list of backend ids. -- `BACKEND__URL`: Url of backend `ID` (where `ID` is the uppercase backend id). +- `BACKENDS_TIMEOUT`: Timeout in seconds for requests to backends. +- `CONNECTIONS_PER_HOST`: Maximum number of concurrent backend connections per host. +- `BACKEND__URLS`: Comma-separated list of urls of backend `ID` (where `ID` is the uppercase backend id). +- `BACKEND__URL`: Url of backend `ID` (where `ID` is the uppercase backend id, deprecated). - `BACKEND__SHARED_SECRET`: Shared secret for backend `ID` (where `ID` is the uppercase backend id). - `BACKEND__SESSION_LIMIT`: Optional session limit for backend `ID` (where `ID` is the uppercase backend id). - `BACKEND__MAX_STREAM_BITRATE`: Optional maximum bitrate for audio/video streams in backend `ID` (where `ID` is the uppercase backend id). - `BACKEND__MAX_SCREEN_BITRATE`: Optional maximum bitrate for screensharing streams in backend `ID` (where `ID` is the uppercase backend id). +- `FEDERATION_TIMEOUT`: Timeout for requests to federation targets in seconds. - `NATS_URL`: Optional URL of NATS server. - `ETCD_ENDPOINTS`: Static list of etcd endpoints (if etcd should be used). - `ETCD_DISCOVERY_SRV`: Alternative domain to use for DNS SRV configuration of etcd endpoints (if etcd should be used). @@ -41,12 +49,15 @@ The running container can be configured through different environment variables: - `USE_PROXY`: Set to `1` if proxy servers should be used as WebRTC backends. - `PROXY_TOKEN_ID`: Id of the token to use when connecting to proxy servers. - `PROXY_TOKEN_KEY`: Private key for the configured token id. +- `PROXY_TIMEOUT`: Timeout in seconds for requests to the proxy server. - `PROXY_URLS`: Space-separated list of proxy URLs to connect to. - `PROXY_DNS_DISCOVERY`: Enable DNS discovery on hostnames of configured static URLs. - `PROXY_ETCD`: Set to `1` if etcd should be used to configure proxy connections. - `PROXY_KEY_PREFIX`: Key prefix of proxy entries. - `MAX_STREAM_BITRATE`: Optional global maximum bitrate for audio/video streams. - `MAX_SCREEN_BITRATE`: Optional global maximum bitrate for screensharing streams. +- `ALLOWED_CANDIDATES`: List of IP addresses / subnets that are allowed to be used by clients in candidates. The allowed list has preference over the blocked list below. +- `BLOCKED_CANDIDATES`: List of IP addresses / subnets to filter from candidates received by clients. - `TURN_API_KEY`: API key that Janus will need to send when requesting TURN credentials. - `TURN_SECRET`: The shared secret to use for generating TURN credentials. - `TURN_SERVERS`: A comma-separated list of TURN servers to use. @@ -109,6 +120,8 @@ The running container can be configured through different environment variables: - `JANUS_URL`: Url to Janus server. - `MAX_STREAM_BITRATE`: Optional maximum bitrate for audio/video streams. - `MAX_SCREEN_BITRATE`: Optional maximum bitrate for screensharing streams. +- `ALLOWED_CANDIDATES`: List of IP addresses / subnets that are allowed to be used by clients in candidates. The allowed list has preference over the blocked list below. +- `BLOCKED_CANDIDATES`: List of IP addresses / subnets to filter from candidates received by clients. - `STATS_IPS`: Comma-separated list of IP addresses that are allowed to access the stats endpoint. - `TRUSTED_PROXIES`: Comma-separated list of IPs / networks that are trusted proxies. - `ETCD_ENDPOINTS`: Static list of etcd endpoints (if etcd should be used). diff --git a/docker/proxy/Dockerfile b/docker/proxy/Dockerfile index fb78e03..4ecc624 100644 --- a/docker/proxy/Dockerfile +++ b/docker/proxy/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=${BUILDPLATFORM} golang:1.23-alpine AS builder +FROM --platform=${BUILDPLATFORM} golang:1.26-alpine AS builder ARG TARGETARCH ARG TARGETOS @@ -12,7 +12,8 @@ RUN touch /.dockerenv && \ FROM alpine:3 ENV CONFIG=/config/proxy.conf -RUN adduser -D spreedbackend && \ +RUN addgroup -g 850 spreedbackend && \ + adduser -D --uid 850 -S -H -G spreedbackend spreedbackend && \ apk add --no-cache bash tzdata ca-certificates su-exec COPY --from=builder /workdir/bin/proxy /usr/bin/nextcloud-spreed-signaling-proxy diff --git a/docker/proxy/entrypoint.sh b/docker/proxy/entrypoint.sh index 5a2d2ff..116a9dd 100755 --- a/docker/proxy/entrypoint.sh +++ b/docker/proxy/entrypoint.sh @@ -96,6 +96,12 @@ if [ ! -f "$CONFIG" ]; then if [ -n "$MAX_SCREEN_BITRATE" ]; then sed -i "s|#maxscreenbitrate =.*|maxscreenbitrate = $MAX_SCREEN_BITRATE|" "$CONFIG" fi + if [ -n "$ALLOWED_CANDIDATES" ]; then + sed -i "s|#allowedcandidates =.*|allowedcandidates = $ALLOWED_CANDIDATES|" "$CONFIG" + fi + if [ -n "$BLOCKED_CANDIDATES" ]; then + sed -i "s|#blockedcandidates =.*|blockedcandidates = $BLOCKED_CANDIDATES|" "$CONFIG" + fi if [ -n "$TOKENS_ETCD" ]; then if [ -z "$HAS_ETCD" ]; then diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile index 9c59e7c..643403b 100644 --- a/docker/server/Dockerfile +++ b/docker/server/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=${BUILDPLATFORM} golang:1.23-alpine AS builder +FROM --platform=${BUILDPLATFORM} golang:1.26-alpine AS builder ARG TARGETARCH ARG TARGETOS @@ -12,7 +12,8 @@ RUN touch /.dockerenv && \ FROM alpine:3 ENV CONFIG=/config/server.conf -RUN adduser -D spreedbackend && \ +RUN addgroup -g 850 spreedbackend && \ + adduser -D --uid 850 -S -H -G spreedbackend spreedbackend && \ apk add --no-cache bash tzdata ca-certificates su-exec COPY --from=builder /workdir/bin/signaling /usr/bin/nextcloud-spreed-signaling diff --git a/docker/server/entrypoint.sh b/docker/server/entrypoint.sh index dc3d08d..03d9996 100755 --- a/docker/server/entrypoint.sh +++ b/docker/server/entrypoint.sh @@ -39,8 +39,21 @@ if [ ! -f "$CONFIG" ]; then if [ -n "$HTTP_LISTEN" ]; then sed -i "s|#listen = 127.0.0.1:8080|listen = $HTTP_LISTEN|" "$CONFIG" fi + if [ -n "$HTTP_READ_TIMEOUT" ]; then + sed -i "/HTTP socket/,/HTTP socket/ s|#readtimeout =.*|readtimeout = $HTTP_READ_TIMEOUT|" "$CONFIG" + fi + if [ -n "$HTTP_WRITE_TIMEOUT" ]; then + sed -i "/HTTP socket/,/HTTP socket/ s|#writetimeout =.*|writetimeout = $HTTP_WRITE_TIMEOUT|" "$CONFIG" + fi + if [ -n "$HTTPS_LISTEN" ]; then sed -i "s|#listen = 127.0.0.1:8443|listen = $HTTPS_LISTEN|" "$CONFIG" + if [ -n "$HTTPS_READ_TIMEOUT" ]; then + sed -i "/HTTPS socket/,/HTTPS socket/ s|#readtimeout =.*|readtimeout = $HTTPS_READ_TIMEOUT|" "$CONFIG" + fi + if [ -n "$HTTPS_WRITE_TIMEOUT" ]; then + sed -i "/HTTPS socket/,/HTTPS socket/ s|#writetimeout =.*|writetimeout = $HTTPS_WRITE_TIMEOUT|" "$CONFIG" + fi if [ -n "$HTTPS_CERTIFICATE" ]; then sed -i "s|certificate = /etc/nginx/ssl/server.crt|certificate = $HTTPS_CERTIFICATE|" "$CONFIG" @@ -64,6 +77,9 @@ if [ ! -f "$CONFIG" ]; then else sed -i "s|#url = nats://localhost:4222|url = nats://loopback|" "$CONFIG" fi + if [ -n "$FEDERATION_TIMEOUT" ]; then + sed -i "/federation/,/federation/ s|#timeout =.*|timeout = $FEDERATION_TIMEOUT|" "$CONFIG" + fi HAS_ETCD= if [ -n "$ETCD_ENDPOINTS" ]; then @@ -103,6 +119,9 @@ if [ ! -f "$CONFIG" ]; then if [ -n "$PROXY_TOKEN_KEY" ]; then sed -i "s|#token_key =.*|token_key = $PROXY_TOKEN_KEY|" "$CONFIG" fi + if [ -n "$PROXY_TIMEOUT" ]; then + sed -i "s|#proxytimeout =.*|proxytimeout = $PROXY_TIMEOUT|" "$CONFIG" + fi if [ -n "$PROXY_ETCD" ]; then if [ -z "$HAS_ETCD" ]; then @@ -131,6 +150,12 @@ if [ ! -f "$CONFIG" ]; then if [ -n "$MAX_SCREEN_BITRATE" ]; then sed -i "s|#maxscreenbitrate =.*|maxscreenbitrate = $MAX_SCREEN_BITRATE|" "$CONFIG" fi + if [ -n "$ALLOWED_CANDIDATES" ]; then + sed -i "s|#allowedcandidates =.*|allowedcandidates = $ALLOWED_CANDIDATES|" "$CONFIG" + fi + if [ -n "$BLOCKED_CANDIDATES" ]; then + sed -i "s|#blockedcandidates =.*|blockedcandidates = $BLOCKED_CANDIDATES|" "$CONFIG" + fi if [ -n "$SKIP_VERIFY" ]; then sed -i "s|#skipverify =.*|skipverify = $SKIP_VERIFY|" "$CONFIG" @@ -232,6 +257,14 @@ if [ ! -f "$CONFIG" ]; then sed -i "s|#secret = the-shared-secret-for-allowall|secret = $BACKENDS_ALLOWALL_SECRET|" "$CONFIG" fi + if [ -n "$BACKENDS_TIMEOUT" ]; then + sed -i "/requests to the backend/,/requests to the backend/ s|^timeout =.*|timeout = $BACKENDS_TIMEOUT|" "$CONFIG" + fi + + if [ -n "$CONNECTIONS_PER_HOST" ]; then + sed -i "s|connectionsperhost =.*|connectionsperhost = $CONNECTIONS_PER_HOST|" "$CONFIG" + fi + if [ -n "$BACKENDS" ]; then BACKENDS_CONFIG=${BACKENDS// /,} sed -i "s|#backends = .*|backends = $BACKENDS_CONFIG|" "$CONFIG" @@ -240,9 +273,15 @@ if [ ! -f "$CONFIG" ]; then for backend in $BACKENDS; do echo "[$backend]" >> "$CONFIG" - declare var="BACKEND_${backend^^}_URL" + declare var="BACKEND_${backend^^}_URLS" if [ -n "${!var}" ]; then - echo "url = ${!var}" >> "$CONFIG" + echo "urls = ${!var}" >> "$CONFIG" + else + declare var_compat="BACKEND_${backend^^}_URL" + if [ -n "${!var_compat}" ]; then + echo "Variable $var_compat is deprecated, use $var instead." + echo "urls = ${!var_compat}" >> "$CONFIG" + fi fi declare var="BACKEND_${backend^^}_SHARED_SECRET" @@ -266,6 +305,8 @@ if [ ! -f "$CONFIG" ]; then fi echo >> "$CONFIG" done + elif [ -n "$BACKENDS_COMPAT_ALLOWED" ]; then + sed -i "s|#backends =.*|allowed = $BACKENDS_COMPAT_ALLOWED\nsecret = $BACKENDS_COMPAT_SECRET|" "$CONFIG" fi fi diff --git a/docs/prometheus-metrics.md b/docs/prometheus-metrics.md index 70d6ef9..c75aa86 100644 --- a/docs/prometheus-metrics.md +++ b/docs/prometheus-metrics.md @@ -52,3 +52,28 @@ The following metrics are available: | `signaling_http_client_pool_connections` | Gauge | 1.2.4 | The current number of HTTP client connections per host | `host` | | `signaling_throttle_delayed_total` | Counter | 1.2.5 | The total number of delayed requests | `action`, `delay` | | `signaling_throttle_bruteforce_total` | Counter | 1.2.5 | The total number of rejected bruteforce requests | `action` | +| `signaling_backend_client_requests_total` | Counter | 2.0.3 | The total number of backend client requests | `backend` | +| `signaling_backend_client_requests_duration` | Histogram | 2.0.3 | The duration of backend client requests in seconds | `backend` | +| `signaling_backend_client_requests_errors_total` | Counter | 2.0.3 | The total number of backend client requests that had an error | `backend`, `error` | +| `signaling_mcu_bandwidth` | Gauge | 2.1.0 | The current bandwidth in bytes per second | `direction` | +| `signaling_mcu_backend_usage` | Gauge | 2.1.0 | The current usage of signaling proxy backends in percent | `url`, `direction` | +| `signaling_mcu_backend_bandwidth` | Gauge | 2.1.0 | The current bandwidth of signaling proxy backends in bytes per second | `url`, `direction` | +| `signaling_proxy_load` | Gauge | 2.1.0 | The current load of the signaling proxy | | +| `signaling_client_rtt` | Histogram | 2.1.0 | The roundtrip time of WebSocket ping messages in milliseconds | | +| `signaling_mcu_selected_candidate_total` | Counter | 2.1.0 | Total number of selected candidates | `origin`, `type`, `transport`, `family` | +| `signaling_mcu_peerconnection_state_total` | Counter | 2.1.0 | Total number PeerConnection states | `state`, `reason` | +| `signaling_mcu_ice_state_total` | Counter | 2.1.0 | Total number of ICE connection states | `state` | +| `signaling_mcu_dtls_state_total` | Counter | 2.1.0 | Total number of DTLS connection states | `state` | +| `signaling_mcu_slow_link_total` | Counter | 2.1.0 | Total number of slow link events | `media`, `direction` | +| `signaling_mcu_media_rtt` | Histogram | 2.1.0 | The roundtrip time of WebRTC media in milliseconds | `media` | +| `signaling_mcu_media_jitter` | Histogram | 2.1.0 | The jitter of WebRTC media in milliseconds | `media`, `origin` | +| `signaling_mcu_media_codecs_total` | Counter | 2.1.0 | The total number of codecs | `media`, `codec` | +| `signaling_mcu_media_nacks_total` | Counter | 2.1.0 | The total number of NACKs | `media`, `direction` | +| `signaling_mcu_media_retransmissions_total` | Counter | 2.1.0 | The total number of received retransmissions | `media` | +| `signaling_mcu_media_bytes_total` | Counter | 2.1.0 | The total number of media bytes sent / received | `media`, `direction` | +| `signaling_mcu_media_lost_total` | Counter | 2.1.0 | The total number of lost media packets | `media`, `origin` | +| `signaling_client_bytes_total` | Counter | 2.1.0 | The total number of bytes sent to or received by clients | `direction` | +| `signaling_client_messages_total` | Counter | 2.1.0 | The total number of messages sent to or received by clients | `direction` | +| `signaling_call_sessions` | Gauge | 2.1.0 | The current number of sessions in a call | `backend`, `room`, `clienttype` | +| `signaling_call_sessions_total` | Counter | 2.1.0 | The total number of sessions in a call | `backend`, `clienttype` | +| `signaling_call_rooms_total` | Counter | 2.1.0 | The total number of rooms with an active call | `backend` | diff --git a/docs/requirements.txt b/docs/requirements.txt index 97531c7..1794104 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ -jinja2==3.1.5 -markdown==3.7 +jinja2==3.1.6 +markdown==3.10.2 mkdocs==1.6.1 readthedocs-sphinx-search==0.3.2 -sphinx==8.1.3 -sphinx_rtd_theme==3.0.2 +sphinx==9.1.0 +sphinx_rtd_theme==3.1.0 diff --git a/docs/standalone-signaling-api-v1.md b/docs/standalone-signaling-api-v1.md index 7378914..e6c5d17 100644 --- a/docs/standalone-signaling-api-v1.md +++ b/docs/standalone-signaling-api-v1.md @@ -173,6 +173,15 @@ future version. Clients should use the data from the [`welcome` message](#welcome-message) instead. +## Client features + +The following client feature flags are currently supported: +- `chat-relay`: The client understands chat message events containing the actual + message payload. +- `internal-incall`: The (internal) client can set the `inCall` flag. +- `start-dialout`: The (internal) client can start outgoing phone calls. + + ### Protocol version "1.0" For protocol version `1.0` in the `hello` request, the `params` from the `auth` @@ -285,13 +294,18 @@ authorized, the backend returns an error and the hello request will be rejected. ### Error codes -- `unsupported-version`: The requested version is not supported. -- `auth-failed`: The session could not be authenticated. -- `too-many-sessions`: Too many sessions exist for this user id. +- `invalid_request`: The backend request for the v1 hello could not be authenticated. Check your shared secret. +- `invalid_hello_version`: The requested `hello` version is not supported. +- `auth_failed`: The session could not be authenticated. - `invalid_backend`: The requested backend URL is not supported. - `invalid_client_type`: The [client type](#client-types) is not supported. +- `invalid_ticket`: The passed ticket in the v1 hello is invalid. +- `no_such_user`: The user id in the v1 hello does not exists. - `invalid_token`: The passed token is invalid (can happen for - [client type `internal`](#client-type-internal)). + [client type `internal`](#client-type-internal) or v2 requests). +- `token_not_valid_yet`: The token could be authenticated but is not valid yet. +- `token_expired`: The token could be authenticated but is expired. +- `too_many_requests`: Too many failed requests from this client. ### Client types @@ -389,6 +403,7 @@ server will return an error and a normal `hello` handshake has to be performed. ### Error codes - `no_such_session`: The session id is no longer valid. +- `too_many_requests`: Too many failed requests from this client. ## Releasing sessions @@ -451,6 +466,10 @@ Message format (Server -> Client): "roomid": "the-room-id", "properties": { ...additional room properties... + }, + "bandwidth": { + "maxstreambitrate": 1048576, + "maxscreenbitrate": 2097152 } } } @@ -459,6 +478,10 @@ Message format (Server -> Client): - The `roomid` will be empty if the client is no longer in a room. - Can be sent without a request if the server moves a client to a room / out of the current room or the properties of a room change. +- The optional `bandwidth` field contains information on the expected bandwidth + limits (in bits per second) for publishing in this room. If publishing is done + through signaling proxies or there already are other publishers in the room, + the actual limits might be lower than what is returned here. Message format (Server -> Client if already joined before): @@ -470,9 +493,11 @@ Message format (Server -> Client if already joined before): "code": "already_joined", "message": "Human readable error message", "details": { - "roomid": "the-room-id", - "properties": { - ...additional room properties... + "room": { + "roomid": "the-room-id", + "properties": { + ...additional room properties... + } } } } @@ -521,8 +546,11 @@ user, the backend returns an error and the room request will be rejected. ### Error codes +- `invalid_request`: The backend request could not be authenticated. Check your shared secret. - `no_such_room`: The requested room does not exist or the user is not invited to the room. +- `duplicate_session`: The given session already joined the room. +- `room_join_failed`: The Talk backend returned an unexpected response while joining the room. ## Join federated room @@ -795,10 +823,9 @@ Message format (Server -> Client, incall change): ## Room messages The server can notify clients about events that happened in a room. Currently -such messages are only sent out when chat messages are posted to notify clients -they should load the new messages. +such messages are only sent out when chat messages are posted. -Message format (Server -> Client, chat messages available): +Message format (Server -> Client, new messages available, refresh chat): { "type": "event" @@ -818,6 +845,28 @@ Message format (Server -> Client, chat messages available): } +Message format (Server -> Client, new message was posted): + + { + "type": "event" + "event": { + "target": "room", + "type": "message", + "message": { + "roomid": "the-room-id", + "data": { + "type": "chat", + "chat": { + "comment": { + ...properties of the chat message... + } + } + } + } + } + } + + ## Sending messages between clients Messages between clients are sent realtime and not stored by the server, i.e. @@ -1225,6 +1274,23 @@ Transient data is supported if the server returns the `transient-data` feature id in the [hello response](#establish-connection). +### Transient session data + +Sessions can set data for a special key in the format `sd:` where +`` is the public id of the session updating the key. A session can +only set its own session data and the entry is removed automatically once the +session leaves the room (either explicitly or because it is expired). + +This can be used to publish local information on a given session to all other +sessions in a room (e.g. the display name of guest users) without having to +manually keep track of which other session that information would have to be +sent to and who already got it. + +Transient session data is supported if the server returns the +`transient-sessiondata` feature id in the +[hello response](#establish-connection). + + ### Set value Message format (Client -> Server): @@ -1295,7 +1361,9 @@ Message format (Server -> Client): ### Initial data When sessions initially join a room, they receive the current state of the -transient data. +transient data. Please note that the initial data can be sent in multiple +events of type `initial` which must be combined to generate the total initial +data. Message format (Server -> Client): @@ -1416,6 +1484,202 @@ The signaling server provides an internal API that can be called from Nextcloud to trigger events from the server side. +## Welcome message + +The welcome message at `/api/v1/welcome` can be retrieved using HTTP `GET` to +check if the signaling server is reachable and to get the version number and +supported features. + +A comma separated list of feature ids is in the `X-Spreed-Signaling-Features` +HTTP header, the version is available in the response body: + + { + "nextcloud-spreed-signaling": "Welcome", + "version": "1.2.3" + } + + +## Server info + +If the feature id `serverinfo` is supported, the server info API at +`/api/v1/serverinfo` can be called with HTTP `GET` to query information about +the server. + +Please note that the client calling this API must be allowed through the +`allowed_ips` option in the `[stats]` section. + + +### Example response with Janus backend + +Below is an example response of the serverinfo endpoint with a connected Janus +server: + + { + "version": "1.2.3", + "features": [ + "feature-1", + "feature-2", + ... + "serverinfo", + ... + ], + "sfu": { + "mode": "janus", + "janus": { + "url": "ws://localhost:8188/", + "connected": true, + "name": "Janus WebRTC Server", + "version": "1.3.1", + "author": "Meetecho s.r.l.", + "datachannels": true, + "fulltrickle": true, + "localip": "192.168.0.1", + "ipv6": false, + "videoroom": { + "name": "JANUS VideoRoom plugin", + "version": "0.0.10", + "author": "Meetecho s.r.l." + } + } + } + } + +If the backend is not connected, the value of `connected` in `janus` will be +`false` and most other entries will be missing. + + +### Example response with signaling proxy backends + +Below is an example response of the serverinfo endpoint with multiple signaling +proxies: + + { + "version": "1.2.3", + "features": [ + "feature-1", + "feature-2", + ... + "serverinfo", + ... + ], + "sfu": { + "mode": "proxy", + "proxies": [ + { + "url": "https://proxy.domain.tld/", + "ip": "192.168.0.1", + "connected": true, + "temporary": false, + "shutdown": false, + "uptime": "2025-03-05T18:09:35.435902408+01:00", + "version": "2.3.4", + "features": [ + "proxy-feature-1", + "proxy-feature-2", + ... + ], + "country": "DE", + "load": 0, + "bandwidth": { + "incoming": 0, + "outgoing": 0 + } + }, + { + "url": "https://proxy.domain.tld/", + "ip": "192.168.0.2", + "connected": false, + "temporary": false + } + ] + } + } + +The `ip` field will only be present if DNS discovery is enabled for resolving +proxy urls. +`uptime` is the ISO8601 time since the connection was established to the proxy. +`country` will only be returned if configured on the proxy. +`load` is an arbitrary value used to order signaling proxies when selecting the +proxy to use for publishing new streams. +`bandwidth` contains the percentage of incoming / outgoing bandwith utilization +for streams on the proxy. Only present if a bandwidth limit is configured on +the proxy. + + +### NATS connection + +Information about the NATS connection are also returned by the serverinfo +endpoint: + + { + "urls": [ + "nats://localhost:4222" + ], + "connected": true, + "serverurl": "nats://localhost:4222", + "serverid": "556c9de63ac214e53a9b976b2e5305d8", + "version": "0.6.8" + } + + +### GRPC connections + +In clustered mode, the signaling server has GRPC connections to other instances +which are included in the serverinfo response: + + [ + { + "target": "192.168.1.1:8080", + "connected": true, + "version": "1.2.3" + }, + { + "target": "192.168.1.2:8080", + "connected": true, + "version": "1.2.3" + } + ] + + +### ETCD cluster + +Some configuration can be loaded from an etcd cluster, the serverinfo response +also contains information on that connection: + + { + "endpoints": [ + "192.168.4.1:2379", + "192.168.4.2:2379" + ], + "active": "etcd-endpoints://0xc0001fba40/192.168.4.2:2379", + "connected": true + } + + +### Dialout session + +If a SIP bridge with support for dial-out is connected, the serverinfo response +will contain an additional property `dialout` with the following contents: + + [ + { + "sessionid": "the-session-id", + "connected": true, + "address": "192.168.1.0", + "useragent": "spreed-webrtc-sip-bridge/1.2.3", + "version": "1.2.3", + "features": [ + "start-dialout", + "datachannels", + "encryption" + ] + } + ] + +If the connection between SIP bridge and signaling server is interrupted, the +`connected` property will be `false` and details on the session (`address`, `useragent` and `features`) omitted. + + ## Rooms API The base URL for the rooms API is `/api/vi/room/`, all requests must be @@ -1573,6 +1837,66 @@ Message format (Backend -> Server) } +#### Send chat room message + +Usually, clients do a regular poll against Talk to fetch chat messages. In order +to reduce the load, the clients can be notified about new messages (which then +causes them to fetch them), or the messages can be sent directly to them. + +Depending on the client feature `chat-relay`, clients will either get the +event with `"refresh": true`, or they get the full chat `comment` object. This +is available if the signaling server supports the feature flag `chat-relay`. +If not, the full event will be sent to the clients (containing both `refresh` +and `comment`). + +Message format (Backend -> Server) + + { + "type": "message" + "message" { + "data": { + "type": "chat", + "chat": { + "refresh": true, + "comment": { + ...properties of the comment written in the chat... + } + } + } + } + } + + +The signaling server also supports combining multiple chat comments into one +request which will then be sent out individually to clients. + +Message format (Backend -> Server) + + { + "type": "message" + "message" { + "data": { + "type": "chat", + "chat": { + "refresh": true, + "comments": [ + { + ...properties of the first comment written in the chat... + }, + { + ...properties of the second comment written in the chat... + } + ] + } + } + } + } + +In this case, clients will either receive a single `"refresh": true` message +(if they don't support `chat-relay`) or multiple messages with the different +comments. + + ### Notify sessions to switch to a different room This can be used to let sessions in a room know that they switch to a different @@ -1644,7 +1968,7 @@ Message format (Backend -> Server) "dialout" { "number": "e164-target-number", "options": { - ...arbitrary options that will be sent back to validate... + ...additional options... } } } @@ -1652,6 +1976,26 @@ Message format (Backend -> Server) Please note that this requires a connected internal client that supports dialout (e.g. the SIP bridge). +The `options` will be sent to Nextcloud Talk for validation of the dialout +request. A field `caller` can be included containing the data that should be +sent as `From` header in the outgoing call, or a field `anonymous` with value +`true` to trigger an anonymous outgoing call (CLIR). + +Example request (dialout to `+49123456789` and use `+491122334455` as caller): + + { + "type": "dialout" + "dialout" { + "number": "+49123456789", + "options": { + "attendeeId": "abcdef", + "actorType": "actor-type", + "actorId": "the-actor", + "caller": "+491122334455" + } + } + } + Message format (Server -> Backend, request was accepted) { diff --git a/etcd/api.go b/etcd/api.go new file mode 100644 index 0000000..b93c38f --- /dev/null +++ b/etcd/api.go @@ -0,0 +1,131 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package etcd + +import ( + "context" + "errors" + "fmt" + "net/url" + "slices" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + clientv3 "go.etcd.io/etcd/client/v3" +) + +type ClientListener interface { + EtcdClientCreated(client Client) +} + +type ClientWatcher interface { + EtcdWatchCreated(client Client, key string) + EtcdKeyUpdated(client Client, key string, value []byte, prevValue []byte) + EtcdKeyDeleted(client Client, key string, prevValue []byte) +} + +type Client interface { + IsConfigured() bool + WaitForConnection(ctx context.Context) error + GetServerInfoEtcd() *BackendServerInfoEtcd + Close() error + + AddListener(listener ClientListener) + RemoveListener(listener ClientListener) + + Get(ctx context.Context, key string, opts ...clientv3.OpOption) (*clientv3.GetResponse, error) + Watch(ctx context.Context, key string, nextRevision int64, watcher ClientWatcher, opts ...clientv3.OpOption) (int64, error) +} + +// Information on a backend in the etcd cluster. + +type BackendInformationEtcd struct { + // Compat setting. + Url string `json:"url,omitempty"` + + Urls []string `json:"urls,omitempty"` + ParsedUrls []*url.URL `json:"-"` + Secret string `json:"secret"` + + MaxStreamBitrate api.Bandwidth `json:"maxstreambitrate,omitempty"` + MaxScreenBitrate api.Bandwidth `json:"maxscreenbitrate,omitempty"` + + SessionLimit uint64 `json:"sessionlimit,omitempty"` +} + +func (p *BackendInformationEtcd) CheckValid() (err error) { + if p.Secret == "" { + return errors.New("secret missing") + } + + if len(p.Urls) > 0 { + slices.Sort(p.Urls) + p.Urls = slices.Compact(p.Urls) + seen := make(map[string]bool) + outIdx := 0 + for _, u := range p.Urls { + parsedUrl, err := url.Parse(u) + if err != nil { + return fmt.Errorf("invalid url %s: %w", u, err) + } + + var changed bool + if parsedUrl, changed = internal.CanonicalizeUrl(parsedUrl); changed { + u = parsedUrl.String() + } + p.Urls[outIdx] = u + if seen[u] { + continue + } + seen[u] = true + p.ParsedUrls = append(p.ParsedUrls, parsedUrl) + outIdx++ + } + if len(p.Urls) != outIdx { + clear(p.Urls[outIdx:]) + p.Urls = p.Urls[:outIdx] + } + } else if p.Url != "" { + parsedUrl, err := url.Parse(p.Url) + if err != nil { + return fmt.Errorf("invalid url: %w", err) + } + var changed bool + if parsedUrl, changed = internal.CanonicalizeUrl(parsedUrl); changed { + p.Url = parsedUrl.String() + } + + p.Urls = append(p.Urls, p.Url) + p.ParsedUrls = append(p.ParsedUrls, parsedUrl) + } else { + return errors.New("urls missing") + } + + return nil +} + +type BackendServerInfoEtcd struct { + Endpoints []string `json:"endpoints"` + + Active string `json:"active,omitempty"` + Connected *bool `json:"connected,omitempty"` +} diff --git a/etcd/api_easyjson.go b/etcd/api_easyjson.go new file mode 100644 index 0000000..16f93ba --- /dev/null +++ b/etcd/api_easyjson.go @@ -0,0 +1,308 @@ +// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. + +package etcd + +import ( + json "encoding/json" + easyjson "github.com/mailru/easyjson" + jlexer "github.com/mailru/easyjson/jlexer" + jwriter "github.com/mailru/easyjson/jwriter" + api "github.com/strukturag/nextcloud-spreed-signaling/v2/api" +) + +// suppress unused package warning +var ( + _ *json.RawMessage + _ *jlexer.Lexer + _ *jwriter.Writer + _ easyjson.Marshaler +) + +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd(in *jlexer.Lexer, out *BackendServerInfoEtcd) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "endpoints": + if in.IsNull() { + in.Skip() + out.Endpoints = nil + } else { + in.Delim('[') + if out.Endpoints == nil { + if !in.IsDelim(']') { + out.Endpoints = make([]string, 0, 4) + } else { + out.Endpoints = []string{} + } + } else { + out.Endpoints = (out.Endpoints)[:0] + } + for !in.IsDelim(']') { + var v1 string + if in.IsNull() { + in.Skip() + } else { + v1 = string(in.String()) + } + out.Endpoints = append(out.Endpoints, v1) + in.WantComma() + } + in.Delim(']') + } + case "active": + if in.IsNull() { + in.Skip() + } else { + out.Active = string(in.String()) + } + case "connected": + if in.IsNull() { + in.Skip() + out.Connected = nil + } else { + if out.Connected == nil { + out.Connected = new(bool) + } + if in.IsNull() { + in.Skip() + } else { + *out.Connected = bool(in.Bool()) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd(out *jwriter.Writer, in BackendServerInfoEtcd) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"endpoints\":" + out.RawString(prefix[1:]) + if in.Endpoints == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v2, v3 := range in.Endpoints { + if v2 > 0 { + out.RawByte(',') + } + out.String(string(v3)) + } + out.RawByte(']') + } + } + if in.Active != "" { + const prefix string = ",\"active\":" + out.RawString(prefix) + out.String(string(in.Active)) + } + if in.Connected != nil { + const prefix string = ",\"connected\":" + out.RawString(prefix) + out.Bool(bool(*in.Connected)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerInfoEtcd) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerInfoEtcd) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerInfoEtcd) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerInfoEtcd) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd1(in *jlexer.Lexer, out *BackendInformationEtcd) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "url": + if in.IsNull() { + in.Skip() + } else { + out.Url = string(in.String()) + } + case "urls": + if in.IsNull() { + in.Skip() + out.Urls = nil + } else { + in.Delim('[') + if out.Urls == nil { + if !in.IsDelim(']') { + out.Urls = make([]string, 0, 4) + } else { + out.Urls = []string{} + } + } else { + out.Urls = (out.Urls)[:0] + } + for !in.IsDelim(']') { + var v4 string + if in.IsNull() { + in.Skip() + } else { + v4 = string(in.String()) + } + out.Urls = append(out.Urls, v4) + in.WantComma() + } + in.Delim(']') + } + case "secret": + if in.IsNull() { + in.Skip() + } else { + out.Secret = string(in.String()) + } + case "maxstreambitrate": + if in.IsNull() { + in.Skip() + } else { + out.MaxStreamBitrate = api.Bandwidth(in.Uint64()) + } + case "maxscreenbitrate": + if in.IsNull() { + in.Skip() + } else { + out.MaxScreenBitrate = api.Bandwidth(in.Uint64()) + } + case "sessionlimit": + if in.IsNull() { + in.Skip() + } else { + out.SessionLimit = uint64(in.Uint64()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd1(out *jwriter.Writer, in BackendInformationEtcd) { + out.RawByte('{') + first := true + _ = first + if in.Url != "" { + const prefix string = ",\"url\":" + first = false + out.RawString(prefix[1:]) + out.String(string(in.Url)) + } + if len(in.Urls) != 0 { + const prefix string = ",\"urls\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + { + out.RawByte('[') + for v5, v6 := range in.Urls { + if v5 > 0 { + out.RawByte(',') + } + out.String(string(v6)) + } + out.RawByte(']') + } + } + { + const prefix string = ",\"secret\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.Secret)) + } + if in.MaxStreamBitrate != 0 { + const prefix string = ",\"maxstreambitrate\":" + out.RawString(prefix) + out.Uint64(uint64(in.MaxStreamBitrate)) + } + if in.MaxScreenBitrate != 0 { + const prefix string = ",\"maxscreenbitrate\":" + out.RawString(prefix) + out.Uint64(uint64(in.MaxScreenBitrate)) + } + if in.SessionLimit != 0 { + const prefix string = ",\"sessionlimit\":" + out.RawString(prefix) + out.Uint64(uint64(in.SessionLimit)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendInformationEtcd) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd1(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendInformationEtcd) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd1(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendInformationEtcd) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd1(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendInformationEtcd) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Etcd1(l, v) +} diff --git a/etcd/api_test.go b/etcd/api_test.go new file mode 100644 index 0000000..887a4ee --- /dev/null +++ b/etcd/api_test.go @@ -0,0 +1,135 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package etcd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateBackendInformationEtcd(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + testcases := []struct { + b BackendInformationEtcd + expectedError string + expectedUrls []string + }{ + { + b: BackendInformationEtcd{}, + expectedError: "secret missing", + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + }, + expectedError: "urls missing", + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + Url: "https://foo\n", + }, + expectedError: "invalid url", + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + Urls: []string{"https://foo\n"}, + }, + expectedError: "invalid url", + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + Urls: []string{"https://foo", "https://foo\n"}, + }, + expectedError: "invalid url", + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + Url: "https://foo:443", + }, + expectedUrls: []string{"https://foo"}, + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + Urls: []string{"https://foo:443"}, + }, + expectedUrls: []string{"https://foo"}, + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + Url: "https://foo:8443", + }, + expectedUrls: []string{"https://foo:8443"}, + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + Urls: []string{"https://foo:8443"}, + }, + expectedUrls: []string{"https://foo:8443"}, + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + Urls: []string{"https://foo", "https://bar", "https://foo"}, + }, + expectedUrls: []string{"https://bar", "https://foo"}, + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + Urls: []string{"https://foo", "https://bar", "https://foo:443", "https://zaz"}, + }, + expectedUrls: []string{"https://bar", "https://foo", "https://zaz"}, + }, + { + b: BackendInformationEtcd{ + Secret: "verysecret", + Urls: []string{"https://foo:443", "https://bar", "https://foo", "https://zaz"}, + }, + expectedUrls: []string{"https://bar", "https://foo", "https://zaz"}, + }, + } + + for idx, tc := range testcases { + if tc.expectedError == "" { + if assert.NoError(tc.b.CheckValid(), "failed for testcase %d", idx) { + assert.Equal(tc.expectedUrls, tc.b.Urls, "failed for testcase %d", idx) + var urls []string + for _, u := range tc.b.ParsedUrls { + urls = append(urls, u.String()) + } + assert.Equal(tc.expectedUrls, urls, "failed for testcase %d", idx) + } + } else { + assert.ErrorContains(tc.b.CheckValid(), tc.expectedError, "failed for testcase %d, got %+v", idx, tc.b.ParsedUrls) + } + } +} diff --git a/etcd_client.go b/etcd/client.go similarity index 62% rename from etcd_client.go rename to etcd/client.go index ea1b64d..1c60687 100644 --- a/etcd_client.go +++ b/etcd/client.go @@ -19,14 +19,13 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package etcd import ( "context" "errors" "fmt" - "log" - "strings" + "slices" "sync" "sync/atomic" "time" @@ -37,28 +36,31 @@ import ( clientv3 "go.etcd.io/etcd/client/v3" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "google.golang.org/grpc/connectivity" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/async" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) -type EtcdClientListener interface { - EtcdClientCreated(client *EtcdClient) -} +var ( + initialWaitDelay = time.Second + maxWaitDelay = 8 * time.Second +) -type EtcdClientWatcher interface { - EtcdWatchCreated(client *EtcdClient, key string) - EtcdKeyUpdated(client *EtcdClient, key string, value []byte, prevValue []byte) - EtcdKeyDeleted(client *EtcdClient, key string, prevValue []byte) -} - -type EtcdClient struct { +type etcdClient struct { + logger log.Logger compatSection string - mu sync.Mutex - client atomic.Value - listeners map[EtcdClientListener]bool + mu sync.Mutex + client atomic.Value + // +checklocks:mu + listeners map[ClientListener]bool } -func NewEtcdClient(config *goconf.ConfigFile, compatSection string) (*EtcdClient, error) { - result := &EtcdClient{ +func NewClient(logger log.Logger, config *goconf.ConfigFile, compatSection string) (Client, error) { + result := &etcdClient{ + logger: logger, compatSection: compatSection, } if err := result.load(config, false); err != nil { @@ -68,33 +70,47 @@ func NewEtcdClient(config *goconf.ConfigFile, compatSection string) (*EtcdClient return result, nil } -func (c *EtcdClient) getConfigStringWithFallback(config *goconf.ConfigFile, option string) string { +func (c *etcdClient) GetServerInfoEtcd() *BackendServerInfoEtcd { + client := c.getEtcdClient() + if client == nil { + return nil + } + + result := &BackendServerInfoEtcd{ + Endpoints: client.Endpoints(), + } + + conn := client.ActiveConnection() + if conn != nil { + result.Active = conn.Target() + result.Connected = internal.MakePtr(conn.GetState() == connectivity.Ready) + } + + return result +} + +func (c *etcdClient) getConfigStringWithFallback(config *goconf.ConfigFile, option string) string { value, _ := config.GetString("etcd", option) if value == "" && c.compatSection != "" { value, _ = config.GetString(c.compatSection, option) if value != "" { - log.Printf("WARNING: Configuring etcd option \"%s\" in section \"%s\" is deprecated, use section \"etcd\" instead", option, c.compatSection) + c.logger.Printf("WARNING: Configuring etcd option \"%s\" in section \"%s\" is deprecated, use section \"etcd\" instead", option, c.compatSection) } } return value } -func (c *EtcdClient) load(config *goconf.ConfigFile, ignoreErrors bool) error { +func (c *etcdClient) load(config *goconf.ConfigFile, ignoreErrors bool) error { var endpoints []string if endpointsString := c.getConfigStringWithFallback(config, "endpoints"); endpointsString != "" { - for _, ep := range strings.Split(endpointsString, ",") { - ep := strings.TrimSpace(ep) - if ep != "" { - endpoints = append(endpoints, ep) - } - } + endpoints = slices.Collect(internal.SplitEntries(endpointsString, ",")) } else if discoverySrv := c.getConfigStringWithFallback(config, "discoverysrv"); discoverySrv != "" { discoveryService := c.getConfigStringWithFallback(config, "discoveryservice") clients, err := srv.GetClient("etcd-client", discoverySrv, discoveryService) if err != nil { if !ignoreErrors { - return fmt.Errorf("Could not discover etcd endpoints for %s: %w", discoverySrv, err) + return fmt.Errorf("could not discover etcd endpoints for %s: %w", discoverySrv, err) } } else { endpoints = clients.Endpoints @@ -106,7 +122,7 @@ func (c *EtcdClient) load(config *goconf.ConfigFile, ignoreErrors bool) error { return nil } - log.Printf("No etcd endpoints configured, not changing client") + c.logger.Printf("No etcd endpoints configured, not changing client") } else { cfg := clientv3.Config{ Endpoints: endpoints, @@ -118,7 +134,7 @@ func (c *EtcdClient) load(config *goconf.ConfigFile, ignoreErrors bool) error { if logLevel, _ := config.GetString("etcd", "loglevel"); logLevel != "" { var l zapcore.Level if err := l.Set(logLevel); err != nil { - return fmt.Errorf("Unsupported etcd log level %s: %w", logLevel, err) + return fmt.Errorf("unsupported etcd log level %s: %w", logLevel, err) } logConfig := zap.NewProductionConfig() @@ -138,10 +154,10 @@ func (c *EtcdClient) load(config *goconf.ConfigFile, ignoreErrors bool) error { tlsConfig, err := tlsInfo.ClientConfig() if err != nil { if !ignoreErrors { - return fmt.Errorf("Could not setup etcd TLS configuration: %w", err) + return fmt.Errorf("could not setup etcd TLS configuration: %w", err) } - log.Printf("Could not setup TLS configuration, will be disabled (%s)", err) + c.logger.Printf("Could not setup TLS configuration, will be disabled (%s)", err) } else { cfg.TLS = tlsConfig } @@ -153,14 +169,14 @@ func (c *EtcdClient) load(config *goconf.ConfigFile, ignoreErrors bool) error { return err } - log.Printf("Could not create new client from etd endpoints %+v: %s", endpoints, err) + c.logger.Printf("Could not create new client from etd endpoints %+v: %s", endpoints, err) } else { prev := c.getEtcdClient() if prev != nil { prev.Close() } c.client.Store(client) - log.Printf("Using etcd endpoints %+v", endpoints) + c.logger.Printf("Using etcd endpoints %+v", endpoints) c.notifyListeners() } } @@ -168,7 +184,7 @@ func (c *EtcdClient) load(config *goconf.ConfigFile, ignoreErrors bool) error { return nil } -func (c *EtcdClient) Close() error { +func (c *etcdClient) Close() error { client := c.getEtcdClient() if client != nil { return client.Close() @@ -177,11 +193,11 @@ func (c *EtcdClient) Close() error { return nil } -func (c *EtcdClient) IsConfigured() bool { +func (c *etcdClient) IsConfigured() bool { return c.getEtcdClient() != nil } -func (c *EtcdClient) getEtcdClient() *clientv3.Client { +func (c *etcdClient) getEtcdClient() *clientv3.Client { client := c.client.Load() if client == nil { return nil @@ -190,14 +206,14 @@ func (c *EtcdClient) getEtcdClient() *clientv3.Client { return client.(*clientv3.Client) } -func (c *EtcdClient) syncClient(ctx context.Context) error { +func (c *etcdClient) syncClient(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() return c.getEtcdClient().Sync(ctx) } -func (c *EtcdClient) notifyListeners() { +func (c *etcdClient) notifyListeners() { c.mu.Lock() defer c.mu.Unlock() @@ -206,12 +222,12 @@ func (c *EtcdClient) notifyListeners() { } } -func (c *EtcdClient) AddListener(listener EtcdClientListener) { +func (c *etcdClient) AddListener(listener ClientListener) { c.mu.Lock() defer c.mu.Unlock() if c.listeners == nil { - c.listeners = make(map[EtcdClientListener]bool) + c.listeners = make(map[ClientListener]bool) } c.listeners[listener] = true if client := c.getEtcdClient(); client != nil { @@ -219,15 +235,15 @@ func (c *EtcdClient) AddListener(listener EtcdClientListener) { } } -func (c *EtcdClient) RemoveListener(listener EtcdClientListener) { +func (c *etcdClient) RemoveListener(listener ClientListener) { c.mu.Lock() defer c.mu.Unlock() delete(c.listeners, listener) } -func (c *EtcdClient) WaitForConnection(ctx context.Context) error { - backoff, err := NewExponentialBackoff(initialWaitDelay, maxWaitDelay) +func (c *etcdClient) WaitForConnection(ctx context.Context) error { + backoff, err := async.NewExponentialBackoff(initialWaitDelay, maxWaitDelay) if err != nil { return err } @@ -241,29 +257,29 @@ func (c *EtcdClient) WaitForConnection(ctx context.Context) error { if errors.Is(err, context.Canceled) { return err } else if errors.Is(err, context.DeadlineExceeded) { - log.Printf("Timeout waiting for etcd client to connect to the cluster, retry in %s", backoff.NextWait()) + c.logger.Printf("Timeout waiting for etcd client to connect to the cluster, retry in %s", backoff.NextWait()) } else { - log.Printf("Could not sync etcd client with the cluster, retry in %s: %s", backoff.NextWait(), err) + c.logger.Printf("Could not sync etcd client with the cluster, retry in %s: %s", backoff.NextWait(), err) } backoff.Wait(ctx) continue } - log.Printf("Client synced, using endpoints %+v", c.getEtcdClient().Endpoints()) + c.logger.Printf("Client synced, using endpoints %+v", c.getEtcdClient().Endpoints()) return nil } } -func (c *EtcdClient) Get(ctx context.Context, key string, opts ...clientv3.OpOption) (*clientv3.GetResponse, error) { +func (c *etcdClient) Get(ctx context.Context, key string, opts ...clientv3.OpOption) (*clientv3.GetResponse, error) { return c.getEtcdClient().Get(ctx, key, opts...) } -func (c *EtcdClient) Watch(ctx context.Context, key string, nextRevision int64, watcher EtcdClientWatcher, opts ...clientv3.OpOption) (int64, error) { - log.Printf("Wait for leader and start watching on %s (rev=%d)", key, nextRevision) +func (c *etcdClient) Watch(ctx context.Context, key string, nextRevision int64, watcher ClientWatcher, opts ...clientv3.OpOption) (int64, error) { + c.logger.Printf("Wait for leader and start watching on %s (rev=%d)", key, nextRevision) opts = append(opts, clientv3.WithRev(nextRevision), clientv3.WithPrevKV()) ch := c.getEtcdClient().Watch(clientv3.WithRequireLeader(ctx), key, opts...) - log.Printf("Watch created for %s", key) + c.logger.Printf("Watch created for %s", key) watcher.EtcdWatchCreated(c, key) for response := range ch { if err := response.Err(); err != nil { @@ -286,7 +302,7 @@ func (c *EtcdClient) Watch(ctx context.Context, key string, nextRevision int64, } watcher.EtcdKeyDeleted(c, string(ev.Kv.Key), prevValue) default: - log.Printf("Unsupported watch event %s %q -> %q", ev.Type, ev.Kv.Key, ev.Kv.Value) + c.logger.Printf("Unsupported watch event %s %q -> %q", ev.Type, ev.Kv.Key, ev.Kv.Value) } } } diff --git a/etcd_client_test.go b/etcd/client_test.go similarity index 51% rename from etcd_client_test.go rename to etcd/client_test.go index 7986e2e..f7bac1e 100644 --- a/etcd_client_test.go +++ b/etcd/client_test.go @@ -19,17 +19,17 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package etcd import ( "context" - "errors" + "crypto/rand" + "crypto/rsa" "net" "net/url" "os" - "runtime" + "path" "strconv" - "syscall" "testing" "time" @@ -42,41 +42,57 @@ import ( "go.etcd.io/etcd/server/v3/lease" "go.uber.org/zap" "go.uber.org/zap/zaptest" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" +) + +const ( + testTimeout = 10 * time.Second ) var ( etcdListenUrl = "http://localhost:8080" ) -func isErrorAddressAlreadyInUse(err error) bool { - var eOsSyscall *os.SyscallError - if !errors.As(err, &eOsSyscall) { - return false - } - var errErrno syscall.Errno // doesn't need a "*" (ptr) because it's already a ptr (uintptr) - if !errors.As(eOsSyscall, &errErrno) { - return false - } - if errErrno == syscall.EADDRINUSE { - return true - } - const WSAEADDRINUSE = 10048 - if runtime.GOOS == "windows" && errErrno == WSAEADDRINUSE { - return true - } - return false -} +func NewEtcdForTestWithTls(t *testing.T, withTLS bool) (*embed.Etcd, string, string) { + t.Helper() -func NewEtcdForTest(t *testing.T) *embed.Etcd { require := require.New(t) cfg := embed.NewConfig() cfg.Dir = t.TempDir() os.Chmod(cfg.Dir, 0700) // nolint cfg.LogLevel = "warn" + cfg.Name = "signalingtest" + cfg.ZapLoggerBuilder = embed.NewZapLoggerBuilder(zaptest.NewLogger(t, zaptest.Level(zap.WarnLevel))) u, err := url.Parse(etcdListenUrl) require.NoError(err) + var keyfile string + var certfile string + if withTLS { + u.Scheme = "https" + + tmpdir := t.TempDir() + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + keyfile = path.Join(tmpdir, "etcd.key") + require.NoError(internal.WritePrivateKey(key, keyfile)) + cfg.ClientTLSInfo.KeyFile = keyfile + cfg.PeerTLSInfo.KeyFile = keyfile + + cert := internal.GenerateSelfSignedCertificateForTesting(t, "etcd", key) + certfile = path.Join(tmpdir, "etcd.pem") + require.NoError(internal.WriteCertificate(cert, certfile)) + cfg.ClientTLSInfo.CertFile = certfile + cfg.ClientTLSInfo.TrustedCAFile = certfile + cfg.PeerTLSInfo.CertFile = certfile + cfg.PeerTLSInfo.TrustedCAFile = certfile + } + // Find a free port to bind the server to. var etcd *embed.Etcd for port := 50000; port < 50100; port++ { @@ -90,10 +106,9 @@ func NewEtcdForTest(t *testing.T) *embed.Etcd { peerListener.Host = net.JoinHostPort("localhost", strconv.Itoa(port+2)) cfg.ListenPeerUrls = []url.URL{*peerListener} cfg.AdvertisePeerUrls = []url.URL{*peerListener} - cfg.InitialCluster = "default=" + peerListener.String() - cfg.ZapLoggerBuilder = embed.NewZapLoggerBuilder(zaptest.NewLogger(t, zaptest.Level(zap.WarnLevel))) + cfg.InitialCluster = "signalingtest=" + peerListener.String() etcd, err = embed.StartEtcd(cfg) - if isErrorAddressAlreadyInUse(err) { + if test.IsErrorAddressAlreadyInUse(err) { continue } @@ -109,17 +124,25 @@ func NewEtcdForTest(t *testing.T) *embed.Etcd { // Wait for server to be ready. <-etcd.Server.ReadyNotify() + return etcd, keyfile, certfile +} + +func NewEtcdForTest(t *testing.T) *embed.Etcd { + t.Helper() + + etcd, _, _ := NewEtcdForTestWithTls(t, false) return etcd } -func NewEtcdClientForTest(t *testing.T) (*embed.Etcd, *EtcdClient) { +func NewClientForTest(t *testing.T) (*embed.Etcd, Client) { etcd := NewEtcdForTest(t) config := goconf.NewConfigFile() config.AddOption("etcd", "endpoints", etcd.Config().ListenClientUrls[0].String()) config.AddOption("etcd", "loglevel", "error") - client, err := NewEtcdClient(config, "") + logger := logtest.NewLoggerForTest(t) + client, err := NewClient(logger, config, "") require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, client.Close()) @@ -127,14 +150,33 @@ func NewEtcdClientForTest(t *testing.T) (*embed.Etcd, *EtcdClient) { return etcd, client } -func SetEtcdValue(etcd *embed.Etcd, key string, value []byte) { +func NewEtcdClientWithTLSForTest(t *testing.T) (*embed.Etcd, Client) { + etcd, keyfile, certfile := NewEtcdForTestWithTls(t, true) + + config := goconf.NewConfigFile() + config.AddOption("etcd", "endpoints", etcd.Config().ListenClientUrls[0].String()) + config.AddOption("etcd", "loglevel", "error") + config.AddOption("etcd", "clientkey", keyfile) + config.AddOption("etcd", "clientcert", certfile) + config.AddOption("etcd", "cacert", certfile) + + logger := logtest.NewLoggerForTest(t) + client, err := NewClient(logger, config, "") + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, client.Close()) + }) + return etcd, client +} + +func SetValue(etcd *embed.Etcd, key string, value []byte) { if kv := etcd.Server.KV(); kv != nil { kv.Put([]byte(key), value, lease.NoLease) kv.Commit() } } -func DeleteEtcdValue(etcd *embed.Etcd, key string) { +func DeleteValue(etcd *embed.Etcd, key string) { if kv := etcd.Server.KV(); kv != nil { kv.DeleteRange([]byte(key), nil) kv.Commit() @@ -143,17 +185,87 @@ func DeleteEtcdValue(etcd *embed.Etcd, key string) { func Test_EtcdClient_Get(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) - etcd, client := NewEtcdClientForTest(t) + require := require.New(t) + etcd, client := NewClientForTest(t) - if response, err := client.Get(context.Background(), "foo"); assert.NoError(err) { + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + if info := client.GetServerInfoEtcd(); assert.NotNil(info) { + assert.NotEmpty(info.Active) + assert.Equal([]string{ + etcd.Config().ListenClientUrls[0].String(), + }, info.Endpoints) + assert.NotNil(info.Connected) + } + + require.NoError(client.WaitForConnection(ctx)) + + if info := client.GetServerInfoEtcd(); assert.NotNil(info) { + assert.NotEmpty(info.Active) + assert.Equal([]string{ + etcd.Config().ListenClientUrls[0].String(), + }, info.Endpoints) + if connected := info.Connected; assert.NotNil(connected) { + assert.True(*connected) + } + } + + if response, err := client.Get(ctx, "foo"); assert.NoError(err) { assert.EqualValues(0, response.Count) } - SetEtcdValue(etcd, "foo", []byte("bar")) + SetValue(etcd, "foo", []byte("bar")) - if response, err := client.Get(context.Background(), "foo"); assert.NoError(err) { + if response, err := client.Get(ctx, "foo"); assert.NoError(err) { + if assert.EqualValues(1, response.Count) { + assert.Equal("foo", string(response.Kvs[0].Key)) + assert.Equal("bar", string(response.Kvs[0].Value)) + } + } +} + +func Test_EtcdClientTLS_Get(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + assert := assert.New(t) + require := require.New(t) + etcd, client := NewEtcdClientWithTLSForTest(t) + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + if info := client.GetServerInfoEtcd(); assert.NotNil(info) { + assert.NotEmpty(info.Active) + assert.Equal([]string{ + etcd.Config().ListenClientUrls[0].String(), + }, info.Endpoints) + assert.NotNil(info.Connected) + } + + require.NoError(client.WaitForConnection(ctx)) + + if info := client.GetServerInfoEtcd(); assert.NotNil(info) { + assert.NotEmpty(info.Active) + assert.Equal([]string{ + etcd.Config().ListenClientUrls[0].String(), + }, info.Endpoints) + if connected := info.Connected; assert.NotNil(connected) { + assert.True(*connected) + } + } + + if response, err := client.Get(ctx, "foo"); assert.NoError(err) { + assert.EqualValues(0, response.Count) + } + + SetValue(etcd, "foo", []byte("bar")) + + if response, err := client.Get(ctx, "foo"); assert.NoError(err) { if assert.EqualValues(1, response.Count) { assert.Equal("foo", string(response.Kvs[0].Key)) assert.Equal("bar", string(response.Kvs[0].Value)) @@ -163,19 +275,20 @@ func Test_EtcdClient_Get(t *testing.T) { func Test_EtcdClient_GetPrefix(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) - etcd, client := NewEtcdClientForTest(t) + etcd, client := NewClientForTest(t) - if response, err := client.Get(context.Background(), "foo"); assert.NoError(err) { + if response, err := client.Get(ctx, "foo"); assert.NoError(err) { assert.EqualValues(0, response.Count) } - SetEtcdValue(etcd, "foo", []byte("1")) - SetEtcdValue(etcd, "foo/lala", []byte("2")) - SetEtcdValue(etcd, "lala/foo", []byte("3")) + SetValue(etcd, "foo", []byte("1")) + SetValue(etcd, "foo/lala", []byte("2")) + SetValue(etcd, "lala/foo", []byte("3")) - if response, err := client.Get(context.Background(), "foo", clientv3.WithPrefix()); assert.NoError(err) { + if response, err := client.Get(ctx, "foo", clientv3.WithPrefix()); assert.NoError(err) { if assert.EqualValues(2, response.Count) { assert.Equal("foo", string(response.Kvs[0].Key)) assert.Equal("1", string(response.Kvs[0].Value)) @@ -220,7 +333,7 @@ func (l *EtcdClientTestListener) Close() { l.cancel() } -func (l *EtcdClientTestListener) EtcdClientCreated(client *EtcdClient) { +func (l *EtcdClientTestListener) EtcdClientCreated(client Client) { go func() { assert := assert.New(l.t) if err := client.WaitForConnection(l.ctx); !assert.NoError(err) { @@ -246,10 +359,10 @@ func (l *EtcdClientTestListener) EtcdClientCreated(client *EtcdClient) { }() } -func (l *EtcdClientTestListener) EtcdWatchCreated(client *EtcdClient, key string) { +func (l *EtcdClientTestListener) EtcdWatchCreated(client Client, key string) { } -func (l *EtcdClientTestListener) EtcdKeyUpdated(client *EtcdClient, key string, value []byte, prevValue []byte) { +func (l *EtcdClientTestListener) EtcdKeyUpdated(client Client, key string, value []byte, prevValue []byte) { evt := etcdEvent{ t: clientv3.EventTypePut, key: string(key), @@ -261,7 +374,7 @@ func (l *EtcdClientTestListener) EtcdKeyUpdated(client *EtcdClient, key string, l.events <- evt } -func (l *EtcdClientTestListener) EtcdKeyDeleted(client *EtcdClient, key string, prevValue []byte) { +func (l *EtcdClientTestListener) EtcdKeyDeleted(client Client, key string, prevValue []byte) { evt := etcdEvent{ t: clientv3.EventTypeDelete, key: string(key), @@ -274,13 +387,14 @@ func (l *EtcdClientTestListener) EtcdKeyDeleted(client *EtcdClient, key string, func Test_EtcdClient_Watch(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) - etcd, client := NewEtcdClientForTest(t) + etcd, client := NewClientForTest(t) - SetEtcdValue(etcd, "foo/a", []byte("1")) + SetValue(etcd, "foo/a", []byte("1")) - listener := NewEtcdClientTestListener(context.Background(), t) + listener := NewEtcdClientTestListener(ctx, t) defer listener.Close() client.AddListener(listener) @@ -288,19 +402,19 @@ func Test_EtcdClient_Watch(t *testing.T) { <-listener.initial - SetEtcdValue(etcd, "foo/b", []byte("2")) + SetValue(etcd, "foo/b", []byte("2")) event := <-listener.events assert.Equal(clientv3.EventTypePut, event.t) assert.Equal("foo/b", event.key) assert.Equal("2", event.value) - SetEtcdValue(etcd, "foo/a", []byte("3")) + SetValue(etcd, "foo/a", []byte("3")) event = <-listener.events assert.Equal(clientv3.EventTypePut, event.t) assert.Equal("foo/a", event.key) assert.Equal("3", event.value) - DeleteEtcdValue(etcd, "foo/a") + DeleteValue(etcd, "foo/a") event = <-listener.events assert.Equal(clientv3.EventTypeDelete, event.t) assert.Equal("foo/a", event.key) diff --git a/etcd/test/etcd.go b/etcd/test/etcd.go new file mode 100644 index 0000000..e29499b --- /dev/null +++ b/etcd/test/etcd.go @@ -0,0 +1,466 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "bytes" + "context" + "errors" + "net" + "net/url" + "os" + "slices" + "strconv" + "strings" + "sync" + "testing" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.etcd.io/etcd/api/v3/etcdserverpb" + "go.etcd.io/etcd/api/v3/mvccpb" + clientv3 "go.etcd.io/etcd/client/v3" + "go.etcd.io/etcd/server/v3/embed" + "go.etcd.io/etcd/server/v3/lease" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" +) + +var ( + etcdListenUrl = "http://localhost:8080" +) + +type EtcdServer struct { + embed *embed.Etcd +} + +func (s *EtcdServer) URL() *url.URL { + return &s.embed.Config().ListenClientUrls[0] +} + +func (s *EtcdServer) SetValue(key string, value []byte) { + if kv := s.embed.Server.KV(); kv != nil { + kv.Put([]byte(key), value, lease.NoLease) + kv.Commit() + } +} + +func (s *EtcdServer) DeleteValue(key string) { + if kv := s.embed.Server.KV(); kv != nil { + kv.DeleteRange([]byte(key), nil) + kv.Commit() + } +} + +func NewServerForTest(t *testing.T) *EtcdServer { + t.Helper() + require := require.New(t) + cfg := embed.NewConfig() + cfg.Dir = t.TempDir() + os.Chmod(cfg.Dir, 0700) // nolint + cfg.LogLevel = "warn" + cfg.Name = "signalingtest" + cfg.ZapLoggerBuilder = embed.NewZapLoggerBuilder(zaptest.NewLogger(t, zaptest.Level(zap.WarnLevel))) + + u, err := url.Parse(etcdListenUrl) + require.NoError(err) + + // Find a free port to bind the server to. + var etcd *embed.Etcd + for port := 50000; port < 50100; port++ { + u.Host = net.JoinHostPort("localhost", strconv.Itoa(port)) + cfg.ListenClientUrls = []url.URL{*u} + cfg.AdvertiseClientUrls = []url.URL{*u} + httpListener := u + httpListener.Host = net.JoinHostPort("localhost", strconv.Itoa(port+1)) + cfg.ListenClientHttpUrls = []url.URL{*httpListener} + peerListener := u + peerListener.Host = net.JoinHostPort("localhost", strconv.Itoa(port+2)) + cfg.ListenPeerUrls = []url.URL{*peerListener} + cfg.AdvertisePeerUrls = []url.URL{*peerListener} + cfg.InitialCluster = "signalingtest=" + peerListener.String() + etcd, err = embed.StartEtcd(cfg) + if test.IsErrorAddressAlreadyInUse(err) { + continue + } + + require.NoError(err) + break + } + require.NotNil(etcd, "could not find free port") + + t.Cleanup(func() { + etcd.Close() + <-etcd.Server.StopNotify() + }) + // Wait for server to be ready. + <-etcd.Server.ReadyNotify() + + server := &EtcdServer{ + embed: etcd, + } + return server +} + +func NewEtcdClientForTest(t *testing.T, server *EtcdServer) etcd.Client { + t.Helper() + + logger := logtest.NewLoggerForTest(t) + + config := goconf.NewConfigFile() + config.AddOption("etcd", "endpoints", server.URL().String()) + + client, err := etcd.NewClient(logger, config, "") + require.NoError(t, err) + + t.Cleanup(func() { + assert.NoError(t, client.Close()) + }) + + return client +} + +type testWatch struct { + key string + op clientv3.Op + rev int64 + + watcher etcd.ClientWatcher +} + +type testClient struct { + mu sync.Mutex + server *Server + + // +checklocks:mu + closed bool + closeCh chan struct{} + processCh chan func() + // +checklocks:mu + listeners []etcd.ClientListener + // +checklocks:mu + watchers []*testWatch +} + +func newTestClient(server *Server) *testClient { + client := &testClient{ + server: server, + closeCh: make(chan struct{}), + processCh: make(chan func(), 1), + } + go func() { + defer close(client.closeCh) + for { + f := <-client.processCh + if f == nil { + return + } + + f() + } + }() + return client +} + +func (c *testClient) IsConfigured() bool { + return true +} + +func (c *testClient) WaitForConnection(ctx context.Context) error { + return nil +} + +func (c *testClient) GetServerInfoEtcd() *etcd.BackendServerInfoEtcd { + return &etcd.BackendServerInfoEtcd{} +} + +func (c *testClient) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return nil + } + + c.closed = true + c.server.removeClient(c) + close(c.processCh) + <-c.closeCh + return nil +} + +func (c *testClient) AddListener(listener etcd.ClientListener) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return + } + + c.listeners = append(c.listeners, listener) + c.processCh <- func() { + listener.EtcdClientCreated(c) + } +} + +func (c *testClient) RemoveListener(listener etcd.ClientListener) { + c.mu.Lock() + defer c.mu.Unlock() + + c.listeners = slices.DeleteFunc(c.listeners, func(l etcd.ClientListener) bool { + return l == listener + }) +} + +func (c *testClient) Get(ctx context.Context, key string, opts ...clientv3.OpOption) (*clientv3.GetResponse, error) { + keys, values, revision := c.server.getValues(key, 0, opts...) + response := &clientv3.GetResponse{ + Count: int64(len(values)), + Header: &etcdserverpb.ResponseHeader{ + Revision: revision, + }, + } + for idx, key := range keys { + response.Kvs = append(response.Kvs, &mvccpb.KeyValue{ + Key: []byte(key), + Value: values[idx], + }) + } + return response, nil +} + +func (c *testClient) notifyUpdated(key string, oldValue []byte, newValue []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return + } + + for _, w := range c.watchers { + if withPrefix := w.op.IsOptsWithPrefix(); (withPrefix && strings.HasPrefix(key, w.key)) || (!withPrefix && key == w.key) { + c.processCh <- func() { + w.watcher.EtcdKeyUpdated(c, key, newValue, oldValue) + } + } + } +} + +func (c *testClient) notifyDeleted(key string, oldValue []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return + } + + for _, w := range c.watchers { + if withPrefix := w.op.IsOptsWithPrefix(); (withPrefix && strings.HasPrefix(key, w.key)) || (!withPrefix && key == w.key) { + c.processCh <- func() { + w.watcher.EtcdKeyDeleted(c, key, oldValue) + } + } + } +} + +func (c *testClient) addWatcher(w *testWatch, opts ...clientv3.OpOption) error { + keys, values, _ := c.server.getValues(w.key, w.rev, opts...) + + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return errors.New("closed") + } + + c.watchers = append(c.watchers, w) + c.processCh <- func() { + w.watcher.EtcdWatchCreated(c, w.key) + } + + for idx, key := range keys { + c.processCh <- func() { + w.watcher.EtcdKeyUpdated(c, key, values[idx], nil) + } + } + + return nil +} + +func (c *testClient) Watch(ctx context.Context, key string, nextRevision int64, watcher etcd.ClientWatcher, opts ...clientv3.OpOption) (int64, error) { + w := &testWatch{ + key: key, + rev: nextRevision, + + watcher: watcher, + } + for _, o := range opts { + o(&w.op) + } + + if err := c.addWatcher(w, opts...); err != nil { + return 0, err + } + + select { + case <-c.closeCh: + // Client is closed. + case <-ctx.Done(): + // Watch context was cancelled / timed out. + } + return c.server.getRevision(), nil +} + +type testServerValue struct { + value []byte + revision int64 +} + +type Server struct { + t *testing.T + mu sync.Mutex + // +checklocks:mu + clients []*testClient + // +checklocks:mu + values map[string]*testServerValue + // +checklocks:mu + revision int64 +} + +func (s *Server) newClient() *testClient { + client := newTestClient(s) + s.addClient(client) + return client +} + +func (s *Server) addClient(client *testClient) { + s.mu.Lock() + defer s.mu.Unlock() + + s.clients = append(s.clients, client) +} + +func (s *Server) removeClient(client *testClient) { + s.mu.Lock() + defer s.mu.Unlock() + + s.clients = slices.DeleteFunc(s.clients, func(c *testClient) bool { + return c == client + }) +} + +func (s *Server) getRevision() int64 { + s.mu.Lock() + defer s.mu.Unlock() + + return s.revision +} + +func (s *Server) getValues(key string, minRevision int64, opts ...clientv3.OpOption) (keys []string, values [][]byte, revision int64) { + s.mu.Lock() + defer s.mu.Unlock() + + var op clientv3.Op + for _, o := range opts { + o(&op) + } + if op.IsOptsWithPrefix() { + for k, value := range s.values { + if minRevision > 0 && value.revision < minRevision { + continue + } + if strings.HasPrefix(k, key) { + keys = append(keys, k) + values = append(values, value.value) + } + } + } else { + if value, found := s.values[key]; found && (minRevision == 0 || value.revision >= minRevision) { + keys = append(keys, key) + values = append(values, value.value) + } + } + + revision = s.revision + return +} + +func (s *Server) SetValue(key string, value []byte) { + s.mu.Lock() + defer s.mu.Unlock() + + prev, found := s.values[key] + if found && bytes.Equal(prev.value, value) { + return + } + + if s.values == nil { + s.values = make(map[string]*testServerValue) + } + if prev == nil { + prev = &testServerValue{} + s.values[key] = prev + } + s.revision++ + prevValue := prev.value + prev.value = value + prev.revision = s.revision + + for _, c := range s.clients { + c.notifyUpdated(key, prevValue, value) + } +} + +func (s *Server) DeleteValue(key string) { + s.mu.Lock() + defer s.mu.Unlock() + + prev, found := s.values[key] + if !found { + return + } + + delete(s.values, key) + s.revision++ + + for _, c := range s.clients { + c.notifyDeleted(key, prev.value) + } +} + +func NewClientForTest(t *testing.T) (*Server, etcd.Client) { + t.Helper() + server := &Server{ + t: t, + revision: 1, + } + client := server.newClient() + t.Cleanup(func() { + assert.NoError(t, client.Close()) + }) + return server, client +} diff --git a/etcd/test/etcd_test.go b/etcd/test/etcd_test.go new file mode 100644 index 0000000..652112e --- /dev/null +++ b/etcd/test/etcd_test.go @@ -0,0 +1,319 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + clientv3 "go.etcd.io/etcd/client/v3" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" +) + +var ( + testTimeout = 10 * time.Second +) + +type updateEvent struct { + key string + value string + prev []byte +} + +type deleteEvent struct { + key string + prev []byte +} + +type testWatcher struct { + created chan struct{} + updated chan updateEvent + deleted chan deleteEvent +} + +func newTestWatcher() *testWatcher { + return &testWatcher{ + created: make(chan struct{}), + updated: make(chan updateEvent), + deleted: make(chan deleteEvent), + } +} + +func (w *testWatcher) EtcdWatchCreated(client etcd.Client, key string) { + close(w.created) +} + +func (w *testWatcher) EtcdKeyUpdated(client etcd.Client, key string, value []byte, prevValue []byte) { + w.updated <- updateEvent{ + key: key, + value: string(value), + prev: prevValue, + } +} + +func (w *testWatcher) EtcdKeyDeleted(client etcd.Client, key string, prevValue []byte) { + w.deleted <- deleteEvent{ + key: key, + prev: prevValue, + } +} + +type serverInterface interface { + SetValue(key string, value []byte) + DeleteValue(key string) +} + +type testClientListener struct { + called chan struct{} +} + +func (l *testClientListener) EtcdClientCreated(c etcd.Client) { + close(l.called) +} + +func testServerWatch(t *testing.T, server serverInterface, client etcd.Client) { + require := require.New(t) + assert := assert.New(t) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + cancelCtx, cancel := context.WithCancel(ctx) + defer cancel() + + assert.True(client.IsConfigured(), "should be configured") + require.NoError(client.WaitForConnection(ctx)) + + listener := &testClientListener{ + called: make(chan struct{}), + } + client.AddListener(listener) + defer client.RemoveListener(listener) + + select { + case <-listener.called: + case <-ctx.Done(): + require.NoError(ctx.Err()) + } + + watcher := newTestWatcher() + + go func() { + if _, err := client.Watch(cancelCtx, "foo", 0, watcher); err != nil { + assert.ErrorIs(err, context.Canceled) + } + }() + + select { + case <-watcher.created: + case <-ctx.Done(): + require.NoError(ctx.Err()) + } + + key := "foo" + value := "bar" + server.SetValue("foo", []byte(value)) + select { + case evt := <-watcher.updated: + assert.Equal(key, evt.key) + assert.Equal(value, evt.value) + assert.Empty(evt.prev) + case <-ctx.Done(): + require.NoError(ctx.Err()) + } + + if response, err := client.Get(ctx, "foo"); assert.NoError(err) { + assert.EqualValues(1, response.Count) + if assert.Len(response.Kvs, 1) { + assert.Equal(key, string(response.Kvs[0].Key)) + assert.Equal(value, string(response.Kvs[0].Value)) + } + } + if response, err := client.Get(ctx, "f"); assert.NoError(err) { + assert.EqualValues(0, response.Count) + assert.Empty(response.Kvs) + } + if response, err := client.Get(ctx, "f", clientv3.WithPrefix()); assert.NoError(err) { + assert.EqualValues(1, response.Count) + if assert.Len(response.Kvs, 1) { + assert.Equal(key, string(response.Kvs[0].Key)) + assert.Equal(value, string(response.Kvs[0].Value)) + } + } + + server.DeleteValue("foo") + select { + case evt := <-watcher.deleted: + assert.Equal(key, evt.key) + assert.Equal(value, string(evt.prev)) + case <-ctx.Done(): + require.NoError(ctx.Err()) + } + + select { + case evt := <-watcher.updated: + assert.Fail("unexpected update event", "got %+v", evt) + case evt := <-watcher.deleted: + assert.Fail("unexpected deleted event", "got %+v", evt) + default: + } +} + +func TestServerWatch_Mock(t *testing.T) { + t.Parallel() + + server, client := NewClientForTest(t) + testServerWatch(t, server, client) +} + +func TestServerWatch_Real(t *testing.T) { + t.Parallel() + + server := NewServerForTest(t) + client := NewEtcdClientForTest(t, server) + testServerWatch(t, server, client) +} + +func testServerWatchInitialData(t *testing.T, server serverInterface, client etcd.Client) { + require := require.New(t) + assert := assert.New(t) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + key := "foo" + value := "bar" + server.SetValue("foo", []byte(value)) + + cancelCtx, cancel := context.WithCancel(ctx) + defer cancel() + + watcher := newTestWatcher() + + go func() { + if _, err := client.Watch(cancelCtx, "foo", 1, watcher); err != nil { + assert.ErrorIs(err, context.Canceled) + } + }() + + select { + case <-watcher.created: + case <-ctx.Done(): + require.NoError(ctx.Err()) + } + + select { + case evt := <-watcher.updated: + assert.Equal(key, evt.key) + assert.Equal(value, evt.value) + assert.Empty(evt.prev) + case <-ctx.Done(): + require.NoError(ctx.Err()) + } + + select { + case evt := <-watcher.updated: + assert.Fail("unexpected update event", "got %+v", evt) + case evt := <-watcher.deleted: + assert.Fail("unexpected deleted event", "got %+v", evt) + default: + } +} + +func TestServerWatchInitialData_Mock(t *testing.T) { + t.Parallel() + + server, client := NewClientForTest(t) + testServerWatchInitialData(t, server, client) +} + +func TestServerWatchInitialData_Real(t *testing.T) { + t.Parallel() + + server := NewServerForTest(t) + client := NewEtcdClientForTest(t, server) + testServerWatchInitialData(t, server, client) +} + +func testServerWatchInitialOldData(t *testing.T, server serverInterface, client etcd.Client) { + require := require.New(t) + assert := assert.New(t) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + key := "foo" + value := "bar" + server.SetValue("foo", []byte(value)) + + response, err := client.Get(ctx, key) + require.NoError(err) + + if assert.EqualValues(1, response.Count) && assert.Len(response.Kvs, 1) { + assert.Equal(key, string(response.Kvs[0].Key)) + assert.Equal(value, string(response.Kvs[0].Value)) + } + + cancelCtx, cancel := context.WithCancel(ctx) + defer cancel() + + watcher := newTestWatcher() + + go func() { + if _, err := client.Watch(cancelCtx, "foo", response.Header.GetRevision()+1, watcher); err != nil { + assert.ErrorIs(err, context.Canceled) + } + }() + + select { + case <-watcher.created: + case <-ctx.Done(): + require.NoError(ctx.Err()) + } + + select { + case evt := <-watcher.updated: + assert.Fail("unexpected update event", "got %+v", evt) + case evt := <-watcher.deleted: + assert.Fail("unexpected deleted event", "got %+v", evt) + default: + } +} + +func TestServerWatchInitialOldData_Mock(t *testing.T) { + t.Parallel() + + server, client := NewClientForTest(t) + testServerWatchInitialOldData(t, server, client) +} + +func TestServerWatchInitialOldData_Real(t *testing.T) { + t.Parallel() + + server := NewServerForTest(t) + client := NewEtcdClientForTest(t, server) + testServerWatchInitialOldData(t, server, client) +} diff --git a/flags.go b/flags.go deleted file mode 100644 index 3f67283..0000000 --- a/flags.go +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2023 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "sync/atomic" -) - -type Flags struct { - flags atomic.Uint32 -} - -func (f *Flags) Add(flags uint32) bool { - for { - old := f.flags.Load() - if old&flags == flags { - // Flags already set. - return false - } - newFlags := old | flags - if f.flags.CompareAndSwap(old, newFlags) { - return true - } - // Another thread updated the flags while we were checking, retry. - } -} - -func (f *Flags) Remove(flags uint32) bool { - for { - old := f.flags.Load() - if old&flags == 0 { - // Flags not set. - return false - } - newFlags := old & ^flags - if f.flags.CompareAndSwap(old, newFlags) { - return true - } - // Another thread updated the flags while we were checking, retry. - } -} - -func (f *Flags) Set(flags uint32) bool { - for { - old := f.flags.Load() - if old == flags { - return false - } - - if f.flags.CompareAndSwap(old, flags) { - return true - } - } -} - -func (f *Flags) Get() uint32 { - return f.flags.Load() -} diff --git a/geoip.go b/geoip.go deleted file mode 100644 index ec461e5..0000000 --- a/geoip.go +++ /dev/null @@ -1,339 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2019 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "archive/tar" - "compress/gzip" - "fmt" - "io" - "log" - "net" - "net/http" - "net/url" - "os" - "strings" - "sync" - "time" - - "github.com/dlintw/goconf" - "github.com/oschwald/maxminddb-golang" -) - -var ( - ErrDatabaseNotInitialized = fmt.Errorf("GeoIP database not initialized yet") -) - -func GetGeoIpDownloadUrl(license string) string { - if license == "" { - return "" - } - - result := "https://download.maxmind.com/app/geoip_download" - result += "?edition_id=GeoLite2-Country" - result += "&license_key=" + url.QueryEscape(license) - result += "&suffix=tar.gz" - return result -} - -type GeoLookup struct { - url string - isFile bool - client http.Client - mu sync.Mutex - - lastModifiedHeader string - lastModifiedTime time.Time - - reader *maxminddb.Reader -} - -func NewGeoLookupFromUrl(url string) (*GeoLookup, error) { - geoip := &GeoLookup{ - url: url, - } - return geoip, nil -} - -func NewGeoLookupFromFile(filename string) (*GeoLookup, error) { - geoip := &GeoLookup{ - url: filename, - isFile: true, - } - if err := geoip.Update(); err != nil { - geoip.Close() - return nil, err - } - return geoip, nil -} - -func (g *GeoLookup) Close() { - g.mu.Lock() - if g.reader != nil { - g.reader.Close() - g.reader = nil - } - g.mu.Unlock() -} - -func (g *GeoLookup) Update() error { - if g.isFile { - return g.updateFile() - } - - return g.updateUrl() -} - -func (g *GeoLookup) updateFile() error { - info, err := os.Stat(g.url) - if err != nil { - return err - } - - if info.ModTime().Equal(g.lastModifiedTime) { - return nil - } - - reader, err := maxminddb.Open(g.url) - if err != nil { - return err - } - - if err := reader.Verify(); err != nil { - return err - } - - metadata := reader.Metadata - log.Printf("Using %s GeoIP database from %s (built on %s)", metadata.DatabaseType, g.url, time.Unix(int64(metadata.BuildEpoch), 0).UTC()) - - g.mu.Lock() - if g.reader != nil { - g.reader.Close() - } - g.reader = reader - g.lastModifiedTime = info.ModTime() - g.mu.Unlock() - return nil -} - -func (g *GeoLookup) updateUrl() error { - request, err := http.NewRequest("GET", g.url, nil) - if err != nil { - return err - } - if g.lastModifiedHeader != "" { - request.Header.Add("If-Modified-Since", g.lastModifiedHeader) - } - response, err := g.client.Do(request) - if err != nil { - return err - } - defer response.Body.Close() - - if response.StatusCode == http.StatusNotModified { - log.Printf("GeoIP database at %s has not changed", g.url) - return nil - } else if response.StatusCode/100 != 2 { - return fmt.Errorf("downloading %s returned an error: %s", g.url, response.Status) - } - - body := response.Body - url := g.url - if strings.HasSuffix(url, ".gz") { - body, err = gzip.NewReader(body) - if err != nil { - return err - } - url = strings.TrimSuffix(url, ".gz") - } - - var geoipdata []byte - if strings.HasSuffix(url, ".tar") || strings.HasSuffix(url, "=tar") { - tarfile := tar.NewReader(body) - for { - header, err := tarfile.Next() - if err == io.EOF { - break - } else if err != nil { - return err - } - - if !strings.HasSuffix(header.Name, ".mmdb") { - continue - } - - geoipdata, err = io.ReadAll(tarfile) - if err != nil { - return err - } - break - } - } else { - geoipdata, err = io.ReadAll(body) - if err != nil { - return err - } - } - - if len(geoipdata) == 0 { - return fmt.Errorf("did not find GeoIP database in download from %s", g.url) - } - - reader, err := maxminddb.FromBytes(geoipdata) - if err != nil { - return err - } - - if err := reader.Verify(); err != nil { - return err - } - - metadata := reader.Metadata - log.Printf("Using %s GeoIP database from %s (built on %s)", metadata.DatabaseType, g.url, time.Unix(int64(metadata.BuildEpoch), 0).UTC()) - - g.mu.Lock() - if g.reader != nil { - g.reader.Close() - } - g.reader = reader - g.lastModifiedHeader = response.Header.Get("Last-Modified") - g.mu.Unlock() - return nil -} - -func (g *GeoLookup) LookupCountry(ip net.IP) (string, error) { - var record struct { - Country struct { - ISOCode string `maxminddb:"iso_code"` - } `maxminddb:"country"` - } - - g.mu.Lock() - if g.reader == nil { - g.mu.Unlock() - return "", ErrDatabaseNotInitialized - } - err := g.reader.Lookup(ip, &record) - g.mu.Unlock() - if err != nil { - return "", err - } - - return record.Country.ISOCode, nil -} - -func LookupContinents(country string) []string { - continents, found := ContinentMap[country] - if !found { - return nil - } - - return continents -} - -func IsValidContinent(continent string) bool { - switch continent { - case "AF": - // Africa - fallthrough - case "AN": - // Antartica - fallthrough - case "AS": - // Asia - fallthrough - case "EU": - // Europe - fallthrough - case "NA": - // North America - fallthrough - case "SA": - // South America - fallthrough - case "OC": - // Oceania - return true - default: - return false - } -} - -func LoadGeoIPOverrides(config *goconf.ConfigFile, ignoreErrors bool) (map[*net.IPNet]string, error) { - options, _ := GetStringOptions(config, "geoip-overrides", true) - if len(options) == 0 { - return nil, nil - } - - var err error - geoipOverrides := make(map[*net.IPNet]string, len(options)) - for option, value := range options { - var ip net.IP - var ipNet *net.IPNet - if strings.Contains(option, "/") { - _, ipNet, err = net.ParseCIDR(option) - if err != nil { - if ignoreErrors { - log.Printf("could not parse CIDR %s (%s), skipping", option, err) - continue - } - - return nil, fmt.Errorf("could not parse CIDR %s: %s", option, err) - } - } else { - ip = net.ParseIP(option) - if ip == nil { - if ignoreErrors { - log.Printf("could not parse IP %s, skipping", option) - continue - } - - return nil, fmt.Errorf("could not parse IP %s", option) - } - - var mask net.IPMask - if ipv4 := ip.To4(); ipv4 != nil { - mask = net.CIDRMask(32, 32) - } else { - mask = net.CIDRMask(128, 128) - } - ipNet = &net.IPNet{ - IP: ip, - Mask: mask, - } - } - - value = strings.ToUpper(strings.TrimSpace(value)) - if value == "" { - log.Printf("IP %s doesn't have a country assigned, skipping", option) - continue - } else if !IsValidCountry(value) { - log.Printf("Country %s for IP %s is invalid, skipping", value, option) - continue - } - - log.Printf("Using country %s for %s", value, ipNet) - geoipOverrides[ipNet] = value - } - - return geoipOverrides, nil -} diff --git a/continentmap.go b/geoip/continentmap.go similarity index 98% rename from continentmap.go rename to geoip/continentmap.go index a99b944..75673bd 100644 --- a/continentmap.go +++ b/geoip/continentmap.go @@ -1,10 +1,10 @@ -package signaling +package geoip // This file has been automatically generated, do not modify. // Source: https://raw.githubusercontent.com/datasets/country-codes/refs/heads/main/data/country-codes.csv var ( - ContinentMap = map[string][]string{ + ContinentMap = map[Country][]Continent{ "AD": {"EU"}, "AE": {"AS"}, "AF": {"AS"}, diff --git a/geoip/geoip.go b/geoip/geoip.go new file mode 100644 index 0000000..a020a38 --- /dev/null +++ b/geoip/geoip.go @@ -0,0 +1,98 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package geoip + +import "strings" + +type ( + Country string + Continent string +) + +var ( + NoCountry = Country("no-country") + noCountryUpper = Country(strings.ToUpper("no-country")) + + Loopback = Country("loopback") + loopbackUpper = Country(strings.ToUpper("loopback")) + + UnknownCountry = Country("unknown-country") + unknownCountryUpper = Country(strings.ToUpper("unknown-country")) +) + +func IsValidCountry(country Country) bool { + switch country { + case "": + fallthrough + case NoCountry: + fallthrough + case noCountryUpper: + fallthrough + case Loopback: + fallthrough + case loopbackUpper: + fallthrough + case UnknownCountry: + fallthrough + case unknownCountryUpper: + return false + default: + return true + } +} + +func LookupContinents(country Country) []Continent { + continents, found := ContinentMap[country] + if !found { + return nil + } + + return continents +} + +func IsValidContinent(continent Continent) bool { + switch continent { + case "AF": + // Africa + fallthrough + case "AN": + // Antartica + fallthrough + case "AS": + // Asia + fallthrough + case "EU": + // Europe + fallthrough + case "NA": + // North America + fallthrough + case "SA": + // South America + fallthrough + case "OC": + // Oceania + return true + default: + return false + } +} diff --git a/geoip/maxmind.go b/geoip/maxmind.go new file mode 100644 index 0000000..52fdd02 --- /dev/null +++ b/geoip/maxmind.go @@ -0,0 +1,236 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package geoip + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync/atomic" + "time" + + "github.com/oschwald/maxminddb-golang" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" +) + +var ( + ErrDatabaseNotInitialized = errors.New("GeoIP database not initialized yet") +) + +func GetMaxMindDownloadUrl(license string) string { + if license == "" { + return "" + } + + result := "https://download.maxmind.com/app/geoip_download" + result += "?edition_id=GeoLite2-Country" + result += "&license_key=" + url.QueryEscape(license) + result += "&suffix=tar.gz" + return result +} + +type Lookup struct { + logger log.Logger + url string + isFile bool + client http.Client + + lastModifiedHeader atomic.Value + lastModifiedTime atomic.Int64 + + reader atomic.Pointer[maxminddb.Reader] +} + +func NewLookupFromUrl(logger log.Logger, url string) (*Lookup, error) { + geoip := &Lookup{ + logger: logger, + url: url, + } + return geoip, nil +} + +func NewLookupFromFile(logger log.Logger, filename string) (*Lookup, error) { + geoip := &Lookup{ + logger: logger, + url: filename, + isFile: true, + } + if err := geoip.Update(); err != nil { + geoip.Close() + return nil, err + } + return geoip, nil +} + +func (g *Lookup) Close() { + if reader := g.reader.Swap(nil); reader != nil { + reader.Close() + } +} + +func (g *Lookup) Update() error { + if g.isFile { + return g.updateFile() + } + + return g.updateUrl() +} + +func (g *Lookup) updateFile() error { + info, err := os.Stat(g.url) + if err != nil { + return err + } + + if info.ModTime().UnixNano() == g.lastModifiedTime.Load() { + return nil + } + + reader, err := maxminddb.Open(g.url) + if err != nil { + return err + } + + if err := reader.Verify(); err != nil { + return err + } + + metadata := reader.Metadata + g.logger.Printf("Using %s GeoIP database from %s (built on %s)", metadata.DatabaseType, g.url, time.Unix(int64(metadata.BuildEpoch), 0).UTC()) + + if old := g.reader.Swap(reader); old != nil { + old.Close() + } + + g.lastModifiedTime.Store(info.ModTime().UnixNano()) + return nil +} + +func (g *Lookup) updateUrl() error { + request, err := http.NewRequest("GET", g.url, nil) + if err != nil { + return err + } + if header := g.lastModifiedHeader.Load(); header != nil { + request.Header.Add("If-Modified-Since", header.(string)) + } + response, err := g.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode == http.StatusNotModified { + g.logger.Printf("GeoIP database at %s has not changed", g.url) + return nil + } else if response.StatusCode/100 != 2 { + return fmt.Errorf("downloading %s returned an error: %s", g.url, response.Status) + } + + body := response.Body + url := g.url + if strings.HasSuffix(url, ".gz") { + body, err = gzip.NewReader(body) + if err != nil { + return err + } + url = strings.TrimSuffix(url, ".gz") + } + + var geoipdata []byte + if strings.HasSuffix(url, ".tar") || strings.HasSuffix(url, "=tar") { + tarfile := tar.NewReader(body) + for { + header, err := tarfile.Next() + if err == io.EOF { + break + } else if err != nil { + return err + } + + if !strings.HasSuffix(header.Name, ".mmdb") { + continue + } + + geoipdata, err = io.ReadAll(tarfile) + if err != nil { + return err + } + break + } + } else { + geoipdata, err = io.ReadAll(body) + if err != nil { + return err + } + } + + if len(geoipdata) == 0 { + return fmt.Errorf("did not find GeoIP database in download from %s", g.url) + } + + reader, err := maxminddb.FromBytes(geoipdata) + if err != nil { + return err + } + + if err := reader.Verify(); err != nil { + return err + } + + metadata := reader.Metadata + g.logger.Printf("Using %s GeoIP database from %s (built on %s)", metadata.DatabaseType, g.url, time.Unix(int64(metadata.BuildEpoch), 0).UTC()) + + if old := g.reader.Swap(reader); old != nil { + old.Close() + } + + g.lastModifiedHeader.Store(response.Header.Get("Last-Modified")) + return nil +} + +func (g *Lookup) LookupCountry(ip net.IP) (Country, error) { + var record struct { + Country struct { + ISOCode string `maxminddb:"iso_code"` + } `maxminddb:"country"` + } + + reader := g.reader.Load() + if reader == nil { + return "", ErrDatabaseNotInitialized + } + + if err := reader.Lookup(ip, &record); err != nil { + return "", err + } + + return Country(record.Country.ISOCode), nil +} diff --git a/geoip_test.go b/geoip/maxmind_test.go similarity index 75% rename from geoip_test.go rename to geoip/maxmind_test.go index 4d1a1e1..ba6b139 100644 --- a/geoip_test.go +++ b/geoip/maxmind_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package geoip import ( "archive/tar" @@ -35,10 +35,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" ) -func testGeoLookupReader(t *testing.T, reader *GeoLookup) { - tests := map[string]string{ +func testLookupReader(t *testing.T, reader *Lookup) { + tests := map[string]Country{ // Example from maxminddb-golang code. "81.2.69.142": "GB", // Local addresses don't have a country assigned. @@ -46,8 +48,6 @@ func testGeoLookupReader(t *testing.T, reader *GeoLookup) { } for ip, expected := range tests { - ip := ip - expected := expected t.Run(ip, func(t *testing.T) { country, err := reader.LookupCountry(net.ParseIP(ip)) if !assert.NoError(t, err, "Could not lookup %s", ip) { @@ -59,7 +59,7 @@ func testGeoLookupReader(t *testing.T, reader *GeoLookup) { } } -func GetGeoIpUrlForTest(t *testing.T) string { +func GetIpUrlForTest(t *testing.T) string { t.Helper() var geoIpUrl string @@ -72,27 +72,29 @@ func GetGeoIpUrlForTest(t *testing.T) string { if license == "" { t.Skip("No MaxMind GeoLite2 license was set in MAXMIND_GEOLITE2_LICENSE environment variable.") } - geoIpUrl = GetGeoIpDownloadUrl(license) + geoIpUrl = GetMaxMindDownloadUrl(license) } return geoIpUrl } -func TestGeoLookup(t *testing.T) { - CatchLogForTest(t) +func TestLookup(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) require := require.New(t) - reader, err := NewGeoLookupFromUrl(GetGeoIpUrlForTest(t)) + reader, err := NewLookupFromUrl(logger, GetIpUrlForTest(t)) require.NoError(err) defer reader.Close() require.NoError(reader.Update()) - testGeoLookupReader(t, reader) + testLookupReader(t, reader) } -func TestGeoLookupCaching(t *testing.T) { - CatchLogForTest(t) +func TestLookupCaching(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) require := require.New(t) - reader, err := NewGeoLookupFromUrl(GetGeoIpUrlForTest(t)) + reader, err := NewLookupFromUrl(logger, GetIpUrlForTest(t)) require.NoError(err) defer reader.Close() @@ -103,21 +105,21 @@ func TestGeoLookupCaching(t *testing.T) { require.NoError(reader.Update()) } -func TestGeoLookupContinent(t *testing.T) { - tests := map[string][]string{ - "AU": {"OC"}, - "DE": {"EU"}, - "RU": {"EU"}, - "": nil, - "INVALID ": nil, +func TestLookupContinent(t *testing.T) { + t.Parallel() + tests := map[Country][]Continent{ + "AU": {"OC"}, + "DE": {"EU"}, + "RU": {"EU"}, + "": nil, + "INVALID": nil, } for country, expected := range tests { - country := country - expected := expected - t.Run(country, func(t *testing.T) { + t.Run(string(country), func(t *testing.T) { + t.Parallel() continents := LookupContinents(country) - if !assert.Equal(t, len(expected), len(continents), "Continents didn't match for %s: got %s, expected %s", country, continents, expected) { + if !assert.Len(t, continents, len(expected), "Continents didn't match for %s: got %s, expected %s", country, continents, expected) { return } for idx, c := range expected { @@ -129,17 +131,19 @@ func TestGeoLookupContinent(t *testing.T) { } } -func TestGeoLookupCloseEmpty(t *testing.T) { - CatchLogForTest(t) - reader, err := NewGeoLookupFromUrl("ignore-url") +func TestLookupCloseEmpty(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + reader, err := NewLookupFromUrl(logger, "ignore-url") require.NoError(t, err) reader.Close() } -func TestGeoLookupFromFile(t *testing.T) { - CatchLogForTest(t) +func TestLookupFromFile(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) require := require.New(t) - geoIpUrl := GetGeoIpUrlForTest(t) + geoIpUrl := GetIpUrlForTest(t) resp, err := http.Get(geoIpUrl) require.NoError(err) @@ -192,14 +196,15 @@ func TestGeoLookupFromFile(t *testing.T) { require.True(foundDatabase, "Did not find GeoIP database in download from %s", geoIpUrl) - reader, err := NewGeoLookupFromFile(tmpfile.Name()) + reader, err := NewLookupFromFile(logger, tmpfile.Name()) require.NoError(err) defer reader.Close() - testGeoLookupReader(t, reader) + testLookupReader(t, reader) } func TestIsValidContinent(t *testing.T) { + t.Parallel() for country, continents := range ContinentMap { for _, continent := range continents { assert.True(t, IsValidContinent(continent), "Continent %s of country %s is not valid", continent, country) diff --git a/geoip/overrides.go b/geoip/overrides.go new file mode 100644 index 0000000..fd522f2 --- /dev/null +++ b/geoip/overrides.go @@ -0,0 +1,130 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package geoip + +import ( + "context" + "fmt" + "maps" + "net" + "strings" + "sync/atomic" + + "github.com/dlintw/goconf" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" +) + +type Overrides map[*net.IPNet]Country + +func (o Overrides) Lookup(ip net.IP) (Country, bool) { + for overrideNet, country := range o { + if overrideNet.Contains(ip) { + return country, true + } + } + + return UnknownCountry, false +} + +type AtomicOverrides struct { + value atomic.Pointer[Overrides] +} + +func (a *AtomicOverrides) Store(value Overrides) { + if len(value) == 0 { + a.value.Store(nil) + } else { + v := maps.Clone(value) + a.value.Store(&v) + } +} + +func (a *AtomicOverrides) Load() Overrides { + value := a.value.Load() + if value == nil { + return nil + } + + return *value +} + +func LoadOverrides(ctx context.Context, cfg *goconf.ConfigFile, ignoreErrors bool) (Overrides, error) { + logger := log.LoggerFromContext(ctx) + options, _ := config.GetStringOptions(cfg, "geoip-overrides", true) + if len(options) == 0 { + return nil, nil + } + + var err error + geoipOverrides := make(Overrides, len(options)) + for option, value := range options { + var ip net.IP + var ipNet *net.IPNet + if strings.Contains(option, "/") { + _, ipNet, err = net.ParseCIDR(option) + if err != nil { + if ignoreErrors { + logger.Printf("could not parse CIDR %s (%s), skipping", option, err) + continue + } + + return nil, fmt.Errorf("could not parse CIDR %s: %w", option, err) + } + } else { + ip = net.ParseIP(option) + if ip == nil { + if ignoreErrors { + logger.Printf("could not parse IP %s, skipping", option) + continue + } + + return nil, fmt.Errorf("could not parse IP %s", option) + } + + var mask net.IPMask + if ipv4 := ip.To4(); ipv4 != nil { + mask = net.CIDRMask(32, 32) + } else { + mask = net.CIDRMask(128, 128) + } + ipNet = &net.IPNet{ + IP: ip, + Mask: mask, + } + } + + value = strings.ToUpper(strings.TrimSpace(value)) + if value == "" { + logger.Printf("IP %s doesn't have a country assigned, skipping", option) + continue + } else if !IsValidCountry(Country(value)) { + logger.Printf("Country %s for IP %s is invalid, skipping", value, option) + continue + } + + logger.Printf("Using country %s for %s", value, ipNet) + geoipOverrides[ipNet] = Country(value) + } + + return geoipOverrides, nil +} diff --git a/geoip/overrides_test.go b/geoip/overrides_test.go new file mode 100644 index 0000000..211eef4 --- /dev/null +++ b/geoip/overrides_test.go @@ -0,0 +1,183 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package geoip + +import ( + "maps" + "net" + "testing" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" +) + +func mustSucceed1[T any, A1 any](t *testing.T, f func(a1 A1) (T, bool), a1 A1) T { + t.Helper() + result, ok := f(a1) + if !ok { + t.FailNow() + } + return result +} + +func TestOverridesEmpty(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + config := goconf.NewConfigFile() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + overrides, err := LoadOverrides(ctx, config, true) + require.NoError(err) + assert.Empty(overrides) +} + +func TestOverrides(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + config := goconf.NewConfigFile() + config.AddOption("geoip-overrides", "10.1.0.0/16", "DE") + config.AddOption("geoip-overrides", "2001:db8::/48", "FR") + config.AddOption("geoip-overrides", "2001:db9::3", "CH") + config.AddOption("geoip-overrides", "10.3.4.5", "custom") + config.AddOption("geoip-overrides", "10.4.5.6", "loopback") + config.AddOption("geoip-overrides", "192.168.1.0", "") + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + overrides, err := LoadOverrides(ctx, config, true) + require.NoError(err) + + if assert.Len(overrides, 4) { + assert.EqualValues("DE", mustSucceed1(t, overrides.Lookup, net.ParseIP("10.1.2.3"))) + assert.EqualValues("DE", mustSucceed1(t, overrides.Lookup, net.ParseIP("10.1.3.4"))) + assert.EqualValues("FR", mustSucceed1(t, overrides.Lookup, net.ParseIP("2001:db8::1"))) + assert.EqualValues("FR", mustSucceed1(t, overrides.Lookup, net.ParseIP("2001:db8::2"))) + assert.EqualValues("CH", mustSucceed1(t, overrides.Lookup, net.ParseIP("2001:db9::3"))) + assert.EqualValues("CUSTOM", mustSucceed1(t, overrides.Lookup, net.ParseIP("10.3.4.5"))) + + country, ok := overrides.Lookup(net.ParseIP("10.4.5.6")) + assert.False(ok, "expected no country, got %s", country) + + country, ok = overrides.Lookup(net.ParseIP("192.168.1.0")) + assert.False(ok, "expected no country, got %s", country) + } +} + +func TestOverridesInvalidIgnoreErrors(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + config := goconf.NewConfigFile() + config.AddOption("geoip-overrides", "invalid-ip", "DE") + config.AddOption("geoip-overrides", "300.1.2.3/8", "DE") + config.AddOption("geoip-overrides", "10.2.0.0/16", "FR") + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + overrides, err := LoadOverrides(ctx, config, true) + require.NoError(err) + + if assert.Len(overrides, 1) { + assert.EqualValues("FR", mustSucceed1(t, overrides.Lookup, net.ParseIP("10.2.3.4"))) + assert.EqualValues("FR", mustSucceed1(t, overrides.Lookup, net.ParseIP("10.2.4.5"))) + + country, ok := overrides.Lookup(net.ParseIP("10.3.4.5")) + assert.False(ok, "expected no country, got %s", country) + + country, ok = overrides.Lookup(net.ParseIP("192.168.1.0")) + assert.False(ok, "expected no country, got %s", country) + } +} + +func TestOverridesInvalidIPReturnErrors(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + config := goconf.NewConfigFile() + config.AddOption("geoip-overrides", "invalid-ip", "DE") + config.AddOption("geoip-overrides", "10.2.0.0/16", "FR") + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + overrides, err := LoadOverrides(ctx, config, false) + assert.ErrorContains(err, "could not parse IP", err) + assert.Empty(overrides) +} + +func TestOverridesInvalidCIDRReturnErrors(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + config := goconf.NewConfigFile() + config.AddOption("geoip-overrides", "300.1.2.3/8", "DE") + config.AddOption("geoip-overrides", "10.2.0.0/16", "FR") + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + overrides, err := LoadOverrides(ctx, config, false) + var e *net.ParseError + assert.ErrorAs(err, &e) + assert.Empty(overrides) +} + +func TestAtomicOverrides(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + overrides := make(Overrides) + overrides[&net.IPNet{ + IP: net.ParseIP("10.1.2.3."), + Mask: net.CIDRMask(32, 32), + }] = "DE" + + var value AtomicOverrides + assert.Nil(value.Load()) + value.Store(make(Overrides)) + assert.Nil(value.Load()) + value.Store(overrides) + if o := value.Load(); assert.NotEmpty(o) { + assert.Equal(overrides, o) + } + // Updating the overrides doesn't change the stored value. + overrides2 := maps.Clone(overrides) + overrides[&net.IPNet{ + IP: net.ParseIP("10.1.2.3."), + Mask: net.CIDRMask(32, 32), + }] = "FR" + if o := value.Load(); assert.NotEmpty(o) { + assert.Equal(overrides2, o) + } +} diff --git a/go.mod b/go.mod index 691479f..a211d9a 100644 --- a/go.mod +++ b/go.mod @@ -1,97 +1,102 @@ -module github.com/strukturag/nextcloud-spreed-signaling +module github.com/strukturag/nextcloud-spreed-signaling/v2 -go 1.22.0 +go 1.25.0 require ( github.com/dlintw/goconf v0.0.0-20120228082610-dcc070983490 - github.com/fsnotify/fsnotify v1.8.0 - github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/fsnotify/fsnotify v1.9.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 - github.com/gorilla/securecookie v1.1.2 github.com/gorilla/websocket v1.5.3 - github.com/mailru/easyjson v0.9.0 - github.com/marcw/cachecontrol v0.0.0-20140722115028-30341fe9a7d5 - github.com/nats-io/nats-server/v2 v2.10.24 - github.com/nats-io/nats.go v1.38.0 + github.com/mailru/easyjson v0.9.1 + github.com/nats-io/nats-server/v2 v2.12.5 + github.com/nats-io/nats.go v1.49.0 github.com/notedit/janus-go v0.0.0-20200517101215-10eb8b95d1a0 github.com/oschwald/maxminddb-golang v1.13.1 - github.com/pion/sdp/v3 v3.0.10 - github.com/prometheus/client_golang v1.20.5 - github.com/stretchr/testify v1.10.0 - go.etcd.io/etcd/api/v3 v3.5.17 - go.etcd.io/etcd/client/pkg/v3 v3.5.17 - go.etcd.io/etcd/client/v3 v3.5.17 - go.etcd.io/etcd/server/v3 v3.5.17 - go.uber.org/zap v1.27.0 - google.golang.org/grpc v1.69.4 - google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 - google.golang.org/protobuf v1.36.3 + github.com/pion/ice/v4 v4.2.1 + github.com/pion/sdp/v3 v3.0.18 + github.com/pquerna/cachecontrol v0.2.0 + github.com/prometheus/client_golang v1.23.2 + github.com/stretchr/testify v1.11.1 + go.etcd.io/etcd/api/v3 v3.6.8 + go.etcd.io/etcd/client/pkg/v3 v3.6.8 + go.etcd.io/etcd/client/v3 v3.6.8 + go.etcd.io/etcd/server/v3 v3.6.8 + go.uber.org/zap v1.27.1 + google.golang.org/grpc v1.79.2 + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 + google.golang.org/protobuf v1.36.11 ) require ( + github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/coreos/go-semver v0.3.0 // indirect - github.com/coreos/go-systemd/v22 v22.3.2 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dustin/go-humanize v1.0.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/btree v1.0.1 // indirect - github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect - github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect - github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect - github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/kylelemons/godebug v1.1.0 // indirect - github.com/minio/highwayhash v1.0.3 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nats-io/jwt/v2 v2.7.3 // indirect - github.com/nats-io/nkeys v0.4.9 // indirect + github.com/nats-io/jwt/v2 v2.8.0 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.1.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/soheilhy/cmux v0.1.5 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect + github.com/wlynxg/anet v0.0.5 // indirect github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect - go.etcd.io/bbolt v1.3.11 // indirect - go.etcd.io/etcd/client/v2 v2.305.17 // indirect - go.etcd.io/etcd/pkg/v3 v3.5.17 // indirect - go.etcd.io/etcd/raft/v3 v3.5.17 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.0 // indirect - go.opentelemetry.io/otel v1.31.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.20.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.20.0 // indirect - go.opentelemetry.io/otel/metric v1.31.0 // indirect - go.opentelemetry.io/otel/sdk v1.31.0 // indirect - go.opentelemetry.io/otel/trace v1.31.0 // indirect - go.opentelemetry.io/proto/otlp v1.0.0 // indirect - go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect - golang.org/x/time v0.8.0 // indirect - google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + go.etcd.io/bbolt v1.4.3 // indirect + go.etcd.io/etcd/pkg/v3 v3.6.8 // indirect + go.etcd.io/raft/v3 v3.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - sigs.k8s.io/yaml v1.2.0 // indirect + sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 24b51e2..4d9b63e 100644 --- a/go.sum +++ b/go.sum @@ -1,281 +1,219 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM= -cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= -cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= -cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI= -github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= -github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlintw/goconf v0.0.0-20120228082610-dcc070983490 h1:I8/Qu5NTaiXi1TsEYmTeLDUlf7u9pEdbG+azjDvx8Vg= github.com/dlintw/goconf v0.0.0-20120228082610-dcc070983490/go.mod h1:jWlUIP63OLr0cV2FGN2IEzSFsMAe58if8rk/SAE0JRE= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM= -github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY= -github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= -github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= -github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= -github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= -github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/marcw/cachecontrol v0.0.0-20140722115028-30341fe9a7d5 h1:Wnc+HxXmAhN6xRzhmPJTiip9/sVZzwa6XlWksxjObCA= -github.com/marcw/cachecontrol v0.0.0-20140722115028-30341fe9a7d5/go.mod h1:e4ZZwiqLDqvzKu9TVxuGnh2kXCWeU6PxLG2hw/+no7g= -github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q= -github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nats-io/jwt/v2 v2.7.3 h1:6bNPK+FXgBeAqdj4cYQ0F8ViHRbi7woQLq4W29nUAzE= -github.com/nats-io/jwt/v2 v2.7.3/go.mod h1:GvkcbHhKquj3pkioy5put1wvPxs78UlZ7D/pY+BgZk4= -github.com/nats-io/nats-server/v2 v2.10.24 h1:KcqqQAD0ZZcG4yLxtvSFJY7CYKVYlnlWoAiVZ6i/IY4= -github.com/nats-io/nats-server/v2 v2.10.24/go.mod h1:olvKt8E5ZlnjyqBGbAXtxvSQKsPodISK5Eo/euIta4s= -github.com/nats-io/nats.go v1.38.0 h1:A7P+g7Wjp4/NWqDOOP/K6hfhr54DvdDQUznt5JFg9XA= -github.com/nats-io/nats.go v1.38.0/go.mod h1:IGUM++TwokGnXPs82/wCuiHS02/aKrdYUQkU8If6yjw= -github.com/nats-io/nkeys v0.4.9 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0= -github.com/nats-io/nkeys v0.4.9/go.mod h1:jcMqs+FLG+W5YO36OX6wFIFcmpdAns+w1Wm6D3I/evE= +github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g= +github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA= +github.com/nats-io/nats-server/v2 v2.12.5 h1:EOHLbsLJgUHUwzkj9gBTOlubkX+dmSs0EYWMdBiHivU= +github.com/nats-io/nats-server/v2 v2.12.5/go.mod h1:JQDAKcwdXs0NRhvYO31dzsXkzCyDkOBS7SKU3Nozu14= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/notedit/janus-go v0.0.0-20200517101215-10eb8b95d1a0 h1:EFU9iv8BMPyBo8iFMHvQleYlF5M3PY6zpAbxsngImjE= github.com/notedit/janus-go v0.0.0-20200517101215-10eb8b95d1a0/go.mod h1:BN/Txse3qz8tZOmCm2OfajB2wHVujWmX3o9nVdsI6gE= -github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY= +github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/sdp/v3 v3.0.10 h1:6MChLE/1xYB+CjumMw+gZ9ufp2DPApuVSnDT8t5MIgA= -github.com/pion/sdp/v3 v3.0.10/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= +github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= +github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k= +github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 h1:uruHq4dN7GR16kFc5fp3d1RIYzJW5onx8Ybykw2YQFA= github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= -go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= -go.etcd.io/etcd/api/v3 v3.5.17 h1:cQB8eb8bxwuxOilBpMJAEo8fAONyrdXTHUNcMd8yT1w= -go.etcd.io/etcd/api/v3 v3.5.17/go.mod h1:d1hvkRuXkts6PmaYk2Vrgqbv7H4ADfAKhyJqHNLJCB4= -go.etcd.io/etcd/client/pkg/v3 v3.5.17 h1:XxnDXAWq2pnxqx76ljWwiQ9jylbpC4rvkAeRVOUKKVw= -go.etcd.io/etcd/client/pkg/v3 v3.5.17/go.mod h1:4DqK1TKacp/86nJk4FLQqo6Mn2vvQFBmruW3pP14H/w= -go.etcd.io/etcd/client/v2 v2.305.17 h1:ajFukQfI//xY5VuSeuUw4TJ4WnNR2kAFfV/P0pDdPMs= -go.etcd.io/etcd/client/v2 v2.305.17/go.mod h1:EttKgEgvwikmXN+b7pkEWxDZr6sEaYsqCiS3k4fa/Vg= -go.etcd.io/etcd/client/v3 v3.5.17 h1:o48sINNeWz5+pjy/Z0+HKpj/xSnBkuVhVvXkjEXbqZY= -go.etcd.io/etcd/client/v3 v3.5.17/go.mod h1:j2d4eXTHWkT2ClBgnnEPm/Wuu7jsqku41v9DZ3OtjQo= -go.etcd.io/etcd/pkg/v3 v3.5.17 h1:1k2wZ+oDp41jrk3F9o15o8o7K3/qliBo0mXqxo1PKaE= -go.etcd.io/etcd/pkg/v3 v3.5.17/go.mod h1:FrztuSuaJG0c7RXCOzT08w+PCugh2kCQXmruNYCpCGA= -go.etcd.io/etcd/raft/v3 v3.5.17 h1:wHPW/b1oFBw/+HjDAQ9vfr17OIInejTIsmwMZpK1dNo= -go.etcd.io/etcd/raft/v3 v3.5.17/go.mod h1:uapEfOMPaJ45CqBYIraLO5+fqyIY2d57nFfxzFwy4D4= -go.etcd.io/etcd/server/v3 v3.5.17 h1:xykBwLZk9IdDsB8z8rMdCCPRvhrG+fwvARaGA0TRiyc= -go.etcd.io/etcd/server/v3 v3.5.17/go.mod h1:40sqgtGt6ZJNKm8nk8x6LexZakPu+NDl/DCgZTZ69Cc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.0 h1:PzIubN4/sjByhDRHLviCjJuweBXWFZWhghjg7cS28+M= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.0/go.mod h1:Ct6zzQEuGK3WpJs2n4dn+wfJYzd/+hNnxMRTWjGn30M= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.20.0 h1:DeFD0VgTZ+Cj6hxravYYZE2W4GlneVH81iAOPjZkzk8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.20.0/go.mod h1:GijYcYmNpX1KazD5JmWGsi4P7dDTTTnfv1UbGn84MnU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.20.0 h1:gvmNvqrPYovvyRmCSygkUDyL8lC5Tl845MLEwqpxhEU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.20.0/go.mod h1:vNUq47TGFioo+ffTSnKNdob241vePmtNZnAODKapKd0= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= -go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= -go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= +go.etcd.io/etcd/api/v3 v3.6.8/go.mod h1:qyQj1HZPUV3B5cbAL8scG62+fyz5dSxxu0w8pn28N6Q= +go.etcd.io/etcd/client/pkg/v3 v3.6.8 h1:Qs/5C0LNFiqXxYf2GU8MVjYUEXJ6sZaYOz0zEqQgy50= +go.etcd.io/etcd/client/pkg/v3 v3.6.8/go.mod h1:GsiTRUZE2318PggZkAo6sWb6l8JLVrnckTNfbG8PWtw= +go.etcd.io/etcd/client/v3 v3.6.8 h1:B3G76t1UykqAOrbio7s/EPatixQDkQBevN8/mwiplrY= +go.etcd.io/etcd/client/v3 v3.6.8/go.mod h1:MVG4BpSIuumPi+ELF7wYtySETmoTWBHVcDoHdVupwt8= +go.etcd.io/etcd/pkg/v3 v3.6.8 h1:Xe+LIL974spy8b4nEx3H0KMr1ofq3r0kh6FbU3aw4es= +go.etcd.io/etcd/pkg/v3 v3.6.8/go.mod h1:TRibVNe+FqJIe1abOAA1PsuQ4wqO87ZaOoprg09Tn8c= +go.etcd.io/etcd/server/v3 v3.6.8 h1:U2strdSEy1U8qcSzRIdkYpvOPtBy/9i/IfaaCI9flZ4= +go.etcd.io/etcd/server/v3 v3.6.8/go.mod h1:88dCtwUnSirkUoJbflQxxWXqtBSZa6lSG0Kuej+dois= +go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= @@ -283,44 +221,27 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= -google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= -google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 h1:fVoAXEKA4+yufmbdVYv+SE73+cPZbbbe8paLsHfkK+U= -google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53/go.mod h1:riSXTwQ4+nqmPGtobMFyW5FqVAmIs0St6VPp4Ug7CE4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= -google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA= -google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= -google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 h1:/WILD1UcXj/ujCxgoL/DvRgt2CP3txG8+FwkUbb9110= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= -sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 h1:fD1pz4yfdADVNfFmcP2aBEtudwUQ1AlLnRBALr33v3s= +sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6/go.mod h1:p4QtZmO4uMYipTQNzagwnNoseA6OxSUutVw05NhYDRs= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/api_grpc.go b/grpc/api.go similarity index 87% rename from api_grpc.go rename to grpc/api.go index 127e880..e29df35 100644 --- a/api_grpc.go +++ b/grpc/api.go @@ -19,21 +19,21 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package grpc import ( - "fmt" + "errors" ) // Information on a GRPC target in the etcd cluster. -type GrpcTargetInformationEtcd struct { +type TargetInformationEtcd struct { Address string `json:"address"` } -func (p *GrpcTargetInformationEtcd) CheckValid() error { +func (p *TargetInformationEtcd) CheckValid() error { if l := len(p.Address); l == 0 { - return fmt.Errorf("address missing") + return errors.New("address missing") } else if p.Address[l-1] == '/' { p.Address = p.Address[:l-1] } diff --git a/api_grpc_easyjson.go b/grpc/api_easyjson.go similarity index 56% rename from api_grpc_easyjson.go rename to grpc/api_easyjson.go index d1678a4..f61268c 100644 --- a/api_grpc_easyjson.go +++ b/grpc/api_easyjson.go @@ -1,6 +1,6 @@ // Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. -package signaling +package grpc import ( json "encoding/json" @@ -17,7 +17,7 @@ var ( _ easyjson.Marshaler ) -func easyjson5dc3c167DecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexer.Lexer, out *GrpcTargetInformationEtcd) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Grpc(in *jlexer.Lexer, out *TargetInformationEtcd) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -30,14 +30,13 @@ func easyjson5dc3c167DecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "address": - out.Address = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Address = string(in.String()) + } default: in.SkipRecursive() } @@ -48,7 +47,7 @@ func easyjson5dc3c167DecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe in.Consumed() } } -func easyjson5dc3c167EncodeGithubComStrukturagNextcloudSpreedSignaling(out *jwriter.Writer, in GrpcTargetInformationEtcd) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Grpc(out *jwriter.Writer, in TargetInformationEtcd) { out.RawByte('{') first := true _ = first @@ -61,25 +60,25 @@ func easyjson5dc3c167EncodeGithubComStrukturagNextcloudSpreedSignaling(out *jwri } // MarshalJSON supports json.Marshaler interface -func (v GrpcTargetInformationEtcd) MarshalJSON() ([]byte, error) { +func (v TargetInformationEtcd) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson5dc3c167EncodeGithubComStrukturagNextcloudSpreedSignaling(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Grpc(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v GrpcTargetInformationEtcd) MarshalEasyJSON(w *jwriter.Writer) { - easyjson5dc3c167EncodeGithubComStrukturagNextcloudSpreedSignaling(w, v) +func (v TargetInformationEtcd) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Grpc(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *GrpcTargetInformationEtcd) UnmarshalJSON(data []byte) error { +func (v *TargetInformationEtcd) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson5dc3c167DecodeGithubComStrukturagNextcloudSpreedSignaling(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Grpc(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *GrpcTargetInformationEtcd) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson5dc3c167DecodeGithubComStrukturagNextcloudSpreedSignaling(l, v) +func (v *TargetInformationEtcd) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Grpc(l, v) } diff --git a/grpc_backend.pb.go b/grpc/backend.pb.go similarity index 68% rename from grpc_backend.pb.go rename to grpc/backend.pb.go index 03c7762..d977af1 100644 --- a/grpc_backend.pb.go +++ b/grpc/backend.pb.go @@ -20,15 +20,16 @@ // along with this program. If not, see . // Code generated by protoc-gen-go. DO NOT EDIT. -// source: grpc_backend.proto +// source: grpc/backend.proto -package signaling +package grpc import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" + unsafe "unsafe" ) const ( @@ -128,48 +129,37 @@ func (x *GetSessionCountReply) GetCount() uint32 { var File_grpc_backend_proto protoreflect.FileDescriptor -var file_grpc_backend_proto_rawDesc = []byte{ - 0x0a, 0x12, 0x67, 0x72, 0x70, 0x63, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x22, - 0x2a, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, - 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x2c, 0x0a, 0x14, 0x47, - 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, - 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0d, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x32, 0x65, 0x0a, 0x0a, 0x52, 0x70, 0x63, - 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x12, 0x57, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x21, 0x2e, 0x73, 0x69, 0x67, - 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, - 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, - 0x42, 0x3c, 0x5a, 0x3a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x73, - 0x74, 0x72, 0x75, 0x6b, 0x74, 0x75, 0x72, 0x61, 0x67, 0x2f, 0x6e, 0x65, 0x78, 0x74, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x70, 0x72, 0x65, 0x65, 0x64, 0x2d, 0x73, 0x69, 0x67, 0x6e, 0x61, - 0x6c, 0x69, 0x6e, 0x67, 0x3b, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} +const file_grpc_backend_proto_rawDesc = "" + + "\n" + + "\x12grpc/backend.proto\x12\x04grpc\"*\n" + + "\x16GetSessionCountRequest\x12\x10\n" + + "\x03url\x18\x01 \x01(\tR\x03url\",\n" + + "\x14GetSessionCountReply\x12\x14\n" + + "\x05count\x18\x01 \x01(\rR\x05count2[\n" + + "\n" + + "RpcBackend\x12M\n" + + "\x0fGetSessionCount\x12\x1c.grpc.GetSessionCountRequest\x1a\x1a.grpc.GetSessionCountReply\"\x00B7Z5github.com/strukturag/nextcloud-spreed-signaling/grpcb\x06proto3" var ( file_grpc_backend_proto_rawDescOnce sync.Once - file_grpc_backend_proto_rawDescData = file_grpc_backend_proto_rawDesc + file_grpc_backend_proto_rawDescData []byte ) func file_grpc_backend_proto_rawDescGZIP() []byte { file_grpc_backend_proto_rawDescOnce.Do(func() { - file_grpc_backend_proto_rawDescData = protoimpl.X.CompressGZIP(file_grpc_backend_proto_rawDescData) + file_grpc_backend_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_grpc_backend_proto_rawDesc), len(file_grpc_backend_proto_rawDesc))) }) return file_grpc_backend_proto_rawDescData } var file_grpc_backend_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_grpc_backend_proto_goTypes = []any{ - (*GetSessionCountRequest)(nil), // 0: signaling.GetSessionCountRequest - (*GetSessionCountReply)(nil), // 1: signaling.GetSessionCountReply + (*GetSessionCountRequest)(nil), // 0: grpc.GetSessionCountRequest + (*GetSessionCountReply)(nil), // 1: grpc.GetSessionCountReply } var file_grpc_backend_proto_depIdxs = []int32{ - 0, // 0: signaling.RpcBackend.GetSessionCount:input_type -> signaling.GetSessionCountRequest - 1, // 1: signaling.RpcBackend.GetSessionCount:output_type -> signaling.GetSessionCountReply + 0, // 0: grpc.RpcBackend.GetSessionCount:input_type -> grpc.GetSessionCountRequest + 1, // 1: grpc.RpcBackend.GetSessionCount:output_type -> grpc.GetSessionCountReply 1, // [1:2] is the sub-list for method output_type 0, // [0:1] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name @@ -186,7 +176,7 @@ func file_grpc_backend_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_grpc_backend_proto_rawDesc, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_grpc_backend_proto_rawDesc), len(file_grpc_backend_proto_rawDesc)), NumEnums: 0, NumMessages: 2, NumExtensions: 0, @@ -197,7 +187,6 @@ func file_grpc_backend_proto_init() { MessageInfos: file_grpc_backend_proto_msgTypes, }.Build() File_grpc_backend_proto = out.File - file_grpc_backend_proto_rawDesc = nil file_grpc_backend_proto_goTypes = nil file_grpc_backend_proto_depIdxs = nil } diff --git a/grpc_internal.proto b/grpc/backend.proto similarity index 80% rename from grpc_internal.proto rename to grpc/backend.proto index 6093f78..28fe818 100644 --- a/grpc_internal.proto +++ b/grpc/backend.proto @@ -21,18 +21,18 @@ */ syntax = "proto3"; -option go_package = "github.com/strukturag/nextcloud-spreed-signaling;signaling"; +option go_package = "github.com/strukturag/nextcloud-spreed-signaling/grpc"; -package signaling; +package grpc; -service RpcInternal { - rpc GetServerId(GetServerIdRequest) returns (GetServerIdReply) {} +service RpcBackend { + rpc GetSessionCount(GetSessionCountRequest) returns (GetSessionCountReply) {} } -message GetServerIdRequest { +message GetSessionCountRequest { +string url = 1; } -message GetServerIdReply { - string serverId = 1; - string version = 2; +message GetSessionCountReply { + uint32 count = 1; } diff --git a/grpc_backend_grpc.pb.go b/grpc/backend_grpc.pb.go similarity index 93% rename from grpc_backend_grpc.pb.go rename to grpc/backend_grpc.pb.go index 9f690fc..3d84ced 100644 --- a/grpc_backend_grpc.pb.go +++ b/grpc/backend_grpc.pb.go @@ -20,9 +20,9 @@ // along with this program. If not, see . // Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// source: grpc_backend.proto +// source: grpc/backend.proto -package signaling +package grpc import ( context "context" @@ -37,7 +37,7 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - RpcBackend_GetSessionCount_FullMethodName = "/signaling.RpcBackend/GetSessionCount" + RpcBackend_GetSessionCount_FullMethodName = "/grpc.RpcBackend/GetSessionCount" ) // RpcBackendClient is the client API for RpcBackend service. @@ -81,7 +81,7 @@ type RpcBackendServer interface { type UnimplementedRpcBackendServer struct{} func (UnimplementedRpcBackendServer) GetSessionCount(context.Context, *GetSessionCountRequest) (*GetSessionCountReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetSessionCount not implemented") + return nil, status.Error(codes.Unimplemented, "method GetSessionCount not implemented") } func (UnimplementedRpcBackendServer) mustEmbedUnimplementedRpcBackendServer() {} func (UnimplementedRpcBackendServer) testEmbeddedByValue() {} @@ -94,7 +94,7 @@ type UnsafeRpcBackendServer interface { } func RegisterRpcBackendServer(s grpc.ServiceRegistrar, srv RpcBackendServer) { - // If the following call pancis, it indicates UnimplementedRpcBackendServer was + // If the following call panics, it indicates UnimplementedRpcBackendServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -126,7 +126,7 @@ func _RpcBackend_GetSessionCount_Handler(srv interface{}, ctx context.Context, d // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var RpcBackend_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "signaling.RpcBackend", + ServiceName: "grpc.RpcBackend", HandlerType: (*RpcBackendServer)(nil), Methods: []grpc.MethodDesc{ { @@ -135,5 +135,5 @@ var RpcBackend_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{}, - Metadata: "grpc_backend.proto", + Metadata: "grpc/backend.proto", } diff --git a/grpc_client.go b/grpc/client.go similarity index 58% rename from grpc_client.go rename to grpc/client.go index 1d33e62..083903f 100644 --- a/grpc_client.go +++ b/grpc/client.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package grpc import ( "context" @@ -27,10 +27,8 @@ import ( "errors" "fmt" "io" - "log" "net" - "net/url" - "strings" + "slices" "sync" "sync/atomic" "time" @@ -39,21 +37,36 @@ import ( clientv3 "go.etcd.io/etcd/client/v3" "google.golang.org/grpc" codes "google.golang.org/grpc/codes" + "google.golang.org/grpc/connectivity" "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" "google.golang.org/grpc/resolver" status "google.golang.org/grpc/status" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async" + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) const ( - GrpcTargetTypeStatic = "static" - GrpcTargetTypeEtcd = "etcd" + TargetTypeStatic = "static" + TargetTypeEtcd = "etcd" - DefaultGrpcTargetType = GrpcTargetTypeStatic + DefaultTargetType = TargetTypeStatic + + initialWaitDelay = time.Second + maxWaitDelay = 8 * time.Second ) var ( - ErrNoSuchResumeId = fmt.Errorf("unknown resume id") + ErrNoSuchResumeId = errors.New("unknown resume id") + ErrNoSuchRoomSession = errors.New("unknown room session id") customResolverPrefix atomic.Uint64 ) @@ -69,7 +82,7 @@ type grpcClientImpl struct { RpcSessionsClient } -func newGrpcClientImpl(conn grpc.ClientConnInterface) *grpcClientImpl { +func newClientImpl(conn grpc.ClientConnInterface) *grpcClientImpl { return &grpcClientImpl{ RpcBackendClient: NewRpcBackendClient(conn), RpcInternalClient: NewRpcInternalClient(conn), @@ -78,13 +91,16 @@ func newGrpcClientImpl(conn grpc.ClientConnInterface) *grpcClientImpl { } } -type GrpcClient struct { - ip net.IP - target string - conn *grpc.ClientConn - impl *grpcClientImpl +type Client struct { + logger log.Logger + ip net.IP + rawTarget string + target string + conn *grpc.ClientConn + impl *grpcClientImpl - isSelf atomic.Bool + isSelf atomic.Bool + version atomic.Value } type customIpResolver struct { @@ -125,7 +141,7 @@ func (r *customIpResolver) Close() { // Noop } -func NewGrpcClient(target string, ip net.IP, opts ...grpc.DialOption) (*GrpcClient, error) { +func NewClient(logger log.Logger, target string, ip net.IP, opts ...grpc.DialOption) (*Client, error) { var conn *grpc.ClientConn var err error if ip != nil { @@ -150,36 +166,43 @@ func NewGrpcClient(target string, ip net.IP, opts ...grpc.DialOption) (*GrpcClie return nil, err } - result := &GrpcClient{ - ip: ip, - target: target, - conn: conn, - impl: newGrpcClientImpl(conn), + result := &Client{ + logger: logger, + ip: ip, + rawTarget: target, + target: target, + conn: conn, + impl: newClientImpl(conn), } if ip != nil { result.target += " (" + ip.String() + ")" } + result.version.Store("") return result, nil } -func (c *GrpcClient) Target() string { +func (c *Client) Target() string { return c.target } -func (c *GrpcClient) Close() error { +func (c *Client) Version() string { + return c.version.Load().(string) +} + +func (c *Client) Close() error { return c.conn.Close() } -func (c *GrpcClient) IsSelf() bool { +func (c *Client) IsSelf() bool { return c.isSelf.Load() } -func (c *GrpcClient) SetSelf(self bool) { +func (c *Client) SetSelf(self bool) { c.isSelf.Store(self) } -func (c *GrpcClient) GetServerId(ctx context.Context) (string, string, error) { +func (c *Client) GetServerId(ctx context.Context) (string, string, error) { statsGrpcClientCalls.WithLabelValues("GetServerId").Inc() response, err := c.impl.GetServerId(ctx, &GetServerIdRequest{}, grpc.WaitForReady(true)) if err != nil { @@ -189,12 +212,12 @@ func (c *GrpcClient) GetServerId(ctx context.Context) (string, string, error) { return response.GetServerId(), response.GetVersion(), nil } -func (c *GrpcClient) LookupResumeId(ctx context.Context, resumeId string) (*LookupResumeIdReply, error) { +func (c *Client) LookupResumeId(ctx context.Context, resumeId api.PrivateSessionId) (*LookupResumeIdReply, error) { statsGrpcClientCalls.WithLabelValues("LookupResumeId").Inc() // TODO: Remove debug logging - log.Printf("Lookup resume id %s on %s", resumeId, c.Target()) + c.logger.Printf("Lookup resume id %s on %s", resumeId, c.Target()) response, err := c.impl.LookupResumeId(ctx, &LookupResumeIdRequest{ - ResumeId: resumeId, + ResumeId: string(resumeId), }, grpc.WaitForReady(true)) if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { return nil, ErrNoSuchResumeId @@ -209,12 +232,12 @@ func (c *GrpcClient) LookupResumeId(ctx context.Context, resumeId string) (*Look return response, nil } -func (c *GrpcClient) LookupSessionId(ctx context.Context, roomSessionId string, disconnectReason string) (string, error) { +func (c *Client) LookupSessionId(ctx context.Context, roomSessionId api.RoomSessionId, disconnectReason string) (api.PublicSessionId, error) { statsGrpcClientCalls.WithLabelValues("LookupSessionId").Inc() // TODO: Remove debug logging - log.Printf("Lookup room session %s on %s", roomSessionId, c.Target()) + c.logger.Printf("Lookup room session %s on %s", roomSessionId, c.Target()) response, err := c.impl.LookupSessionId(ctx, &LookupSessionIdRequest{ - RoomSessionId: roomSessionId, + RoomSessionId: string(roomSessionId), DisconnectReason: disconnectReason, }, grpc.WaitForReady(true)) if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { @@ -228,17 +251,17 @@ func (c *GrpcClient) LookupSessionId(ctx context.Context, roomSessionId string, return "", ErrNoSuchRoomSession } - return sessionId, nil + return api.PublicSessionId(sessionId), nil } -func (c *GrpcClient) IsSessionInCall(ctx context.Context, sessionId string, room *Room) (bool, error) { +func (c *Client) IsSessionInCall(ctx context.Context, sessionId api.PublicSessionId, roomId string, backendUrl string) (bool, error) { statsGrpcClientCalls.WithLabelValues("IsSessionInCall").Inc() // TODO: Remove debug logging - log.Printf("Check if session %s is in call %s on %s", sessionId, room.Id(), c.Target()) + c.logger.Printf("Check if session %s is in call %s on %s", sessionId, roomId, c.Target()) response, err := c.impl.IsSessionInCall(ctx, &IsSessionInCallRequest{ - SessionId: sessionId, - RoomId: room.Id(), - BackendUrl: room.Backend().url, + SessionId: string(sessionId), + RoomId: roomId, + BackendUrl: backendUrl, }, grpc.WaitForReady(true)) if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { return false, nil @@ -249,13 +272,18 @@ func (c *GrpcClient) IsSessionInCall(ctx context.Context, sessionId string, room return response.GetInCall(), nil } -func (c *GrpcClient) GetInternalSessions(ctx context.Context, roomId string, backend *Backend) (internal map[string]*InternalSessionData, virtual map[string]*VirtualSessionData, err error) { +func (c *Client) GetInternalSessions(ctx context.Context, roomId string, backendUrls []string) (internal map[api.PublicSessionId]*InternalSessionData, virtual map[api.PublicSessionId]*VirtualSessionData, err error) { statsGrpcClientCalls.WithLabelValues("GetInternalSessions").Inc() // TODO: Remove debug logging - log.Printf("Get internal sessions for %s@%s on %s", roomId, backend.Id(), c.Target()) + c.logger.Printf("Get internal sessions for %s on %s", roomId, c.Target()) + var backendUrl string + if len(backendUrls) > 0 { + backendUrl = backendUrls[0] + } response, err := c.impl.GetInternalSessions(ctx, &GetInternalSessionsRequest{ - RoomId: roomId, - BackendUrl: backend.Url(), + RoomId: roomId, + BackendUrl: backendUrl, + BackendUrls: backendUrls, }, grpc.WaitForReady(true)) if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { return nil, nil, nil @@ -264,44 +292,44 @@ func (c *GrpcClient) GetInternalSessions(ctx context.Context, roomId string, bac } if len(response.InternalSessions) > 0 { - internal = make(map[string]*InternalSessionData, len(response.InternalSessions)) + internal = make(map[api.PublicSessionId]*InternalSessionData, len(response.InternalSessions)) for _, s := range response.InternalSessions { - internal[s.SessionId] = s + internal[api.PublicSessionId(s.SessionId)] = s } } if len(response.VirtualSessions) > 0 { - virtual = make(map[string]*VirtualSessionData, len(response.VirtualSessions)) + virtual = make(map[api.PublicSessionId]*VirtualSessionData, len(response.VirtualSessions)) for _, s := range response.VirtualSessions { - virtual[s.SessionId] = s + virtual[api.PublicSessionId(s.SessionId)] = s } } return } -func (c *GrpcClient) GetPublisherId(ctx context.Context, sessionId string, streamType StreamType) (string, string, net.IP, error) { +func (c *Client) GetPublisherId(ctx context.Context, sessionId api.PublicSessionId, streamType sfu.StreamType) (api.PublicSessionId, string, net.IP, string, string, error) { statsGrpcClientCalls.WithLabelValues("GetPublisherId").Inc() // TODO: Remove debug logging - log.Printf("Get %s publisher id %s on %s", streamType, sessionId, c.Target()) + c.logger.Printf("Get %s publisher id %s on %s", streamType, sessionId, c.Target()) response, err := c.impl.GetPublisherId(ctx, &GetPublisherIdRequest{ - SessionId: sessionId, + SessionId: string(sessionId), StreamType: string(streamType), }, grpc.WaitForReady(true)) if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { - return "", "", nil, nil + return "", "", nil, "", "", nil } else if err != nil { - return "", "", nil, err + return "", "", nil, "", "", err } - return response.GetPublisherId(), response.GetProxyUrl(), net.ParseIP(response.GetIp()), nil + return api.PublicSessionId(response.GetPublisherId()), response.GetProxyUrl(), net.ParseIP(response.GetIp()), response.GetConnectToken(), response.GetPublisherToken(), nil } -func (c *GrpcClient) GetSessionCount(ctx context.Context, u *url.URL) (uint32, error) { +func (c *Client) GetSessionCount(ctx context.Context, url string) (uint32, error) { statsGrpcClientCalls.WithLabelValues("GetSessionCount").Inc() // TODO: Remove debug logging - log.Printf("Get session count for %s on %s", u, c.Target()) + c.logger.Printf("Get session count for %s on %s", url, c.Target()) response, err := c.impl.GetSessionCount(ctx, &GetSessionCountRequest{ - Url: u.String(), + Url: url, }, grpc.WaitForReady(true)) if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { return 0, nil @@ -312,9 +340,43 @@ func (c *GrpcClient) GetSessionCount(ctx context.Context, u *url.URL) (uint32, e return response.GetCount(), nil } +func (c *Client) GetTransientData(ctx context.Context, roomId string, backend *talk.Backend) (api.TransientDataEntries, error) { + statsGrpcClientCalls.WithLabelValues("GetTransientData").Inc() + // TODO: Remove debug logging + c.logger.Printf("Get transient data for %s@%s on %s", roomId, backend.Id(), c.Target()) + response, err := c.impl.GetTransientData(ctx, &GetTransientDataRequest{ + RoomId: roomId, + BackendUrls: backend.Urls(), + }, grpc.WaitForReady(true)) + if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { + return nil, nil + } else if err != nil { + return nil, err + } + + entries := response.GetEntries() + if len(entries) == 0 { + return nil, nil + } + + result := make(api.TransientDataEntries, len(entries)) + for k, v := range entries { + var value any + if err := json.Unmarshal(v.Value, &value); err != nil { + return nil, err + } + if v.Expires > 0 { + result[k] = api.NewTransientDataEntryWithExpires(value, time.UnixMicro(v.Expires)) + } else { + result[k] = api.NewTransientDataEntry(value, 0) + } + } + return result, nil +} + type ProxySessionReceiver interface { RemoteAddr() string - Country() string + Country() geoip.Country UserAgent() string OnProxyMessage(message *ServerSessionMessage) error @@ -322,7 +384,8 @@ type ProxySessionReceiver interface { } type SessionProxy struct { - sessionId string + logger log.Logger + sessionId api.PublicSessionId receiver ProxySessionReceiver sendMu sync.Mutex @@ -334,7 +397,7 @@ func (p *SessionProxy) recvPump() { defer func() { p.receiver.OnProxyClose(closeError) if err := p.Close(); err != nil { - log.Printf("Error closing proxy for session %s: %s", p.sessionId, err) + p.logger.Printf("Error closing proxy for session %s: %s", p.sessionId, err) } }() @@ -345,13 +408,13 @@ func (p *SessionProxy) recvPump() { break } - log.Printf("Error receiving message from proxy for session %s: %s", p.sessionId, err) + p.logger.Printf("Error receiving message from proxy for session %s: %s", p.sessionId, err) closeError = err break } if err := p.receiver.OnProxyMessage(msg); err != nil { - log.Printf("Error processing message %+v from proxy for session %s: %s", msg, p.sessionId, err) + p.logger.Printf("Error processing message %+v from proxy for session %s: %s", msg, p.sessionId, err) } } } @@ -368,12 +431,12 @@ func (p *SessionProxy) Close() error { return p.client.CloseSend() } -func (c *GrpcClient) ProxySession(ctx context.Context, sessionId string, receiver ProxySessionReceiver) (*SessionProxy, error) { +func (c *Client) ProxySession(ctx context.Context, sessionId api.PublicSessionId, receiver ProxySessionReceiver) (*SessionProxy, error) { statsGrpcClientCalls.WithLabelValues("ProxySession").Inc() md := metadata.Pairs( - "sessionId", sessionId, + "sessionId", string(sessionId), "remoteAddr", receiver.RemoteAddr(), - "country", receiver.Country(), + "country", string(receiver.Country()), "userAgent", receiver.UserAgent(), ) client, err := c.impl.ProxySession(metadata.NewOutgoingContext(ctx, md), grpc.WaitForReady(true)) @@ -382,6 +445,7 @@ func (c *GrpcClient) ProxySession(ctx context.Context, sessionId string, receive } proxy := &SessionProxy{ + logger: c.logger, sessionId: sessionId, receiver: receiver, @@ -392,24 +456,29 @@ func (c *GrpcClient) ProxySession(ctx context.Context, sessionId string, receive return proxy, nil } -type grpcClientsList struct { - clients []*GrpcClient - entry *DnsMonitorEntry +type clientsList struct { + clients []*Client + entry *dns.MonitorEntry } -type GrpcClients struct { +type Clients struct { mu sync.RWMutex version string + logger log.Logger - clientsMap map[string]*grpcClientsList - clients []*GrpcClient + // +checklocks:mu + clientsMap map[string]*clientsList + // +checklocks:mu + clients []*Client - dnsMonitor *DnsMonitor + dnsMonitor *dns.Monitor + // +checklocks:mu dnsDiscovery bool - etcdClient *EtcdClient - targetPrefix string - targetInformation map[string]*GrpcTargetInformationEtcd + etcdClient etcd.Client // +checklocksignore: Only written to from constructor. + targetPrefix string + // +checklocks:mu + targetInformation map[string]*TargetInformationEtcd dialOptions atomic.Value // []grpc.DialOption creds credentials.TransportCredentials @@ -419,14 +488,15 @@ type GrpcClients struct { selfCheckWaitGroup sync.WaitGroup closeCtx context.Context - closeFunc context.CancelFunc + closeFunc context.CancelFunc // +checklocksignore: No locking necessary. } -func NewGrpcClients(config *goconf.ConfigFile, etcdClient *EtcdClient, dnsMonitor *DnsMonitor, version string) (*GrpcClients, error) { +func NewClients(ctx context.Context, config *goconf.ConfigFile, etcdClient etcd.Client, dnsMonitor *dns.Monitor, version string) (*Clients, error) { initializedCtx, initializedFunc := context.WithCancel(context.Background()) closeCtx, closeFunc := context.WithCancel(context.Background()) - result := &GrpcClients{ + result := &Clients{ version: version, + logger: log.LoggerFromContext(ctx), dnsMonitor: dnsMonitor, etcdClient: etcdClient, initializedCtx: initializedCtx, @@ -440,8 +510,34 @@ func NewGrpcClients(config *goconf.ConfigFile, etcdClient *EtcdClient, dnsMonito return result, nil } -func (c *GrpcClients) load(config *goconf.ConfigFile, fromReload bool) error { - creds, err := NewReloadableCredentials(config, false) +func (c *Clients) GetServerInfoGrpc() (result []talk.BackendServerInfoGrpc) { + c.mu.RLock() + defer c.mu.RUnlock() + + for _, client := range c.clients { + if client.IsSelf() { + continue + } + + grpc := talk.BackendServerInfoGrpc{ + Target: client.rawTarget, + } + if len(client.ip) > 0 { + grpc.IP = client.ip.String() + } + if client.conn.GetState() == connectivity.Ready { + grpc.Connected = true + grpc.Version = client.Version() + } + + result = append(result, grpc) + } + + return +} + +func (c *Clients) load(config *goconf.ConfigFile, fromReload bool) error { + creds, err := NewReloadableCredentials(c.logger, config, false) if err != nil { return err } @@ -458,13 +554,13 @@ func (c *GrpcClients) load(config *goconf.ConfigFile, fromReload bool) error { targetType, _ := config.GetString("grpc", "targettype") if targetType == "" { - targetType = DefaultGrpcTargetType + targetType = DefaultTargetType } switch targetType { - case GrpcTargetTypeStatic: + case TargetTypeStatic: err = c.loadTargetsStatic(config, fromReload, opts...) - case GrpcTargetTypeEtcd: + case TargetTypeEtcd: err = c.loadTargetsEtcd(config, fromReload, opts...) default: err = fmt.Errorf("unknown GRPC target type: %s", targetType) @@ -472,18 +568,18 @@ func (c *GrpcClients) load(config *goconf.ConfigFile, fromReload bool) error { return err } -func (c *GrpcClients) closeClient(client *GrpcClient) { +func (c *Clients) closeClient(client *Client) { if client.IsSelf() { // Already closed. return } if err := client.Close(); err != nil { - log.Printf("Error closing client to %s: %s", client.Target(), err) + c.logger.Printf("Error closing client to %s: %s", client.Target(), err) } } -func (c *GrpcClients) isClientAvailable(target string, client *GrpcClient) bool { +func (c *Clients) isClientAvailable(target string, client *Client) bool { c.mu.RLock() defer c.mu.RUnlock() @@ -492,16 +588,10 @@ func (c *GrpcClients) isClientAvailable(target string, client *GrpcClient) bool return false } - for _, entry := range entries.clients { - if entry == client { - return true - } - } - - return false + return slices.Contains(entries.clients, client) } -func (c *GrpcClients) getServerIdWithTimeout(ctx context.Context, client *GrpcClient) (string, string, error) { +func (c *Clients) getServerIdWithTimeout(ctx context.Context, client *Client) (string, string, error) { ctx2, cancel := context.WithTimeout(ctx, time.Second) defer cancel() @@ -509,8 +599,12 @@ func (c *GrpcClients) getServerIdWithTimeout(ctx context.Context, client *GrpcCl return id, version, err } -func (c *GrpcClients) checkIsSelf(ctx context.Context, target string, client *GrpcClient) { - backoff, _ := NewExponentialBackoff(initialWaitDelay, maxWaitDelay) +func (c *Clients) WaitForSelfCheck() { + c.selfCheckWaitGroup.Wait() +} + +func (c *Clients) checkIsSelf(ctx context.Context, target string, client *Client) { + backoff, _ := async.NewExponentialBackoff(initialWaitDelay, maxWaitDelay) defer c.selfCheckWaitGroup.Done() loop: @@ -531,27 +625,28 @@ loop: } if status.Code(err) != codes.Canceled { - log.Printf("Error checking GRPC server id of %s, retrying in %s: %s", client.Target(), backoff.NextWait(), err) + c.logger.Printf("Error checking GRPC server id of %s, retrying in %s: %s", client.Target(), backoff.NextWait(), err) } backoff.Wait(ctx) continue } - if id == GrpcServerId { - log.Printf("GRPC target %s is this server, removing", client.Target()) + client.version.Store(version) + if id == ServerId { + c.logger.Printf("GRPC target %s is this server, removing", client.Target()) c.closeClient(client) client.SetSelf(true) } else if version != c.version { - log.Printf("WARNING: Node %s is runing different version %s than local node (%s)", client.Target(), version, c.version) + c.logger.Printf("WARNING: Node %s is running different version %s than local node (%s)", client.Target(), version, c.version) } else { - log.Printf("Checked GRPC server id of %s running version %s", client.Target(), version) + c.logger.Printf("Checked GRPC server id of %s running version %s", client.Target(), version) } break loop } } } -func (c *GrpcClients) loadTargetsStatic(config *goconf.ConfigFile, fromReload bool, opts ...grpc.DialOption) error { +func (c *Clients) loadTargetsStatic(config *goconf.ConfigFile, fromReload bool, opts ...grpc.DialOption) error { c.mu.Lock() defer c.mu.Unlock() @@ -568,8 +663,8 @@ func (c *GrpcClients) loadTargetsStatic(config *goconf.ConfigFile, fromReload bo c.dnsDiscovery = dnsDiscovery } - clientsMap := make(map[string]*grpcClientsList) - var clients []*GrpcClient + clientsMap := make(map[string]*clientsList) + var clients []*Client removeTargets := make(map[string]bool, len(c.clientsMap)) for target, entries := range c.clientsMap { removeTargets[target] = true @@ -577,12 +672,7 @@ func (c *GrpcClients) loadTargetsStatic(config *goconf.ConfigFile, fromReload bo } targets, _ := config.GetString("grpc", "targets") - for _, target := range strings.Split(targets, ",") { - target = strings.TrimSpace(target) - if target == "" { - continue - } - + for target := range internal.SplitEntries(targets, ",") { if entries, found := clientsMap[target]; found { clients = append(clients, entries.clients...) if dnsDiscovery && entries.entry == nil { @@ -609,13 +699,13 @@ func (c *GrpcClients) loadTargetsStatic(config *goconf.ConfigFile, fromReload bo return err } - clientsMap[target] = &grpcClientsList{ + clientsMap[target] = &clientsList{ entry: entry, } continue } - client, err := NewGrpcClient(target, nil, opts...) + client, err := NewClient(c.logger, target, nil, opts...) if err != nil { for _, entry := range clientsMap { for _, client := range entry.clients { @@ -633,10 +723,10 @@ func (c *GrpcClients) loadTargetsStatic(config *goconf.ConfigFile, fromReload bo c.selfCheckWaitGroup.Add(1) go c.checkIsSelf(c.closeCtx, target, client) - log.Printf("Adding %s as GRPC target", client.Target()) + c.logger.Printf("Adding %s as GRPC target", client.Target()) entry, found := clientsMap[target] if !found { - entry = &grpcClientsList{} + entry = &clientsList{} clientsMap[target] = entry } entry.clients = append(entry.clients, client) @@ -646,7 +736,7 @@ func (c *GrpcClients) loadTargetsStatic(config *goconf.ConfigFile, fromReload bo for target := range removeTargets { if entry, found := clientsMap[target]; found { for _, client := range entry.clients { - log.Printf("Deleting GRPC target %s", client.Target()) + c.logger.Printf("Deleting GRPC target %s", client.Target()) c.closeClient(client) } @@ -665,7 +755,7 @@ func (c *GrpcClients) loadTargetsStatic(config *goconf.ConfigFile, fromReload bo return nil } -func (c *GrpcClients) onLookup(entry *DnsMonitorEntry, all []net.IP, added []net.IP, keep []net.IP, removed []net.IP) { +func (c *Clients) onLookup(entry *dns.MonitorEntry, all []net.IP, added []net.IP, keep []net.IP, removed []net.IP) { c.mu.Lock() defer c.mu.Unlock() @@ -678,12 +768,12 @@ func (c *GrpcClients) onLookup(entry *DnsMonitorEntry, all []net.IP, added []net opts := c.dialOptions.Load().([]grpc.DialOption) mapModified := false - var newClients []*GrpcClient + var newClients []*Client for _, ip := range removed { for _, client := range e.clients { if ip.Equal(client.ip) { mapModified = true - log.Printf("Removing connection to %s", client.Target()) + c.logger.Printf("Removing connection to %s", client.Target()) c.closeClient(client) c.wakeupForTesting() } @@ -699,16 +789,16 @@ func (c *GrpcClients) onLookup(entry *DnsMonitorEntry, all []net.IP, added []net } for _, ip := range added { - client, err := NewGrpcClient(target, ip, opts...) + client, err := NewClient(c.logger, target, ip, opts...) if err != nil { - log.Printf("Error creating client to %s with IP %s: %s", target, ip.String(), err) + c.logger.Printf("Error creating client to %s with IP %s: %s", target, ip.String(), err) continue } c.selfCheckWaitGroup.Add(1) go c.checkIsSelf(c.closeCtx, target, client) - log.Printf("Adding %s as GRPC target", client.Target()) + c.logger.Printf("Adding %s as GRPC target", client.Target()) newClients = append(newClients, client) mapModified = true c.wakeupForTesting() @@ -717,7 +807,7 @@ func (c *GrpcClients) onLookup(entry *DnsMonitorEntry, all []net.IP, added []net if mapModified { c.clientsMap[target].clients = newClients - c.clients = make([]*GrpcClient, 0, len(c.clientsMap)) + c.clients = make([]*Client, 0, len(c.clientsMap)) for _, entry := range c.clientsMap { c.clients = append(c.clients, entry.clients...) } @@ -725,25 +815,28 @@ func (c *GrpcClients) onLookup(entry *DnsMonitorEntry, all []net.IP, added []net } } -func (c *GrpcClients) loadTargetsEtcd(config *goconf.ConfigFile, fromReload bool, opts ...grpc.DialOption) error { +func (c *Clients) loadTargetsEtcd(config *goconf.ConfigFile, fromReload bool, opts ...grpc.DialOption) error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.etcdClient.IsConfigured() { - return fmt.Errorf("No etcd endpoints configured") + return errors.New("no etcd endpoints configured") } targetPrefix, _ := config.GetString("grpc", "targetprefix") if targetPrefix == "" { - return fmt.Errorf("No GRPC target prefix configured") + return errors.New("no GRPC target prefix configured") } c.targetPrefix = targetPrefix if c.targetInformation == nil { - c.targetInformation = make(map[string]*GrpcTargetInformationEtcd) + c.targetInformation = make(map[string]*TargetInformationEtcd) } c.etcdClient.AddListener(c) return nil } -func (c *GrpcClients) EtcdClientCreated(client *EtcdClient) { +func (c *Clients) EtcdClientCreated(client etcd.Client) { go func() { if err := client.WaitForConnection(c.closeCtx); err != nil { if errors.Is(err, context.Canceled) { @@ -753,7 +846,7 @@ func (c *GrpcClients) EtcdClientCreated(client *EtcdClient) { panic(err) } - backoff, _ := NewExponentialBackoff(initialWaitDelay, maxWaitDelay) + backoff, _ := async.NewExponentialBackoff(initialWaitDelay, maxWaitDelay) var nextRevision int64 for c.closeCtx.Err() == nil { response, err := c.getGrpcTargets(c.closeCtx, client, c.targetPrefix) @@ -761,9 +854,9 @@ func (c *GrpcClients) EtcdClientCreated(client *EtcdClient) { if errors.Is(err, context.Canceled) { return } else if errors.Is(err, context.DeadlineExceeded) { - log.Printf("Timeout getting initial list of GRPC targets, retry in %s", backoff.NextWait()) + c.logger.Printf("Timeout getting initial list of GRPC targets, retry in %s", backoff.NextWait()) } else { - log.Printf("Could not get initial list of GRPC targets, retry in %s: %s", backoff.NextWait(), err) + c.logger.Printf("Could not get initial list of GRPC targets, retry in %s: %s", backoff.NextWait(), err) } backoff.Wait(c.closeCtx) @@ -783,7 +876,7 @@ func (c *GrpcClients) EtcdClientCreated(client *EtcdClient) { for c.closeCtx.Err() == nil { var err error if nextRevision, err = client.Watch(c.closeCtx, c.targetPrefix, nextRevision, c, clientv3.WithPrefix()); err != nil { - log.Printf("Error processing watch for %s (%s), retry in %s", c.targetPrefix, err, backoff.NextWait()) + c.logger.Printf("Error processing watch for %s (%s), retry in %s", c.targetPrefix, err, backoff.NextWait()) backoff.Wait(c.closeCtx) continue } @@ -792,31 +885,31 @@ func (c *GrpcClients) EtcdClientCreated(client *EtcdClient) { backoff.Reset() prevRevision = nextRevision } else { - log.Printf("Processing watch for %s interrupted, retry in %s", c.targetPrefix, backoff.NextWait()) + c.logger.Printf("Processing watch for %s interrupted, retry in %s", c.targetPrefix, backoff.NextWait()) backoff.Wait(c.closeCtx) } } }() } -func (c *GrpcClients) EtcdWatchCreated(client *EtcdClient, key string) { +func (c *Clients) EtcdWatchCreated(client etcd.Client, key string) { } -func (c *GrpcClients) getGrpcTargets(ctx context.Context, client *EtcdClient, targetPrefix string) (*clientv3.GetResponse, error) { +func (c *Clients) getGrpcTargets(ctx context.Context, client etcd.Client, targetPrefix string) (*clientv3.GetResponse, error) { ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() return client.Get(ctx, targetPrefix, clientv3.WithPrefix()) } -func (c *GrpcClients) EtcdKeyUpdated(client *EtcdClient, key string, data []byte, prevValue []byte) { - var info GrpcTargetInformationEtcd +func (c *Clients) EtcdKeyUpdated(client etcd.Client, key string, data []byte, prevValue []byte) { + var info TargetInformationEtcd if err := json.Unmarshal(data, &info); err != nil { - log.Printf("Could not decode GRPC target %s=%s: %s", key, string(data), err) + c.logger.Printf("Could not decode GRPC target %s=%s: %s", key, string(data), err) return } if err := info.CheckValid(); err != nil { - log.Printf("Received invalid GRPC target %s=%s: %s", key, string(data), err) + c.logger.Printf("Received invalid GRPC target %s=%s: %s", key, string(data), err) return } @@ -830,27 +923,27 @@ func (c *GrpcClients) EtcdKeyUpdated(client *EtcdClient, key string, data []byte } if _, found := c.clientsMap[info.Address]; found { - log.Printf("GRPC target %s already exists, ignoring %s", info.Address, key) + c.logger.Printf("GRPC target %s already exists, ignoring %s", info.Address, key) return } opts := c.dialOptions.Load().([]grpc.DialOption) - cl, err := NewGrpcClient(info.Address, nil, opts...) + cl, err := NewClient(c.logger, info.Address, nil, opts...) if err != nil { - log.Printf("Could not create GRPC client for target %s: %s", info.Address, err) + c.logger.Printf("Could not create GRPC client for target %s: %s", info.Address, err) return } c.selfCheckWaitGroup.Add(1) go c.checkIsSelf(c.closeCtx, info.Address, cl) - log.Printf("Adding %s as GRPC target", cl.Target()) + c.logger.Printf("Adding %s as GRPC target", cl.Target()) if c.clientsMap == nil { - c.clientsMap = make(map[string]*grpcClientsList) + c.clientsMap = make(map[string]*clientsList) } - c.clientsMap[info.Address] = &grpcClientsList{ - clients: []*GrpcClient{cl}, + c.clientsMap[info.Address] = &clientsList{ + clients: []*Client{cl}, } c.clients = append(c.clients, cl) c.targetInformation[key] = &info @@ -858,17 +951,18 @@ func (c *GrpcClients) EtcdKeyUpdated(client *EtcdClient, key string, data []byte c.wakeupForTesting() } -func (c *GrpcClients) EtcdKeyDeleted(client *EtcdClient, key string, prevValue []byte) { +func (c *Clients) EtcdKeyDeleted(client etcd.Client, key string, prevValue []byte) { c.mu.Lock() defer c.mu.Unlock() c.removeEtcdClientLocked(key) } -func (c *GrpcClients) removeEtcdClientLocked(key string) { +// +checklocks:c.mu +func (c *Clients) removeEtcdClientLocked(key string) { info, found := c.targetInformation[key] if !found { - log.Printf("No connection found for %s, ignoring", key) + c.logger.Printf("No connection found for %s, ignoring", key) c.wakeupForTesting() return } @@ -880,11 +974,11 @@ func (c *GrpcClients) removeEtcdClientLocked(key string) { } for _, client := range entry.clients { - log.Printf("Removing connection to %s (from %s)", client.Target(), key) + c.logger.Printf("Removing connection to %s (from %s)", client.Target(), key) c.closeClient(client) } delete(c.clientsMap, info.Address) - c.clients = make([]*GrpcClient, 0, len(c.clientsMap)) + c.clients = make([]*Client, 0, len(c.clientsMap)) for _, entry := range c.clientsMap { c.clients = append(c.clients, entry.clients...) } @@ -892,7 +986,7 @@ func (c *GrpcClients) removeEtcdClientLocked(key string) { c.wakeupForTesting() } -func (c *GrpcClients) WaitForInitialized(ctx context.Context) error { +func (c *Clients) WaitForInitialized(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() @@ -901,7 +995,20 @@ func (c *GrpcClients) WaitForInitialized(ctx context.Context) error { } } -func (c *GrpcClients) wakeupForTesting() { +func (c *Clients) GetWakeupChannelForTesting() <-chan struct{} { + c.mu.Lock() + defer c.mu.Unlock() + + if c.wakeupChanForTesting != nil { + return c.wakeupChanForTesting + } + + ch := make(chan struct{}, 1) + c.wakeupChanForTesting = ch + return ch +} + +func (c *Clients) wakeupForTesting() { if c.wakeupChanForTesting == nil { return } @@ -912,20 +1019,20 @@ func (c *GrpcClients) wakeupForTesting() { } } -func (c *GrpcClients) Reload(config *goconf.ConfigFile) { +func (c *Clients) Reload(config *goconf.ConfigFile) { if err := c.load(config, true); err != nil { - log.Printf("Could not reload RPC clients: %s", err) + c.logger.Printf("Could not reload RPC clients: %s", err) } } -func (c *GrpcClients) Close() { +func (c *Clients) Close() { c.mu.Lock() defer c.mu.Unlock() for _, entry := range c.clientsMap { for _, client := range entry.clients { if err := client.Close(); err != nil { - log.Printf("Error closing client to %s: %s", client.Target(), err) + c.logger.Printf("Error closing client to %s: %s", client.Target(), err) } } @@ -950,7 +1057,7 @@ func (c *GrpcClients) Close() { c.closeFunc() } -func (c *GrpcClients) GetClients() []*GrpcClient { +func (c *Clients) GetClients() []*Client { c.mu.RLock() defer c.mu.RUnlock() @@ -958,7 +1065,7 @@ func (c *GrpcClients) GetClients() []*GrpcClient { return c.clients } - result := make([]*GrpcClient, 0, len(c.clients)-1) + result := make([]*Client, 0, len(c.clients)-1) for _, client := range c.clients { if client.IsSelf() { continue diff --git a/grpc_stats_prometheus.go b/grpc/client_stats_prometheus.go similarity index 74% rename from grpc_stats_prometheus.go rename to grpc/client_stats_prometheus.go index fa37bdc..8f7070e 100644 --- a/grpc_stats_prometheus.go +++ b/grpc/client_stats_prometheus.go @@ -19,14 +19,16 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package grpc import ( "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" ) var ( - statsGrpcClients = prometheus.NewGauge(prometheus.GaugeOpts{ + statsGrpcClients = prometheus.NewGauge(prometheus.GaugeOpts{ // +checklocksignore: Global readonly variable. Namespace: "signaling", Subsystem: "grpc", Name: "clients", @@ -43,23 +45,8 @@ var ( statsGrpcClients, statsGrpcClientCalls, } - - statsGrpcServerCalls = prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "signaling", - Subsystem: "grpc", - Name: "server_calls_total", - Help: "The total number of GRPC server calls", - }, []string{"method"}) - - grpcServerStats = []prometheus.Collector{ - statsGrpcServerCalls, - } ) func RegisterGrpcClientStats() { - registerAll(grpcClientStats...) -} - -func RegisterGrpcServerStats() { - registerAll(grpcServerStats...) + metrics.RegisterAll(grpcClientStats...) } diff --git a/grpc/client_test.go b/grpc/client_test.go new file mode 100644 index 0000000..6f6680c --- /dev/null +++ b/grpc/client_test.go @@ -0,0 +1,285 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2022 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package grpc + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns" + dnstest "github.com/strukturag/nextcloud-spreed-signaling/v2/dns/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + etcdtest "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" +) + +func NewClientsForTestWithConfig(t *testing.T, config *goconf.ConfigFile, etcdClient etcd.Client, lookup *dnstest.MockLookup) (*Clients, *dns.Monitor) { + dnsMonitor := dnstest.NewMonitorForTest(t, time.Hour, lookup) // will be updated manually + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + client, err := NewClients(ctx, config, etcdClient, dnsMonitor, "0.0.0") + require.NoError(t, err) + t.Cleanup(func() { + client.Close() + }) + + return client, dnsMonitor +} + +func NewClientsForTest(t *testing.T, addr string, lookup *dnstest.MockLookup) (*Clients, *dns.Monitor) { + config := goconf.NewConfigFile() + config.AddOption("grpc", "targets", addr) + config.AddOption("grpc", "dnsdiscovery", "true") + + return NewClientsForTestWithConfig(t, config, nil, lookup) +} + +func NewClientsWithEtcdForTest(t *testing.T, embedEtcd *etcdtest.EtcdServer, lookup *dnstest.MockLookup) (*Clients, *dns.Monitor) { + config := goconf.NewConfigFile() + config.AddOption("etcd", "endpoints", embedEtcd.URL().String()) + + config.AddOption("grpc", "targettype", "etcd") + config.AddOption("grpc", "targetprefix", "/grpctargets") + + logger := logtest.NewLoggerForTest(t) + etcdClient, err := etcd.NewClient(logger, config, "") + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, etcdClient.Close()) + }) + + return NewClientsForTestWithConfig(t, config, etcdClient, lookup) +} + +func waitForEvent(ctx context.Context, t *testing.T, ch <-chan struct{}) { + t.Helper() + + select { + case <-ch: + return + case <-ctx.Done(): + assert.Fail(t, "timeout waiting for event") + } +} + +func Test_GrpcClients_DnsDiscovery(t *testing.T) { // nolint:paralleltest + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + lookup := dnstest.NewMockLookup() + target := "testgrpc:12345" + ip1 := net.ParseIP("192.168.0.1") + ip2 := net.ParseIP("192.168.0.2") + targetWithIp1 := fmt.Sprintf("%s (%s)", target, ip1) + targetWithIp2 := fmt.Sprintf("%s (%s)", target, ip2) + lookup.Set("testgrpc", []net.IP{ip1}) + client, dnsMonitor := NewClientsForTest(t, target, lookup) + ch := client.GetWakeupChannelForTesting() + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + // Wait for initial check to be done to make sure internal dnsmonitor goroutine is waiting. + if err := dnsMonitor.WaitForTicker(ctx); err != nil { + require.NoError(err) + } + + test.DrainWakeupChannel(ch) + dnsMonitor.CheckHostnames() + if clients := client.GetClients(); assert.Len(clients, 1) { + assert.Equal(targetWithIp1, clients[0].Target()) + assert.True(clients[0].ip.Equal(ip1), "Expected IP %s, got %s", ip1, clients[0].ip) + } + + lookup.Set("testgrpc", []net.IP{ip1, ip2}) + test.DrainWakeupChannel(ch) + dnsMonitor.CheckHostnames() + waitForEvent(ctx, t, ch) + + if clients := client.GetClients(); assert.Len(clients, 2) { + assert.Equal(targetWithIp1, clients[0].Target()) + assert.True(clients[0].ip.Equal(ip1), "Expected IP %s, got %s", ip1, clients[0].ip) + assert.Equal(targetWithIp2, clients[1].Target()) + assert.True(clients[1].ip.Equal(ip2), "Expected IP %s, got %s", ip2, clients[1].ip) + } + + lookup.Set("testgrpc", []net.IP{ip2}) + test.DrainWakeupChannel(ch) + dnsMonitor.CheckHostnames() + waitForEvent(ctx, t, ch) + + if clients := client.GetClients(); assert.Len(clients, 1) { + assert.Equal(targetWithIp2, clients[0].Target()) + assert.True(clients[0].ip.Equal(ip2), "Expected IP %s, got %s", ip2, clients[0].ip) + } + }) +} + +func Test_GrpcClients_DnsDiscoveryInitialFailed(t *testing.T) { + t.Parallel() + assert := assert.New(t) + lookup := dnstest.NewMockLookup() + target := "testgrpc:12345" + ip1 := net.ParseIP("192.168.0.1") + targetWithIp1 := fmt.Sprintf("%s (%s)", target, ip1) + client, dnsMonitor := NewClientsForTest(t, target, lookup) + ch := client.GetWakeupChannelForTesting() + + testCtx, testCtxCancel := context.WithTimeout(context.Background(), testTimeout) + defer testCtxCancel() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, client.WaitForInitialized(ctx)) + + assert.Empty(client.GetClients()) + + lookup.Set("testgrpc", []net.IP{ip1}) + test.DrainWakeupChannel(ch) + dnsMonitor.CheckHostnames() + waitForEvent(testCtx, t, ch) + + if clients := client.GetClients(); assert.Len(clients, 1) { + assert.Equal(targetWithIp1, clients[0].Target()) + assert.True(clients[0].ip.Equal(ip1), "Expected IP %s, got %s", ip1, clients[0].ip) + } +} + +func Test_GrpcClients_EtcdInitial(t *testing.T) { // nolint:paralleltest + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { + _, addr1 := NewServerForTest(t) + _, addr2 := NewServerForTest(t) + + embedEtcd := etcdtest.NewServerForTest(t) + + embedEtcd.SetValue("/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) + embedEtcd.SetValue("/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) + + client, _ := NewClientsWithEtcdForTest(t, embedEtcd, nil) + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + require.NoError(t, client.WaitForInitialized(ctx)) + + clients := client.GetClients() + assert.Len(t, clients, 2, "Expected two clients, got %+v", clients) + }) +} + +func Test_GrpcClients_EtcdUpdate(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + assert := assert.New(t) + embedEtcd := etcdtest.NewServerForTest(t) + client, _ := NewClientsWithEtcdForTest(t, embedEtcd, nil) + ch := client.GetWakeupChannelForTesting() + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + assert.Empty(client.GetClients()) + + test.DrainWakeupChannel(ch) + _, addr1 := NewServerForTest(t) + embedEtcd.SetValue("/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) + waitForEvent(ctx, t, ch) + if clients := client.GetClients(); assert.Len(clients, 1) { + assert.Equal(addr1, clients[0].Target()) + } + + test.DrainWakeupChannel(ch) + _, addr2 := NewServerForTest(t) + embedEtcd.SetValue("/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) + waitForEvent(ctx, t, ch) + if clients := client.GetClients(); assert.Len(clients, 2) { + assert.Equal(addr1, clients[0].Target()) + assert.Equal(addr2, clients[1].Target()) + } + + test.DrainWakeupChannel(ch) + embedEtcd.DeleteValue("/grpctargets/one") + waitForEvent(ctx, t, ch) + if clients := client.GetClients(); assert.Len(clients, 1) { + assert.Equal(addr2, clients[0].Target()) + } + + test.DrainWakeupChannel(ch) + _, addr3 := NewServerForTest(t) + embedEtcd.SetValue("/grpctargets/two", []byte("{\"address\":\""+addr3+"\"}")) + waitForEvent(ctx, t, ch) + if clients := client.GetClients(); assert.Len(clients, 1) { + assert.Equal(addr3, clients[0].Target()) + } +} + +func Test_GrpcClients_EtcdIgnoreSelf(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + assert := assert.New(t) + embedEtcd := etcdtest.NewServerForTest(t) + client, _ := NewClientsWithEtcdForTest(t, embedEtcd, nil) + ch := client.GetWakeupChannelForTesting() + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + assert.Empty(client.GetClients()) + + test.DrainWakeupChannel(ch) + _, addr1 := NewServerForTest(t) + embedEtcd.SetValue("/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) + waitForEvent(ctx, t, ch) + if clients := client.GetClients(); assert.Len(clients, 1) { + assert.Equal(addr1, clients[0].Target()) + } + + test.DrainWakeupChannel(ch) + server2, addr2 := NewServerForTest(t) + server2.serverId = ServerId + embedEtcd.SetValue("/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) + waitForEvent(ctx, t, ch) + client.WaitForSelfCheck() + if clients := client.GetClients(); assert.Len(clients, 1) { + assert.Equal(addr1, clients[0].Target()) + } + + test.DrainWakeupChannel(ch) + embedEtcd.DeleteValue("/grpctargets/two") + waitForEvent(ctx, t, ch) + if clients := client.GetClients(); assert.Len(clients, 1) { + assert.Equal(addr1, clients[0].Target()) + } +} diff --git a/grpc_common.go b/grpc/common.go similarity index 75% rename from grpc_common.go rename to grpc/common.go index b7df93e..085b13f 100644 --- a/grpc_common.go +++ b/grpc/common.go @@ -19,25 +19,29 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package grpc import ( "context" "crypto/tls" + "errors" "fmt" - "log" "net" + "time" "github.com/dlintw/goconf" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/security" ) type reloadableCredentials struct { config *tls.Config - loader *CertificateReloader - pool *CertPoolReloader + loader *security.CertificateReloader + pool *security.CertPoolReloader } func (c *reloadableCredentials) ClientHandshake(ctx context.Context, authority string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { @@ -134,7 +138,35 @@ func (c *reloadableCredentials) Close() { } } -func NewReloadableCredentials(config *goconf.ConfigFile, server bool) (credentials.TransportCredentials, error) { +func (c *reloadableCredentials) WaitForCertificateReload(ctx context.Context, counter uint64) error { + if c.loader == nil { + return errors.New("no certificate loaded") + } + + for counter == c.loader.GetReloadCounter() { + if err := ctx.Err(); err != nil { + return err + } + time.Sleep(time.Millisecond) + } + return nil +} + +func (c *reloadableCredentials) WaitForCertPoolReload(ctx context.Context, counter uint64) error { + if c.pool == nil { + return errors.New("no certificate pool loaded") + } + + for counter == c.pool.GetReloadCounter() { + if err := ctx.Err(); err != nil { + return err + } + time.Sleep(time.Millisecond) + } + return nil +} + +func NewReloadableCredentials(logger log.Logger, config *goconf.ConfigFile, server bool) (credentials.TransportCredentials, error) { var prefix string var caPrefix string if server { @@ -150,18 +182,18 @@ func NewReloadableCredentials(config *goconf.ConfigFile, server bool) (credentia cfg := &tls.Config{ NextProtos: []string{"h2"}, } - var loader *CertificateReloader + var loader *security.CertificateReloader var err error if certificateFile != "" && keyFile != "" { - loader, err = NewCertificateReloader(certificateFile, keyFile) + loader, err = security.NewCertificateReloader(logger, certificateFile, keyFile) if err != nil { return nil, fmt.Errorf("invalid GRPC %s certificate / key in %s / %s: %w", prefix, certificateFile, keyFile, err) } } - var pool *CertPoolReloader + var pool *security.CertPoolReloader if caFile != "" { - pool, err = NewCertPoolReloader(caFile) + pool, err = security.NewCertPoolReloader(logger, caFile) if err != nil { return nil, err } @@ -173,9 +205,9 @@ func NewReloadableCredentials(config *goconf.ConfigFile, server bool) (credentia if loader == nil && pool == nil { if server { - log.Printf("WARNING: No GRPC server certificate and/or key configured, running unencrypted") + logger.Printf("WARNING: No GRPC server certificate and/or key configured, running unencrypted") } else { - log.Printf("WARNING: No GRPC CA configured, expecting unencrypted connections") + logger.Printf("WARNING: No GRPC CA configured, expecting unencrypted connections") } return insecure.NewCredentials(), nil } diff --git a/grpc_backend.proto b/grpc/common_test.go similarity index 71% rename from grpc_backend.proto rename to grpc/common_test.go index f667f12..2b3d1ed 100644 --- a/grpc_backend.proto +++ b/grpc/common_test.go @@ -19,20 +19,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ - syntax = "proto3"; +package grpc - option go_package = "github.com/strukturag/nextcloud-spreed-signaling;signaling"; +import ( + "time" +) - package signaling; - - service RpcBackend { - rpc GetSessionCount(GetSessionCountRequest) returns (GetSessionCountReply) {} - } - - message GetSessionCountRequest { - string url = 1; - } - - message GetSessionCountReply { - uint32 count = 1; - } +const ( + testTimeout = 10 * time.Second +) diff --git a/grpc/internal.pb.go b/grpc/internal.pb.go new file mode 100644 index 0000000..243f459 --- /dev/null +++ b/grpc/internal.pb.go @@ -0,0 +1,359 @@ +//* +// Standalone signaling server for the Nextcloud Spreed app. +// Copyright (C) 2022 struktur AG +// +// @author Joachim Bauch +// +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: grpc/internal.proto + +package grpc + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetServerIdRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetServerIdRequest) Reset() { + *x = GetServerIdRequest{} + mi := &file_grpc_internal_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetServerIdRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetServerIdRequest) ProtoMessage() {} + +func (x *GetServerIdRequest) ProtoReflect() protoreflect.Message { + mi := &file_grpc_internal_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetServerIdRequest.ProtoReflect.Descriptor instead. +func (*GetServerIdRequest) Descriptor() ([]byte, []int) { + return file_grpc_internal_proto_rawDescGZIP(), []int{0} +} + +type GetServerIdReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServerId string `protobuf:"bytes,1,opt,name=serverId,proto3" json:"serverId,omitempty"` + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetServerIdReply) Reset() { + *x = GetServerIdReply{} + mi := &file_grpc_internal_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetServerIdReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetServerIdReply) ProtoMessage() {} + +func (x *GetServerIdReply) ProtoReflect() protoreflect.Message { + mi := &file_grpc_internal_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetServerIdReply.ProtoReflect.Descriptor instead. +func (*GetServerIdReply) Descriptor() ([]byte, []int) { + return file_grpc_internal_proto_rawDescGZIP(), []int{1} +} + +func (x *GetServerIdReply) GetServerId() string { + if x != nil { + return x.ServerId + } + return "" +} + +func (x *GetServerIdReply) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +type GetTransientDataRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomId string `protobuf:"bytes,1,opt,name=roomId,proto3" json:"roomId,omitempty"` + BackendUrls []string `protobuf:"bytes,2,rep,name=backendUrls,proto3" json:"backendUrls,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTransientDataRequest) Reset() { + *x = GetTransientDataRequest{} + mi := &file_grpc_internal_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTransientDataRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTransientDataRequest) ProtoMessage() {} + +func (x *GetTransientDataRequest) ProtoReflect() protoreflect.Message { + mi := &file_grpc_internal_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTransientDataRequest.ProtoReflect.Descriptor instead. +func (*GetTransientDataRequest) Descriptor() ([]byte, []int) { + return file_grpc_internal_proto_rawDescGZIP(), []int{2} +} + +func (x *GetTransientDataRequest) GetRoomId() string { + if x != nil { + return x.RoomId + } + return "" +} + +func (x *GetTransientDataRequest) GetBackendUrls() []string { + if x != nil { + return x.BackendUrls + } + return nil +} + +type GrpcTransientDataEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value []byte `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + Expires int64 `protobuf:"varint,2,opt,name=expires,proto3" json:"expires,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GrpcTransientDataEntry) Reset() { + *x = GrpcTransientDataEntry{} + mi := &file_grpc_internal_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GrpcTransientDataEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GrpcTransientDataEntry) ProtoMessage() {} + +func (x *GrpcTransientDataEntry) ProtoReflect() protoreflect.Message { + mi := &file_grpc_internal_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GrpcTransientDataEntry.ProtoReflect.Descriptor instead. +func (*GrpcTransientDataEntry) Descriptor() ([]byte, []int) { + return file_grpc_internal_proto_rawDescGZIP(), []int{3} +} + +func (x *GrpcTransientDataEntry) GetValue() []byte { + if x != nil { + return x.Value + } + return nil +} + +func (x *GrpcTransientDataEntry) GetExpires() int64 { + if x != nil { + return x.Expires + } + return 0 +} + +type GetTransientDataReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries map[string]*GrpcTransientDataEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTransientDataReply) Reset() { + *x = GetTransientDataReply{} + mi := &file_grpc_internal_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTransientDataReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTransientDataReply) ProtoMessage() {} + +func (x *GetTransientDataReply) ProtoReflect() protoreflect.Message { + mi := &file_grpc_internal_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTransientDataReply.ProtoReflect.Descriptor instead. +func (*GetTransientDataReply) Descriptor() ([]byte, []int) { + return file_grpc_internal_proto_rawDescGZIP(), []int{4} +} + +func (x *GetTransientDataReply) GetEntries() map[string]*GrpcTransientDataEntry { + if x != nil { + return x.Entries + } + return nil +} + +var File_grpc_internal_proto protoreflect.FileDescriptor + +const file_grpc_internal_proto_rawDesc = "" + + "\n" + + "\x13grpc/internal.proto\x12\x04grpc\"\x14\n" + + "\x12GetServerIdRequest\"H\n" + + "\x10GetServerIdReply\x12\x1a\n" + + "\bserverId\x18\x01 \x01(\tR\bserverId\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\"S\n" + + "\x17GetTransientDataRequest\x12\x16\n" + + "\x06roomId\x18\x01 \x01(\tR\x06roomId\x12 \n" + + "\vbackendUrls\x18\x02 \x03(\tR\vbackendUrls\"H\n" + + "\x16GrpcTransientDataEntry\x12\x14\n" + + "\x05value\x18\x01 \x01(\fR\x05value\x12\x18\n" + + "\aexpires\x18\x02 \x01(\x03R\aexpires\"\xb5\x01\n" + + "\x15GetTransientDataReply\x12B\n" + + "\aentries\x18\x01 \x03(\v2(.grpc.GetTransientDataReply.EntriesEntryR\aentries\x1aX\n" + + "\fEntriesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + + "\x05value\x18\x02 \x01(\v2\x1c.grpc.GrpcTransientDataEntryR\x05value:\x028\x012\xa2\x01\n" + + "\vRpcInternal\x12A\n" + + "\vGetServerId\x12\x18.grpc.GetServerIdRequest\x1a\x16.grpc.GetServerIdReply\"\x00\x12P\n" + + "\x10GetTransientData\x12\x1d.grpc.GetTransientDataRequest\x1a\x1b.grpc.GetTransientDataReply\"\x00B7Z5github.com/strukturag/nextcloud-spreed-signaling/grpcb\x06proto3" + +var ( + file_grpc_internal_proto_rawDescOnce sync.Once + file_grpc_internal_proto_rawDescData []byte +) + +func file_grpc_internal_proto_rawDescGZIP() []byte { + file_grpc_internal_proto_rawDescOnce.Do(func() { + file_grpc_internal_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_grpc_internal_proto_rawDesc), len(file_grpc_internal_proto_rawDesc))) + }) + return file_grpc_internal_proto_rawDescData +} + +var file_grpc_internal_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_grpc_internal_proto_goTypes = []any{ + (*GetServerIdRequest)(nil), // 0: grpc.GetServerIdRequest + (*GetServerIdReply)(nil), // 1: grpc.GetServerIdReply + (*GetTransientDataRequest)(nil), // 2: grpc.GetTransientDataRequest + (*GrpcTransientDataEntry)(nil), // 3: grpc.GrpcTransientDataEntry + (*GetTransientDataReply)(nil), // 4: grpc.GetTransientDataReply + nil, // 5: grpc.GetTransientDataReply.EntriesEntry +} +var file_grpc_internal_proto_depIdxs = []int32{ + 5, // 0: grpc.GetTransientDataReply.entries:type_name -> grpc.GetTransientDataReply.EntriesEntry + 3, // 1: grpc.GetTransientDataReply.EntriesEntry.value:type_name -> grpc.GrpcTransientDataEntry + 0, // 2: grpc.RpcInternal.GetServerId:input_type -> grpc.GetServerIdRequest + 2, // 3: grpc.RpcInternal.GetTransientData:input_type -> grpc.GetTransientDataRequest + 1, // 4: grpc.RpcInternal.GetServerId:output_type -> grpc.GetServerIdReply + 4, // 5: grpc.RpcInternal.GetTransientData:output_type -> grpc.GetTransientDataReply + 4, // [4:6] is the sub-list for method output_type + 2, // [2:4] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_grpc_internal_proto_init() } +func file_grpc_internal_proto_init() { + if File_grpc_internal_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_grpc_internal_proto_rawDesc), len(file_grpc_internal_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_grpc_internal_proto_goTypes, + DependencyIndexes: file_grpc_internal_proto_depIdxs, + MessageInfos: file_grpc_internal_proto_msgTypes, + }.Build() + File_grpc_internal_proto = out.File + file_grpc_internal_proto_goTypes = nil + file_grpc_internal_proto_depIdxs = nil +} diff --git a/grpc/internal.proto b/grpc/internal.proto new file mode 100644 index 0000000..3a72483 --- /dev/null +++ b/grpc/internal.proto @@ -0,0 +1,53 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2022 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +syntax = "proto3"; + +option go_package = "github.com/strukturag/nextcloud-spreed-signaling/grpc"; + +package grpc; + +service RpcInternal { + rpc GetServerId(GetServerIdRequest) returns (GetServerIdReply) {} + rpc GetTransientData(GetTransientDataRequest) returns (GetTransientDataReply) {} +} + +message GetServerIdRequest { +} + +message GetServerIdReply { + string serverId = 1; + string version = 2; +} + +message GetTransientDataRequest { + string roomId = 1; + repeated string backendUrls = 2; +} + +message GrpcTransientDataEntry { + bytes value = 1; + int64 expires = 2; +} + +message GetTransientDataReply { + map entries = 1; +} diff --git a/grpc_internal_grpc.pb.go b/grpc/internal_grpc.pb.go similarity index 70% rename from grpc_internal_grpc.pb.go rename to grpc/internal_grpc.pb.go index 577ef0d..23d7f42 100644 --- a/grpc_internal_grpc.pb.go +++ b/grpc/internal_grpc.pb.go @@ -20,9 +20,9 @@ // along with this program. If not, see . // Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// source: grpc_internal.proto +// source: grpc/internal.proto -package signaling +package grpc import ( context "context" @@ -37,7 +37,8 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - RpcInternal_GetServerId_FullMethodName = "/signaling.RpcInternal/GetServerId" + RpcInternal_GetServerId_FullMethodName = "/grpc.RpcInternal/GetServerId" + RpcInternal_GetTransientData_FullMethodName = "/grpc.RpcInternal/GetTransientData" ) // RpcInternalClient is the client API for RpcInternal service. @@ -45,6 +46,7 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type RpcInternalClient interface { GetServerId(ctx context.Context, in *GetServerIdRequest, opts ...grpc.CallOption) (*GetServerIdReply, error) + GetTransientData(ctx context.Context, in *GetTransientDataRequest, opts ...grpc.CallOption) (*GetTransientDataReply, error) } type rpcInternalClient struct { @@ -65,11 +67,22 @@ func (c *rpcInternalClient) GetServerId(ctx context.Context, in *GetServerIdRequ return out, nil } +func (c *rpcInternalClient) GetTransientData(ctx context.Context, in *GetTransientDataRequest, opts ...grpc.CallOption) (*GetTransientDataReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetTransientDataReply) + err := c.cc.Invoke(ctx, RpcInternal_GetTransientData_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // RpcInternalServer is the server API for RpcInternal service. // All implementations must embed UnimplementedRpcInternalServer // for forward compatibility. type RpcInternalServer interface { GetServerId(context.Context, *GetServerIdRequest) (*GetServerIdReply, error) + GetTransientData(context.Context, *GetTransientDataRequest) (*GetTransientDataReply, error) mustEmbedUnimplementedRpcInternalServer() } @@ -81,7 +94,10 @@ type RpcInternalServer interface { type UnimplementedRpcInternalServer struct{} func (UnimplementedRpcInternalServer) GetServerId(context.Context, *GetServerIdRequest) (*GetServerIdReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetServerId not implemented") + return nil, status.Error(codes.Unimplemented, "method GetServerId not implemented") +} +func (UnimplementedRpcInternalServer) GetTransientData(context.Context, *GetTransientDataRequest) (*GetTransientDataReply, error) { + return nil, status.Error(codes.Unimplemented, "method GetTransientData not implemented") } func (UnimplementedRpcInternalServer) mustEmbedUnimplementedRpcInternalServer() {} func (UnimplementedRpcInternalServer) testEmbeddedByValue() {} @@ -94,7 +110,7 @@ type UnsafeRpcInternalServer interface { } func RegisterRpcInternalServer(s grpc.ServiceRegistrar, srv RpcInternalServer) { - // If the following call pancis, it indicates UnimplementedRpcInternalServer was + // If the following call panics, it indicates UnimplementedRpcInternalServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -122,18 +138,40 @@ func _RpcInternal_GetServerId_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _RpcInternal_GetTransientData_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTransientDataRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RpcInternalServer).GetTransientData(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RpcInternal_GetTransientData_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RpcInternalServer).GetTransientData(ctx, req.(*GetTransientDataRequest)) + } + return interceptor(ctx, in, info, handler) +} + // RpcInternal_ServiceDesc is the grpc.ServiceDesc for RpcInternal service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var RpcInternal_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "signaling.RpcInternal", + ServiceName: "grpc.RpcInternal", HandlerType: (*RpcInternalServer)(nil), Methods: []grpc.MethodDesc{ { MethodName: "GetServerId", Handler: _RpcInternal_GetServerId_Handler, }, + { + MethodName: "GetTransientData", + Handler: _RpcInternal_GetTransientData_Handler, + }, }, Streams: []grpc.StreamDesc{}, - Metadata: "grpc_internal.proto", + Metadata: "grpc/internal.proto", } diff --git a/grpc/server.go b/grpc/server.go new file mode 100644 index 0000000..1a99f8c --- /dev/null +++ b/grpc/server.go @@ -0,0 +1,338 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2022 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package grpc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + + "github.com/dlintw/goconf" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + status "google.golang.org/grpc/status" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +func init() { + RegisterServerStats() +} + +type ServerHub interface { + GetSessionIdByResumeId(resumeId api.PrivateSessionId) api.PublicSessionId + GetSessionIdByRoomSessionId(roomSessionId api.RoomSessionId) (api.PublicSessionId, error) + IsSessionIdInCall(sessionId api.PublicSessionId, roomId string, backendUrl string) (bool, bool) + + DisconnectSessionByRoomSessionId(sessionId api.PublicSessionId, roomSessionId api.RoomSessionId, reason string) + + GetBackend(u *url.URL) *talk.Backend + GetInternalSessions(roomId string, backend *talk.Backend) ([]*InternalSessionData, []*VirtualSessionData, bool) + GetTransientEntries(roomId string, backend *talk.Backend) (api.TransientDataEntries, bool) + GetPublisherIdForSessionId(ctx context.Context, sessionId api.PublicSessionId, streamType sfu.StreamType) (*GetPublisherIdReply, error) + + ProxySession(request RpcSessions_ProxySessionServer) error +} + +type Server struct { + UnimplementedRpcBackendServer + UnimplementedRpcInternalServer + UnimplementedRpcMcuServer + UnimplementedRpcSessionsServer + + logger log.Logger + version string + creds credentials.TransportCredentials + conn *grpc.Server + listener net.Listener + serverId string // can be overwritten from tests + + hub ServerHub +} + +func NewServer(ctx context.Context, cfg *goconf.ConfigFile, version string) (*Server, error) { + var listener net.Listener + if addr, _ := config.GetStringOptionWithEnv(cfg, "grpc", "listen"); addr != "" { + var err error + listener, err = net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("could not create GRPC listener %s: %w", addr, err) + } + } + + logger := log.LoggerFromContext(ctx) + creds, err := NewReloadableCredentials(logger, cfg, true) + if err != nil { + return nil, err + } + + conn := grpc.NewServer(grpc.Creds(creds)) + result := &Server{ + logger: logger, + version: version, + creds: creds, + conn: conn, + listener: listener, + serverId: ServerId, + } + RegisterRpcBackendServer(conn, result) + RegisterRpcInternalServer(conn, result) + RegisterRpcSessionsServer(conn, result) + RegisterRpcMcuServer(conn, result) + return result, nil +} + +func (s *Server) SetHub(hub ServerHub) { + s.hub = hub +} + +func (s *Server) SetServerId(serverId string) { + s.serverId = serverId +} + +func (s *Server) Run() error { + if s.listener == nil { + return nil + } + + return s.conn.Serve(s.listener) +} + +type SimpleCloser interface { + Close() +} + +func (s *Server) Close() { + s.conn.GracefulStop() + if cr, ok := s.creds.(SimpleCloser); ok { + cr.Close() + } +} + +func (s *Server) CloseUnclean() { + s.conn.Stop() +} + +func (s *Server) LookupResumeId(ctx context.Context, request *LookupResumeIdRequest) (*LookupResumeIdReply, error) { + statsGrpcServerCalls.WithLabelValues("LookupResumeId").Inc() + // TODO: Remove debug logging + s.logger.Printf("Lookup session for resume id %s", request.ResumeId) + sessionId := s.hub.GetSessionIdByResumeId(api.PrivateSessionId(request.ResumeId)) + if sessionId == "" { + return nil, status.Error(codes.NotFound, "no such room session id") + } + + return &LookupResumeIdReply{ + SessionId: string(sessionId), + }, nil +} + +func (s *Server) LookupSessionId(ctx context.Context, request *LookupSessionIdRequest) (*LookupSessionIdReply, error) { + statsGrpcServerCalls.WithLabelValues("LookupSessionId").Inc() + // TODO: Remove debug logging + s.logger.Printf("Lookup session id for room session id %s", request.RoomSessionId) + sid, err := s.hub.GetSessionIdByRoomSessionId(api.RoomSessionId(request.RoomSessionId)) + if errors.Is(err, ErrNoSuchRoomSession) { + return nil, status.Error(codes.NotFound, "no such room session id") + } else if err != nil { + return nil, err + } + + if sid != "" && request.DisconnectReason != "" { + s.hub.DisconnectSessionByRoomSessionId(sid, api.RoomSessionId(request.RoomSessionId), request.DisconnectReason) + } + return &LookupSessionIdReply{ + SessionId: string(sid), + }, nil +} + +func (s *Server) IsSessionInCall(ctx context.Context, request *IsSessionInCallRequest) (*IsSessionInCallReply, error) { + statsGrpcServerCalls.WithLabelValues("IsSessionInCall").Inc() + // TODO: Remove debug logging + s.logger.Printf("Check if session %s is in call %s on %s", request.SessionId, request.RoomId, request.BackendUrl) + + found, inCall := s.hub.IsSessionIdInCall(api.PublicSessionId(request.SessionId), request.GetRoomId(), request.GetBackendUrl()) + if !found { + return nil, status.Error(codes.NotFound, "no such session id") + } + + result := &IsSessionInCallReply{ + InCall: inCall, + } + return result, nil +} + +func (s *Server) GetInternalSessions(ctx context.Context, request *GetInternalSessionsRequest) (*GetInternalSessionsReply, error) { + statsGrpcServerCalls.WithLabelValues("GetInternalSessions").Inc() + // TODO: Remove debug logging + s.logger.Printf("Get internal sessions from %s on %v (fallback %s)", request.RoomId, request.BackendUrls, request.BackendUrl) // nolint + + var backendUrls []string + if len(request.BackendUrls) > 0 { + backendUrls = request.BackendUrls + } else if request.BackendUrl != "" { // nolint + backendUrls = append(backendUrls, request.BackendUrl) // nolint + } else { + // Only compat backend. + backendUrls = []string{""} + } + + result := &GetInternalSessionsReply{} + processed := make(map[string]bool) + for _, bu := range backendUrls { + var parsed *url.URL + if bu != "" { + var err error + parsed, err = url.Parse(bu) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "invalid url") + } + } + + backend := s.hub.GetBackend(parsed) + if backend == nil { + return nil, status.Error(codes.NotFound, "no such backend") + } + + // Only process each backend once. + if processed[backend.Id()] { + continue + } + processed[backend.Id()] = true + + internalSessions, virtualSessions, found := s.hub.GetInternalSessions(request.RoomId, backend) + if !found { + return nil, status.Error(codes.NotFound, "no such room") + } + + result.InternalSessions = append(result.InternalSessions, internalSessions...) + result.VirtualSessions = append(result.VirtualSessions, virtualSessions...) + } + + return result, nil +} + +func (s *Server) GetPublisherId(ctx context.Context, request *GetPublisherIdRequest) (*GetPublisherIdReply, error) { + statsGrpcServerCalls.WithLabelValues("GetPublisherId").Inc() + // TODO: Remove debug logging + s.logger.Printf("Get %s publisher id for session %s", request.StreamType, request.SessionId) + + return s.hub.GetPublisherIdForSessionId(ctx, api.PublicSessionId(request.SessionId), sfu.StreamType(request.StreamType)) +} + +func (s *Server) GetServerId(ctx context.Context, request *GetServerIdRequest) (*GetServerIdReply, error) { + statsGrpcServerCalls.WithLabelValues("GetServerId").Inc() + return &GetServerIdReply{ + ServerId: s.serverId, + Version: s.version, + }, nil +} + +func (s *Server) GetTransientData(ctx context.Context, request *GetTransientDataRequest) (*GetTransientDataReply, error) { + statsGrpcServerCalls.WithLabelValues("GetTransientData").Inc() + + backendUrls := request.BackendUrls + if len(backendUrls) == 0 { + // Only compat backend. + backendUrls = []string{""} + } + + result := &GetTransientDataReply{} + processed := make(map[string]bool) + for _, bu := range backendUrls { + var parsed *url.URL + if bu != "" { + var err error + parsed, err = url.Parse(bu) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "invalid url") + } + } + + backend := s.hub.GetBackend(parsed) + if backend == nil { + return nil, status.Error(codes.NotFound, "no such backend") + } + + // Only process each backend once. + if processed[backend.Id()] { + continue + } + processed[backend.Id()] = true + + entries, found := s.hub.GetTransientEntries(request.RoomId, backend) + if !found { + return nil, status.Error(codes.NotFound, "no such room") + } else if len(entries) == 0 { + return nil, status.Error(codes.NotFound, "room has no transient data") + } + + if result.Entries == nil { + result.Entries = make(map[string]*GrpcTransientDataEntry) + } + for k, v := range entries { + e := &GrpcTransientDataEntry{} + var err error + if e.Value, err = json.Marshal(v.Value); err != nil { + return nil, status.Errorf(codes.Internal, "error marshalling data: %s", err) + } + if !v.Expires.IsZero() { + e.Expires = v.Expires.UnixMicro() + } + result.Entries[k] = e + } + } + + return result, nil +} + +func (s *Server) GetSessionCount(ctx context.Context, request *GetSessionCountRequest) (*GetSessionCountReply, error) { + statsGrpcServerCalls.WithLabelValues("SessionCount").Inc() + + u, err := url.Parse(request.Url) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "invalid url") + } + + backend := s.hub.GetBackend(u) + if backend == nil { + return nil, status.Error(codes.NotFound, "no such backend") + } + + return &GetSessionCountReply{ + Count: uint32(backend.Len()), + }, nil +} + +func (s *Server) ProxySession(request RpcSessions_ProxySessionServer) error { + statsGrpcServerCalls.WithLabelValues("ProxySession").Inc() + + return s.hub.ProxySession(request) +} diff --git a/grpc/server_id.go b/grpc/server_id.go new file mode 100644 index 0000000..216ea6c --- /dev/null +++ b/grpc/server_id.go @@ -0,0 +1,45 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package grpc + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" +) + +var ( + ServerId string +) + +func init() { + hostname, err := os.Hostname() + if err != nil { + hostname = internal.RandomString(8) + } + md := sha256.New() + fmt.Fprintf(md, "%s-%s-%d", internal.RandomString(32), hostname, os.Getpid()) + ServerId = hex.EncodeToString(md.Sum(nil)) +} diff --git a/grpc/server_stats_prometheus.go b/grpc/server_stats_prometheus.go new file mode 100644 index 0000000..2602fd9 --- /dev/null +++ b/grpc/server_stats_prometheus.go @@ -0,0 +1,45 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2022 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package grpc + +import ( + "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" +) + +var ( + statsGrpcServerCalls = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "grpc", + Name: "server_calls_total", + Help: "The total number of GRPC server calls", + }, []string{"method"}) + + grpcServerStats = []prometheus.Collector{ + statsGrpcServerCalls, + } +) + +func RegisterServerStats() { + metrics.RegisterAll(grpcServerStats...) +} diff --git a/grpc/server_test.go b/grpc/server_test.go new file mode 100644 index 0000000..ebec287 --- /dev/null +++ b/grpc/server_test.go @@ -0,0 +1,854 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2022 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package grpc + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "net" + "net/url" + "path" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + status "google.golang.org/grpc/status" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" +) + +type CertificateReloadWaiter interface { + WaitForCertificateReload(ctx context.Context, counter uint64) error +} + +func (s *Server) WaitForCertificateReload(ctx context.Context, counter uint64) error { + c, ok := s.creds.(CertificateReloadWaiter) + if !ok { + return errors.New("no reloadable credentials found") + } + + return c.WaitForCertificateReload(ctx, counter) +} + +type CertPoolReloadWaiter interface { + WaitForCertPoolReload(ctx context.Context, counter uint64) error +} + +func (s *Server) WaitForCertPoolReload(ctx context.Context, counter uint64) error { + c, ok := s.creds.(CertPoolReloadWaiter) + if !ok { + return errors.New("no reloadable credentials found") + } + + return c.WaitForCertPoolReload(ctx, counter) +} + +func NewServerForTestWithConfig(t *testing.T, config *goconf.ConfigFile) (server *Server, addr string) { + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + for port := 50000; port < 50100; port++ { + addr = net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + config.AddOption("grpc", "listen", addr) + var err error + server, err = NewServer(ctx, config, "0.0.0") + if test.IsErrorAddressAlreadyInUse(err) { + continue + } + + require.NoError(t, err) + break + } + + require.NotNil(t, server, "could not find free port") + + // Don't match with own server id by default. + server.SetServerId("dont-match") + + go func() { + assert.NoError(t, server.Run(), "could not start GRPC server") + }() + + t.Cleanup(func() { + server.Close() + }) + return server, addr +} + +func NewServerForTest(t *testing.T) (server *Server, addr string) { + config := goconf.NewConfigFile() + return NewServerForTestWithConfig(t, config) +} + +func TestServer_ReloadCerts(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + + org1 := "Testing certificate" + cert1 := internal.GenerateSelfSignedCertificateForTesting(t, org1, key) + + dir := t.TempDir() + privkeyFile := path.Join(dir, "privkey.pem") + pubkeyFile := path.Join(dir, "pubkey.pem") + certFile := path.Join(dir, "cert.pem") + require.NoError(internal.WritePrivateKey(key, privkeyFile)) + require.NoError(internal.WritePublicKey(&key.PublicKey, pubkeyFile)) + require.NoError(internal.WriteCertificate(cert1, certFile)) + + config := goconf.NewConfigFile() + config.AddOption("grpc", "servercertificate", certFile) + config.AddOption("grpc", "serverkey", privkeyFile) + + server, addr := NewServerForTestWithConfig(t, config) + + cp1 := x509.NewCertPool() + cp1.AddCert(cert1) + + cfg1 := &tls.Config{ + RootCAs: cp1, + } + conn1, err := tls.Dial("tcp", addr, cfg1) + require.NoError(err) + defer conn1.Close() // nolint + state1 := conn1.ConnectionState() + if certs := state1.PeerCertificates; assert.NotEmpty(certs) { + if assert.NotEmpty(certs[0].Subject.Organization) { + assert.Equal(org1, certs[0].Subject.Organization[0]) + } + } + + org2 := "Updated certificate" + cert2 := internal.GenerateSelfSignedCertificateForTesting(t, org2, key) + internal.ReplaceCertificate(t, certFile, cert2) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + require.NoError(server.WaitForCertificateReload(ctx, 0)) + + cp2 := x509.NewCertPool() + cp2.AddCert(cert2) + + cfg2 := &tls.Config{ + RootCAs: cp2, + } + conn2, err := tls.Dial("tcp", addr, cfg2) + require.NoError(err) + defer conn2.Close() // nolint + state2 := conn2.ConnectionState() + if certs := state2.PeerCertificates; assert.NotEmpty(certs) { + if assert.NotEmpty(certs[0].Subject.Organization) { + assert.Equal(org2, certs[0].Subject.Organization[0]) + } + } +} + +func TestServer_ReloadCA(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + require := require.New(t) + serverKey, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + clientKey, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + + serverCert := internal.GenerateSelfSignedCertificateForTesting(t, "Server cert", serverKey) + org1 := "Testing client" + clientCert1 := internal.GenerateSelfSignedCertificateForTesting(t, org1, clientKey) + + dir := t.TempDir() + privkeyFile := path.Join(dir, "privkey.pem") + pubkeyFile := path.Join(dir, "pubkey.pem") + certFile := path.Join(dir, "cert.pem") + caFile := path.Join(dir, "ca.pem") + require.NoError(internal.WritePrivateKey(serverKey, privkeyFile)) + require.NoError(internal.WritePublicKey(&serverKey.PublicKey, pubkeyFile)) + require.NoError(internal.WriteCertificate(serverCert, certFile)) + require.NoError(internal.WriteCertificate(clientCert1, caFile)) + + config := goconf.NewConfigFile() + config.AddOption("grpc", "servercertificate", certFile) + config.AddOption("grpc", "serverkey", privkeyFile) + config.AddOption("grpc", "clientca", caFile) + + server, addr := NewServerForTestWithConfig(t, config) + + pool := x509.NewCertPool() + pool.AddCert(serverCert) + + pair1, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: clientCert1.Raw, + }), pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(clientKey), + })) + require.NoError(err) + + cfg1 := &tls.Config{ + RootCAs: pool, + Certificates: []tls.Certificate{pair1}, + } + client1, err := NewClient(logger, addr, nil, grpc.WithTransportCredentials(credentials.NewTLS(cfg1))) + require.NoError(err) + defer client1.Close() // nolint + + ctx1, cancel1 := context.WithTimeout(context.Background(), time.Second) + defer cancel1() + + _, _, err = client1.GetServerId(ctx1) + require.NoError(err) + + org2 := "Updated client" + clientCert2 := internal.GenerateSelfSignedCertificateForTesting(t, org2, clientKey) + internal.ReplaceCertificate(t, caFile, clientCert2) + + require.NoError(server.WaitForCertPoolReload(ctx1, 0)) + + pair2, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: clientCert2.Raw, + }), pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(clientKey), + })) + require.NoError(err) + + cfg2 := &tls.Config{ + RootCAs: pool, + Certificates: []tls.Certificate{pair2}, + } + client2, err := NewClient(logger, addr, nil, grpc.WithTransportCredentials(credentials.NewTLS(cfg2))) + require.NoError(err) + defer client2.Close() // nolint + + ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second) + defer cancel2() + + // This will fail if the CA certificate has not been reloaded by the server. + _, _, err = client2.GetServerId(ctx2) + require.NoError(err) +} + +func TestClients_Encryption(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { + require := require.New(t) + serverKey, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + clientKey, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + + serverCert := internal.GenerateSelfSignedCertificateForTesting(t, "Server cert", serverKey) + clientCert := internal.GenerateSelfSignedCertificateForTesting(t, "Testing client", clientKey) + + dir := t.TempDir() + serverPrivkeyFile := path.Join(dir, "server-privkey.pem") + serverPubkeyFile := path.Join(dir, "server-pubkey.pem") + serverCertFile := path.Join(dir, "server-cert.pem") + require.NoError(internal.WritePrivateKey(serverKey, serverPrivkeyFile)) + require.NoError(internal.WritePublicKey(&serverKey.PublicKey, serverPubkeyFile)) + require.NoError(internal.WriteCertificate(serverCert, serverCertFile)) + clientPrivkeyFile := path.Join(dir, "client-privkey.pem") + clientPubkeyFile := path.Join(dir, "client-pubkey.pem") + clientCertFile := path.Join(dir, "client-cert.pem") + require.NoError(internal.WritePrivateKey(clientKey, clientPrivkeyFile)) + require.NoError(internal.WritePublicKey(&clientKey.PublicKey, clientPubkeyFile)) + require.NoError(internal.WriteCertificate(clientCert, clientCertFile)) + + serverConfig := goconf.NewConfigFile() + serverConfig.AddOption("grpc", "servercertificate", serverCertFile) + serverConfig.AddOption("grpc", "serverkey", serverPrivkeyFile) + serverConfig.AddOption("grpc", "clientca", clientCertFile) + _, addr := NewServerForTestWithConfig(t, serverConfig) + + clientConfig := goconf.NewConfigFile() + clientConfig.AddOption("grpc", "targets", addr) + clientConfig.AddOption("grpc", "clientcertificate", clientCertFile) + clientConfig.AddOption("grpc", "clientkey", clientPrivkeyFile) + clientConfig.AddOption("grpc", "serverca", serverCertFile) + clients, _ := NewClientsForTestWithConfig(t, clientConfig, nil, nil) + + ctx, cancel1 := context.WithTimeout(context.Background(), time.Second) + defer cancel1() + + require.NoError(clients.WaitForInitialized(ctx)) + + for _, client := range clients.GetClients() { + _, _, err := client.GetServerId(ctx) + require.NoError(err) + } + }) +} + +type disconnectInfo struct { + sessionId api.PublicSessionId + roomSessionId api.RoomSessionId + reason string +} + +type testServerHub struct { + t *testing.T + backend *talk.Backend + + disconnected atomic.Pointer[disconnectInfo] +} + +func newTestServerHub(t *testing.T) *testServerHub { + t.Helper() + logger := logtest.NewLoggerForTest(t) + + cfg := goconf.NewConfigFile() + cfg.AddOption(testBackendId, "secret", "not-so-secret") + cfg.AddOption(testBackendId, "sessionlimit", "10") + backend, err := talk.NewBackendFromConfig(logger, testBackendId, cfg, "foo") + require.NoError(t, err) + + u, err := url.Parse(testBackendUrl) + require.NoError(t, err) + backend.AddUrl(u) + + return &testServerHub{ + t: t, + backend: backend, + } +} + +const ( + testResumeId = "test-resume-id" + testSessionId = "test-session-id" + testRoomSessionId = "test-room-session-id" + testInternalSessionId = "test-internal-session-id" + testVirtualSessionId = "test-virtual-session-id" + testInternalInCallFlags = 2 + testVirtualInCallFlags = 3 + testBackendId = "backend-1" + testBackendUrl = "https://server.domain.invalid" + testRoomId = "test-room-id" + testStreamType = sfu.StreamTypeVideo + testProxyUrl = "https://proxy.domain.invalid" + testIp = "1.2.3.4" + testConnectToken = "test-connection-token" + testPublisherToken = "test-publisher-token" + testAddr = "2.3.4.5" + testCountry = geoip.Country("DE") + testAgent = "test-agent" +) + +var ( + testFeatures = []string{"bar", "foo"} + testExpires = time.Now().Add(time.Minute).Truncate(time.Millisecond) + testMessage = []byte("hello world!") +) + +func (h *testServerHub) GetSessionIdByResumeId(resumeId api.PrivateSessionId) api.PublicSessionId { + if resumeId == testResumeId { + return testSessionId + } + + return "" +} + +func (h *testServerHub) GetSessionIdByRoomSessionId(roomSessionId api.RoomSessionId) (api.PublicSessionId, error) { + if roomSessionId == testRoomSessionId { + return testSessionId, nil + } + + return "", ErrNoSuchRoomSession +} + +func (h *testServerHub) IsSessionIdInCall(sessionId api.PublicSessionId, roomId string, backendUrl string) (bool, bool) { + if roomId == testRoomId && backendUrl == testBackendUrl { + return sessionId == testSessionId, true + } + + return false, false +} + +func (h *testServerHub) DisconnectSessionByRoomSessionId(sessionId api.PublicSessionId, roomSessionId api.RoomSessionId, reason string) { + h.t.Helper() + prev := h.disconnected.Swap(&disconnectInfo{ + sessionId: sessionId, + roomSessionId: roomSessionId, + reason: reason, + }) + assert.Nil(h.t, prev, "duplicate call") +} + +func (h *testServerHub) GetBackend(u *url.URL) *talk.Backend { + if u == nil { + // No compat backend. + return nil + } else if u.String() == testBackendUrl { + return h.backend + } + + return nil +} + +func (h *testServerHub) GetInternalSessions(roomId string, backend *talk.Backend) ([]*InternalSessionData, []*VirtualSessionData, bool) { + if roomId == testRoomId && backend == h.backend { + return []*InternalSessionData{ + { + SessionId: testInternalSessionId, + InCall: testInternalInCallFlags, + Features: testFeatures, + }, + }, []*VirtualSessionData{ + { + SessionId: testVirtualSessionId, + InCall: testVirtualInCallFlags, + }, + }, true + } + + return nil, nil, false +} + +func (h *testServerHub) GetTransientEntries(roomId string, backend *talk.Backend) (api.TransientDataEntries, bool) { + if roomId == testRoomId && backend == h.backend { + return api.TransientDataEntries{ + "foo": api.NewTransientDataEntryWithExpires("bar", testExpires), + "bar": api.NewTransientDataEntry(123, 0), + }, true + } + + return nil, false +} + +func (h *testServerHub) GetPublisherIdForSessionId(ctx context.Context, sessionId api.PublicSessionId, streamType sfu.StreamType) (*GetPublisherIdReply, error) { + if sessionId == testSessionId { + if streamType != testStreamType { + return nil, status.Error(codes.NotFound, "no such publisher") + } + + return &GetPublisherIdReply{ + PublisherId: testSessionId, + ProxyUrl: testProxyUrl, + Ip: testIp, + ConnectToken: testConnectToken, + PublisherToken: testPublisherToken, + }, nil + } + + return nil, status.Error(codes.NotFound, "no such session") +} + +func getMetadata(t *testing.T, md metadata.MD, key string) string { + t.Helper() + if values := md.Get(key); len(values) > 0 { + return values[0] + } + + return "" +} + +func (h *testServerHub) ProxySession(request RpcSessions_ProxySessionServer) error { + h.t.Helper() + if md, found := metadata.FromIncomingContext(request.Context()); assert.True(h.t, found) { + if getMetadata(h.t, md, "sessionId") != testSessionId { + return status.Error(codes.InvalidArgument, "unknown session id") + } + + assert.Equal(h.t, testSessionId, getMetadata(h.t, md, "sessionId")) + assert.Equal(h.t, testAddr, getMetadata(h.t, md, "remoteAddr")) + assert.EqualValues(h.t, testCountry, getMetadata(h.t, md, "country")) + assert.Equal(h.t, testAgent, getMetadata(h.t, md, "userAgent")) + } + + assert.NoError(h.t, request.Send(&ServerSessionMessage{ + Message: testMessage, + })) + + return nil +} + +func TestServer_GetSessionIdByResumeId(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + hub := newTestServerHub(t) + + server, addr := NewServerForTest(t) + server.SetHub(hub) + clients, _ := NewClientsForTest(t, addr, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(clients.WaitForInitialized(ctx)) + + for _, client := range clients.GetClients() { + reply, err := client.LookupResumeId(ctx, "") + assert.ErrorIs(err, ErrNoSuchResumeId, "expected unknown resume id, got %s", reply.GetSessionId()) + + reply, err = client.LookupResumeId(ctx, testResumeId+"1") + assert.ErrorIs(err, ErrNoSuchResumeId, "expected unknown resume id, got %s", reply.GetSessionId()) + + if reply, err := client.LookupResumeId(ctx, testResumeId); assert.NoError(err) { + assert.Equal(testSessionId, reply.SessionId) + } + } +} + +func TestServer_LookupSessionId(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + hub := newTestServerHub(t) + + server, addr := NewServerForTest(t) + server.SetHub(hub) + clients, _ := NewClientsForTest(t, addr, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(clients.WaitForInitialized(ctx)) + + for _, client := range clients.GetClients() { + sessionId, err := client.LookupSessionId(ctx, "", "") + assert.ErrorIs(err, ErrNoSuchRoomSession, "expected unknown room session id, got %s", sessionId) + + sessionId, err = client.LookupSessionId(ctx, testRoomSessionId+"1", "") + assert.ErrorIs(err, ErrNoSuchRoomSession, "expected unknown room session id, got %s", sessionId) + + if sessionId, err := client.LookupSessionId(ctx, testRoomSessionId, "test-reason"); assert.NoError(err) { + assert.EqualValues(testSessionId, sessionId) + } + } + + if disconnected := hub.disconnected.Load(); assert.NotNil(disconnected, "session was not disconnected") { + assert.EqualValues(testSessionId, disconnected.sessionId) + assert.EqualValues(testRoomSessionId, disconnected.roomSessionId) + assert.Equal("test-reason", disconnected.reason) + } +} + +func TestServer_IsSessionInCall(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + hub := newTestServerHub(t) + + server, addr := NewServerForTest(t) + server.SetHub(hub) + clients, _ := NewClientsForTest(t, addr, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(clients.WaitForInitialized(ctx)) + + for _, client := range clients.GetClients() { + if inCall, err := client.IsSessionInCall(ctx, testSessionId, testRoomId+"1", testBackendUrl); assert.NoError(err) { + assert.False(inCall) + } + if inCall, err := client.IsSessionInCall(ctx, testSessionId, testRoomId, testBackendUrl+"1"); assert.NoError(err) { + assert.False(inCall) + } + + if inCall, err := client.IsSessionInCall(ctx, testSessionId+"1", testRoomId, testBackendUrl); assert.NoError(err) { + assert.False(inCall, "should not be in call") + } + if inCall, err := client.IsSessionInCall(ctx, testSessionId, testRoomId, testBackendUrl); assert.NoError(err) { + assert.True(inCall, "should be in call") + } + } +} + +func TestServer_GetInternalSessions(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + hub := newTestServerHub(t) + + server, addr := NewServerForTest(t) + server.SetHub(hub) + clients, _ := NewClientsForTest(t, addr, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(clients.WaitForInitialized(ctx)) + + for _, client := range clients.GetClients() { + if internal, virtual, err := client.GetInternalSessions(ctx, testRoomId+"1", []string{testBackendUrl}); assert.NoError(err) { + assert.Empty(internal) + assert.Empty(virtual) + } + if internal, virtual, err := client.GetInternalSessions(ctx, testRoomId, nil); assert.NoError(err) { + assert.Empty(internal) + assert.Empty(virtual) + } + if internal, virtual, err := client.GetInternalSessions(ctx, testRoomId, []string{testBackendUrl}); assert.NoError(err) { + if assert.Len(internal, 1) && assert.NotNil(internal[testInternalSessionId], "did not find %s in %+v", testInternalSessionId, internal) { + assert.Equal(testInternalSessionId, internal[testInternalSessionId].SessionId) + assert.EqualValues(testInternalInCallFlags, internal[testInternalSessionId].InCall) + assert.Equal(testFeatures, internal[testInternalSessionId].Features) + } + if assert.Len(virtual, 1) && assert.NotNil(virtual[testVirtualSessionId], "did not find %s in %+v", testVirtualSessionId, virtual) { + assert.Equal(testVirtualSessionId, virtual[testVirtualSessionId].SessionId) + assert.EqualValues(testVirtualInCallFlags, virtual[testVirtualSessionId].InCall) + } + } + } +} + +func TestServer_GetPublisherId(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + hub := newTestServerHub(t) + + server, addr := NewServerForTest(t) + server.SetHub(hub) + clients, _ := NewClientsForTest(t, addr, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(clients.WaitForInitialized(ctx)) + + for _, client := range clients.GetClients() { + if publisherId, proxyUrl, ip, connToken, publisherToken, err := client.GetPublisherId(ctx, testSessionId, sfu.StreamTypeVideo); assert.NoError(err) { + assert.EqualValues(testSessionId, publisherId) + assert.Equal(testProxyUrl, proxyUrl) + assert.True(net.ParseIP(testIp).Equal(ip), "expected IP %s, got %s", testIp, ip.String()) + assert.Equal(testConnectToken, connToken) + assert.Equal(testPublisherToken, publisherToken) + } + } +} + +func TestServer_GetTransientData(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + hub := newTestServerHub(t) + + server, addr := NewServerForTest(t) + server.SetHub(hub) + clients, _ := NewClientsForTest(t, addr, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(clients.WaitForInitialized(ctx)) + + for _, client := range clients.GetClients() { + if entries, err := client.GetTransientData(ctx, testRoomId+"1", hub.backend); assert.NoError(err) { + assert.Empty(entries) + } + if entries, err := client.GetTransientData(ctx, testRoomId, hub.backend); assert.NoError(err) && assert.Len(entries, 2) { + if e := entries["foo"]; assert.NotNil(e, "did not find foo in %+v", entries) { + assert.Equal("bar", e.Value) + assert.Equal(testExpires, e.Expires) + } + + if e := entries["bar"]; assert.NotNil(e, "did not find bar in %+v", entries) { + assert.EqualValues(123, e.Value) + assert.True(e.Expires.IsZero(), "should have no expiration, got %s", e.Expires) + } + } + } +} + +type testReceiver struct { + t *testing.T + received atomic.Bool + closed chan struct{} +} + +func (r *testReceiver) RemoteAddr() string { + return testAddr +} + +func (r *testReceiver) Country() geoip.Country { + return testCountry +} + +func (r *testReceiver) UserAgent() string { + return testAgent +} + +func (r *testReceiver) OnProxyMessage(message *ServerSessionMessage) error { + assert.Equal(r.t, testMessage, message.Message) + assert.False(r.t, r.received.Swap(true), "received additional message %v", message) + return nil +} + +func (r *testReceiver) OnProxyClose(err error) { + if err != nil { + if s := status.Convert(err); assert.NotNil(r.t, s, "expected status, got %+v", err) { + assert.Equal(r.t, codes.InvalidArgument, s.Code()) + assert.Equal(r.t, "unknown session id", s.Message()) + } + } + + close(r.closed) +} + +func TestServer_ProxySession(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + hub := newTestServerHub(t) + + server, addr := NewServerForTest(t) + server.SetHub(hub) + clients, _ := NewClientsForTest(t, addr, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(clients.WaitForInitialized(ctx)) + + for _, client := range clients.GetClients() { + receiver := &testReceiver{ + t: t, + closed: make(chan struct{}), + } + if proxy, err := client.ProxySession(ctx, testSessionId, receiver); assert.NoError(err) { + t.Cleanup(func() { + assert.NoError(proxy.Close()) + }) + + assert.NotNil(proxy) + <-receiver.closed + assert.True(receiver.received.Load(), "should have received message") + } + } +} + +func TestServer_ProxySessionError(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + hub := newTestServerHub(t) + + server, addr := NewServerForTest(t) + server.SetHub(hub) + clients, _ := NewClientsForTest(t, addr, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(clients.WaitForInitialized(ctx)) + + for _, client := range clients.GetClients() { + receiver := &testReceiver{ + t: t, + closed: make(chan struct{}), + } + if proxy, err := client.ProxySession(ctx, testSessionId+"1", receiver); assert.NoError(err) { + t.Cleanup(func() { + assert.NoError(proxy.Close()) + }) + + assert.NotNil(proxy) + <-receiver.closed + } + } +} + +type testSession struct{} + +func (s *testSession) PublicId() api.PublicSessionId { + return testSessionId +} + +func (s *testSession) ClientType() api.ClientType { + return api.HelloClientTypeClient +} + +func TestServer_GetSessionCount(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + hub := newTestServerHub(t) + + server, addr := NewServerForTest(t) + server.SetHub(hub) + clients, _ := NewClientsForTest(t, addr, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(clients.WaitForInitialized(ctx)) + + for _, client := range clients.GetClients() { + if count, err := client.GetSessionCount(ctx, testBackendUrl+"1"); assert.NoError(err) { + assert.EqualValues(0, count) + } + if count, err := client.GetSessionCount(ctx, testBackendUrl); assert.NoError(err) { + assert.EqualValues(0, count) + } + assert.NoError(hub.backend.AddSession(&testSession{})) + if count, err := client.GetSessionCount(ctx, testBackendUrl); assert.NoError(err) { + assert.EqualValues(1, count) + } + hub.backend.RemoveSession(&testSession{}) + if count, err := client.GetSessionCount(ctx, testBackendUrl); assert.NoError(err) { + assert.EqualValues(0, count) + } + } +} diff --git a/grpc_sessions.pb.go b/grpc/sessions.pb.go similarity index 61% rename from grpc_sessions.pb.go rename to grpc/sessions.pb.go index f47e8fb..783d885 100644 --- a/grpc_sessions.pb.go +++ b/grpc/sessions.pb.go @@ -20,15 +20,16 @@ // along with this program. If not, see . // Code generated by protoc-gen-go. DO NOT EDIT. -// source: grpc_sessions.proto +// source: grpc/sessions.proto -package signaling +package grpc import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" + unsafe "unsafe" ) const ( @@ -328,9 +329,11 @@ func (x *IsSessionInCallReply) GetInCall() bool { } type GetInternalSessionsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - RoomId string `protobuf:"bytes,1,opt,name=roomId,proto3" json:"roomId,omitempty"` - BackendUrl string `protobuf:"bytes,2,opt,name=backendUrl,proto3" json:"backendUrl,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RoomId string `protobuf:"bytes,1,opt,name=roomId,proto3" json:"roomId,omitempty"` + // Deprecated: Marked as deprecated in grpc/sessions.proto. + BackendUrl string `protobuf:"bytes,2,opt,name=backendUrl,proto3" json:"backendUrl,omitempty"` + BackendUrls []string `protobuf:"bytes,3,rep,name=backendUrls,proto3" json:"backendUrls,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -372,6 +375,7 @@ func (x *GetInternalSessionsRequest) GetRoomId() string { return "" } +// Deprecated: Marked as deprecated in grpc/sessions.proto. func (x *GetInternalSessionsRequest) GetBackendUrl() string { if x != nil { return x.BackendUrl @@ -379,6 +383,13 @@ func (x *GetInternalSessionsRequest) GetBackendUrl() string { return "" } +func (x *GetInternalSessionsRequest) GetBackendUrls() []string { + if x != nil { + return x.BackendUrls + } + return nil +} + type InternalSessionData struct { state protoimpl.MessageState `protogen:"open.v1"` SessionId string `protobuf:"bytes,1,opt,name=sessionId,proto3" json:"sessionId,omitempty"` @@ -633,146 +644,93 @@ func (x *ServerSessionMessage) GetMessage() []byte { var File_grpc_sessions_proto protoreflect.FileDescriptor -var file_grpc_sessions_proto_rawDesc = []byte{ - 0x0a, 0x13, 0x67, 0x72, 0x70, 0x63, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, - 0x22, 0x33, 0x0a, 0x15, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, - 0x49, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, - 0x75, 0x6d, 0x65, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, - 0x75, 0x6d, 0x65, 0x49, 0x64, 0x22, 0x33, 0x0a, 0x13, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, - 0x65, 0x73, 0x75, 0x6d, 0x65, 0x49, 0x64, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x1c, 0x0a, 0x09, - 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x6a, 0x0a, 0x16, 0x4c, 0x6f, - 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x72, 0x6f, 0x6f, 0x6d, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x72, 0x6f, 0x6f, - 0x6d, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x64, 0x69, - 0x73, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, - 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x34, 0x0a, 0x14, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x1c, - 0x0a, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x6e, 0x0a, 0x16, - 0x49, 0x73, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x43, 0x61, 0x6c, 0x6c, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x0a, - 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x55, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0a, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x55, 0x72, 0x6c, 0x22, 0x2e, 0x0a, 0x14, - 0x49, 0x73, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x43, 0x61, 0x6c, 0x6c, 0x52, - 0x65, 0x70, 0x6c, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x6e, 0x43, 0x61, 0x6c, 0x6c, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x6e, 0x43, 0x61, 0x6c, 0x6c, 0x22, 0x54, 0x0a, 0x1a, - 0x47, 0x65, 0x74, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x6f, - 0x6f, 0x6d, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, - 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x0a, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x55, 0x72, 0x6c, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x55, - 0x72, 0x6c, 0x22, 0x67, 0x0a, 0x13, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x61, 0x74, 0x61, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x6e, 0x43, 0x61, 0x6c, - 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x6e, 0x43, 0x61, 0x6c, 0x6c, 0x12, - 0x1a, 0x0a, 0x08, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x08, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x22, 0x4a, 0x0a, 0x12, 0x56, - 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x61, 0x74, - 0x61, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, - 0x16, 0x0a, 0x06, 0x69, 0x6e, 0x43, 0x61, 0x6c, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, - 0x06, 0x69, 0x6e, 0x43, 0x61, 0x6c, 0x6c, 0x22, 0xaf, 0x01, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x49, - 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, - 0x65, 0x70, 0x6c, 0x79, 0x12, 0x4a, 0x0a, 0x10, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, - 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x49, 0x6e, 0x74, 0x65, 0x72, - 0x6e, 0x61, 0x6c, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x61, 0x74, 0x61, 0x52, 0x10, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, - 0x12, 0x47, 0x0a, 0x0f, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x73, 0x69, 0x67, 0x6e, - 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x56, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x61, 0x74, 0x61, 0x52, 0x0f, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, - 0x6c, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x30, 0x0a, 0x14, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x30, 0x0a, 0x14, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0xd2, 0x03, - 0x0a, 0x0b, 0x52, 0x70, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x54, 0x0a, - 0x0e, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x49, 0x64, 0x12, - 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x4c, 0x6f, 0x6f, 0x6b, - 0x75, 0x70, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x49, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1e, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x4c, 0x6f, - 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x49, 0x64, 0x52, 0x65, 0x70, 0x6c, - 0x79, 0x22, 0x00, 0x12, 0x57, 0x0a, 0x0f, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x21, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, - 0x6e, 0x67, 0x2e, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x49, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x73, 0x69, 0x67, 0x6e, - 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x12, 0x57, 0x0a, 0x0f, - 0x49, 0x73, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x43, 0x61, 0x6c, 0x6c, 0x12, - 0x21, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x49, 0x73, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x43, 0x61, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x49, - 0x73, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x43, 0x61, 0x6c, 0x6c, 0x52, 0x65, - 0x70, 0x6c, 0x79, 0x22, 0x00, 0x12, 0x63, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x49, 0x6e, 0x74, 0x65, - 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x25, 0x2e, 0x73, - 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x49, 0x6e, 0x74, 0x65, - 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, - 0x47, 0x65, 0x74, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x12, 0x56, 0x0a, 0x0c, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x2e, 0x73, 0x69, 0x67, - 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1f, 0x2e, 0x73, 0x69, - 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, - 0x30, 0x01, 0x42, 0x3c, 0x5a, 0x3a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, - 0x2f, 0x73, 0x74, 0x72, 0x75, 0x6b, 0x74, 0x75, 0x72, 0x61, 0x67, 0x2f, 0x6e, 0x65, 0x78, 0x74, - 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x70, 0x72, 0x65, 0x65, 0x64, 0x2d, 0x73, 0x69, 0x67, - 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x3b, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} +const file_grpc_sessions_proto_rawDesc = "" + + "\n" + + "\x13grpc/sessions.proto\x12\x04grpc\"3\n" + + "\x15LookupResumeIdRequest\x12\x1a\n" + + "\bresumeId\x18\x01 \x01(\tR\bresumeId\"3\n" + + "\x13LookupResumeIdReply\x12\x1c\n" + + "\tsessionId\x18\x01 \x01(\tR\tsessionId\"j\n" + + "\x16LookupSessionIdRequest\x12$\n" + + "\rroomSessionId\x18\x01 \x01(\tR\rroomSessionId\x12*\n" + + "\x10disconnectReason\x18\x02 \x01(\tR\x10disconnectReason\"4\n" + + "\x14LookupSessionIdReply\x12\x1c\n" + + "\tsessionId\x18\x01 \x01(\tR\tsessionId\"n\n" + + "\x16IsSessionInCallRequest\x12\x1c\n" + + "\tsessionId\x18\x01 \x01(\tR\tsessionId\x12\x16\n" + + "\x06roomId\x18\x02 \x01(\tR\x06roomId\x12\x1e\n" + + "\n" + + "backendUrl\x18\x03 \x01(\tR\n" + + "backendUrl\".\n" + + "\x14IsSessionInCallReply\x12\x16\n" + + "\x06inCall\x18\x01 \x01(\bR\x06inCall\"z\n" + + "\x1aGetInternalSessionsRequest\x12\x16\n" + + "\x06roomId\x18\x01 \x01(\tR\x06roomId\x12\"\n" + + "\n" + + "backendUrl\x18\x02 \x01(\tB\x02\x18\x01R\n" + + "backendUrl\x12 \n" + + "\vbackendUrls\x18\x03 \x03(\tR\vbackendUrls\"g\n" + + "\x13InternalSessionData\x12\x1c\n" + + "\tsessionId\x18\x01 \x01(\tR\tsessionId\x12\x16\n" + + "\x06inCall\x18\x02 \x01(\rR\x06inCall\x12\x1a\n" + + "\bfeatures\x18\x03 \x03(\tR\bfeatures\"J\n" + + "\x12VirtualSessionData\x12\x1c\n" + + "\tsessionId\x18\x01 \x01(\tR\tsessionId\x12\x16\n" + + "\x06inCall\x18\x02 \x01(\rR\x06inCall\"\xa5\x01\n" + + "\x18GetInternalSessionsReply\x12E\n" + + "\x10internalSessions\x18\x01 \x03(\v2\x19.grpc.InternalSessionDataR\x10internalSessions\x12B\n" + + "\x0fvirtualSessions\x18\x02 \x03(\v2\x18.grpc.VirtualSessionDataR\x0fvirtualSessions\"0\n" + + "\x14ClientSessionMessage\x12\x18\n" + + "\amessage\x18\x01 \x01(\fR\amessage\"0\n" + + "\x14ServerSessionMessage\x12\x18\n" + + "\amessage\x18\x01 \x01(\fR\amessage2\xa0\x03\n" + + "\vRpcSessions\x12J\n" + + "\x0eLookupResumeId\x12\x1b.grpc.LookupResumeIdRequest\x1a\x19.grpc.LookupResumeIdReply\"\x00\x12M\n" + + "\x0fLookupSessionId\x12\x1c.grpc.LookupSessionIdRequest\x1a\x1a.grpc.LookupSessionIdReply\"\x00\x12M\n" + + "\x0fIsSessionInCall\x12\x1c.grpc.IsSessionInCallRequest\x1a\x1a.grpc.IsSessionInCallReply\"\x00\x12Y\n" + + "\x13GetInternalSessions\x12 .grpc.GetInternalSessionsRequest\x1a\x1e.grpc.GetInternalSessionsReply\"\x00\x12L\n" + + "\fProxySession\x12\x1a.grpc.ClientSessionMessage\x1a\x1a.grpc.ServerSessionMessage\"\x00(\x010\x01B7Z5github.com/strukturag/nextcloud-spreed-signaling/grpcb\x06proto3" var ( file_grpc_sessions_proto_rawDescOnce sync.Once - file_grpc_sessions_proto_rawDescData = file_grpc_sessions_proto_rawDesc + file_grpc_sessions_proto_rawDescData []byte ) func file_grpc_sessions_proto_rawDescGZIP() []byte { file_grpc_sessions_proto_rawDescOnce.Do(func() { - file_grpc_sessions_proto_rawDescData = protoimpl.X.CompressGZIP(file_grpc_sessions_proto_rawDescData) + file_grpc_sessions_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_grpc_sessions_proto_rawDesc), len(file_grpc_sessions_proto_rawDesc))) }) return file_grpc_sessions_proto_rawDescData } var file_grpc_sessions_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_grpc_sessions_proto_goTypes = []any{ - (*LookupResumeIdRequest)(nil), // 0: signaling.LookupResumeIdRequest - (*LookupResumeIdReply)(nil), // 1: signaling.LookupResumeIdReply - (*LookupSessionIdRequest)(nil), // 2: signaling.LookupSessionIdRequest - (*LookupSessionIdReply)(nil), // 3: signaling.LookupSessionIdReply - (*IsSessionInCallRequest)(nil), // 4: signaling.IsSessionInCallRequest - (*IsSessionInCallReply)(nil), // 5: signaling.IsSessionInCallReply - (*GetInternalSessionsRequest)(nil), // 6: signaling.GetInternalSessionsRequest - (*InternalSessionData)(nil), // 7: signaling.InternalSessionData - (*VirtualSessionData)(nil), // 8: signaling.VirtualSessionData - (*GetInternalSessionsReply)(nil), // 9: signaling.GetInternalSessionsReply - (*ClientSessionMessage)(nil), // 10: signaling.ClientSessionMessage - (*ServerSessionMessage)(nil), // 11: signaling.ServerSessionMessage + (*LookupResumeIdRequest)(nil), // 0: grpc.LookupResumeIdRequest + (*LookupResumeIdReply)(nil), // 1: grpc.LookupResumeIdReply + (*LookupSessionIdRequest)(nil), // 2: grpc.LookupSessionIdRequest + (*LookupSessionIdReply)(nil), // 3: grpc.LookupSessionIdReply + (*IsSessionInCallRequest)(nil), // 4: grpc.IsSessionInCallRequest + (*IsSessionInCallReply)(nil), // 5: grpc.IsSessionInCallReply + (*GetInternalSessionsRequest)(nil), // 6: grpc.GetInternalSessionsRequest + (*InternalSessionData)(nil), // 7: grpc.InternalSessionData + (*VirtualSessionData)(nil), // 8: grpc.VirtualSessionData + (*GetInternalSessionsReply)(nil), // 9: grpc.GetInternalSessionsReply + (*ClientSessionMessage)(nil), // 10: grpc.ClientSessionMessage + (*ServerSessionMessage)(nil), // 11: grpc.ServerSessionMessage } var file_grpc_sessions_proto_depIdxs = []int32{ - 7, // 0: signaling.GetInternalSessionsReply.internalSessions:type_name -> signaling.InternalSessionData - 8, // 1: signaling.GetInternalSessionsReply.virtualSessions:type_name -> signaling.VirtualSessionData - 0, // 2: signaling.RpcSessions.LookupResumeId:input_type -> signaling.LookupResumeIdRequest - 2, // 3: signaling.RpcSessions.LookupSessionId:input_type -> signaling.LookupSessionIdRequest - 4, // 4: signaling.RpcSessions.IsSessionInCall:input_type -> signaling.IsSessionInCallRequest - 6, // 5: signaling.RpcSessions.GetInternalSessions:input_type -> signaling.GetInternalSessionsRequest - 10, // 6: signaling.RpcSessions.ProxySession:input_type -> signaling.ClientSessionMessage - 1, // 7: signaling.RpcSessions.LookupResumeId:output_type -> signaling.LookupResumeIdReply - 3, // 8: signaling.RpcSessions.LookupSessionId:output_type -> signaling.LookupSessionIdReply - 5, // 9: signaling.RpcSessions.IsSessionInCall:output_type -> signaling.IsSessionInCallReply - 9, // 10: signaling.RpcSessions.GetInternalSessions:output_type -> signaling.GetInternalSessionsReply - 11, // 11: signaling.RpcSessions.ProxySession:output_type -> signaling.ServerSessionMessage + 7, // 0: grpc.GetInternalSessionsReply.internalSessions:type_name -> grpc.InternalSessionData + 8, // 1: grpc.GetInternalSessionsReply.virtualSessions:type_name -> grpc.VirtualSessionData + 0, // 2: grpc.RpcSessions.LookupResumeId:input_type -> grpc.LookupResumeIdRequest + 2, // 3: grpc.RpcSessions.LookupSessionId:input_type -> grpc.LookupSessionIdRequest + 4, // 4: grpc.RpcSessions.IsSessionInCall:input_type -> grpc.IsSessionInCallRequest + 6, // 5: grpc.RpcSessions.GetInternalSessions:input_type -> grpc.GetInternalSessionsRequest + 10, // 6: grpc.RpcSessions.ProxySession:input_type -> grpc.ClientSessionMessage + 1, // 7: grpc.RpcSessions.LookupResumeId:output_type -> grpc.LookupResumeIdReply + 3, // 8: grpc.RpcSessions.LookupSessionId:output_type -> grpc.LookupSessionIdReply + 5, // 9: grpc.RpcSessions.IsSessionInCall:output_type -> grpc.IsSessionInCallReply + 9, // 10: grpc.RpcSessions.GetInternalSessions:output_type -> grpc.GetInternalSessionsReply + 11, // 11: grpc.RpcSessions.ProxySession:output_type -> grpc.ServerSessionMessage 7, // [7:12] is the sub-list for method output_type 2, // [2:7] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name @@ -789,7 +747,7 @@ func file_grpc_sessions_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_grpc_sessions_proto_rawDesc, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_grpc_sessions_proto_rawDesc), len(file_grpc_sessions_proto_rawDesc)), NumEnums: 0, NumMessages: 12, NumExtensions: 0, @@ -800,7 +758,6 @@ func file_grpc_sessions_proto_init() { MessageInfos: file_grpc_sessions_proto_msgTypes, }.Build() File_grpc_sessions_proto = out.File - file_grpc_sessions_proto_rawDesc = nil file_grpc_sessions_proto_goTypes = nil file_grpc_sessions_proto_depIdxs = nil } diff --git a/grpc_sessions.proto b/grpc/sessions.proto similarity index 95% rename from grpc_sessions.proto rename to grpc/sessions.proto index 3d29dfe..858eca0 100644 --- a/grpc_sessions.proto +++ b/grpc/sessions.proto @@ -21,9 +21,9 @@ */ syntax = "proto3"; -option go_package = "github.com/strukturag/nextcloud-spreed-signaling;signaling"; +option go_package = "github.com/strukturag/nextcloud-spreed-signaling/grpc"; -package signaling; +package grpc; service RpcSessions { rpc LookupResumeId(LookupResumeIdRequest) returns (LookupResumeIdReply) {} @@ -63,7 +63,8 @@ message IsSessionInCallReply { message GetInternalSessionsRequest { string roomId = 1; - string backendUrl = 2; + string backendUrl = 2 [deprecated = true]; + repeated string backendUrls = 3; } message InternalSessionData { diff --git a/grpc_sessions_grpc.pb.go b/grpc/sessions_grpc.pb.go similarity index 91% rename from grpc_sessions_grpc.pb.go rename to grpc/sessions_grpc.pb.go index 8b9c6a1..c10dcb7 100644 --- a/grpc_sessions_grpc.pb.go +++ b/grpc/sessions_grpc.pb.go @@ -20,9 +20,9 @@ // along with this program. If not, see . // Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// source: grpc_sessions.proto +// source: grpc/sessions.proto -package signaling +package grpc import ( context "context" @@ -37,11 +37,11 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - RpcSessions_LookupResumeId_FullMethodName = "/signaling.RpcSessions/LookupResumeId" - RpcSessions_LookupSessionId_FullMethodName = "/signaling.RpcSessions/LookupSessionId" - RpcSessions_IsSessionInCall_FullMethodName = "/signaling.RpcSessions/IsSessionInCall" - RpcSessions_GetInternalSessions_FullMethodName = "/signaling.RpcSessions/GetInternalSessions" - RpcSessions_ProxySession_FullMethodName = "/signaling.RpcSessions/ProxySession" + RpcSessions_LookupResumeId_FullMethodName = "/grpc.RpcSessions/LookupResumeId" + RpcSessions_LookupSessionId_FullMethodName = "/grpc.RpcSessions/LookupSessionId" + RpcSessions_IsSessionInCall_FullMethodName = "/grpc.RpcSessions/IsSessionInCall" + RpcSessions_GetInternalSessions_FullMethodName = "/grpc.RpcSessions/GetInternalSessions" + RpcSessions_ProxySession_FullMethodName = "/grpc.RpcSessions/ProxySession" ) // RpcSessionsClient is the client API for RpcSessions service. @@ -136,19 +136,19 @@ type RpcSessionsServer interface { type UnimplementedRpcSessionsServer struct{} func (UnimplementedRpcSessionsServer) LookupResumeId(context.Context, *LookupResumeIdRequest) (*LookupResumeIdReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method LookupResumeId not implemented") + return nil, status.Error(codes.Unimplemented, "method LookupResumeId not implemented") } func (UnimplementedRpcSessionsServer) LookupSessionId(context.Context, *LookupSessionIdRequest) (*LookupSessionIdReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method LookupSessionId not implemented") + return nil, status.Error(codes.Unimplemented, "method LookupSessionId not implemented") } func (UnimplementedRpcSessionsServer) IsSessionInCall(context.Context, *IsSessionInCallRequest) (*IsSessionInCallReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method IsSessionInCall not implemented") + return nil, status.Error(codes.Unimplemented, "method IsSessionInCall not implemented") } func (UnimplementedRpcSessionsServer) GetInternalSessions(context.Context, *GetInternalSessionsRequest) (*GetInternalSessionsReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetInternalSessions not implemented") + return nil, status.Error(codes.Unimplemented, "method GetInternalSessions not implemented") } func (UnimplementedRpcSessionsServer) ProxySession(grpc.BidiStreamingServer[ClientSessionMessage, ServerSessionMessage]) error { - return status.Errorf(codes.Unimplemented, "method ProxySession not implemented") + return status.Error(codes.Unimplemented, "method ProxySession not implemented") } func (UnimplementedRpcSessionsServer) mustEmbedUnimplementedRpcSessionsServer() {} func (UnimplementedRpcSessionsServer) testEmbeddedByValue() {} @@ -161,7 +161,7 @@ type UnsafeRpcSessionsServer interface { } func RegisterRpcSessionsServer(s grpc.ServiceRegistrar, srv RpcSessionsServer) { - // If the following call pancis, it indicates UnimplementedRpcSessionsServer was + // If the following call panics, it indicates UnimplementedRpcSessionsServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -254,7 +254,7 @@ type RpcSessions_ProxySessionServer = grpc.BidiStreamingServer[ClientSessionMess // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var RpcSessions_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "signaling.RpcSessions", + ServiceName: "grpc.RpcSessions", HandlerType: (*RpcSessionsServer)(nil), Methods: []grpc.MethodDesc{ { @@ -282,5 +282,5 @@ var RpcSessions_ServiceDesc = grpc.ServiceDesc{ ClientStreams: true, }, }, - Metadata: "grpc_sessions.proto", + Metadata: "grpc/sessions.proto", } diff --git a/grpc/sfu.pb.go b/grpc/sfu.pb.go new file mode 100644 index 0000000..4c4cabf --- /dev/null +++ b/grpc/sfu.pb.go @@ -0,0 +1,238 @@ +//* +// Standalone signaling server for the Nextcloud Spreed app. +// Copyright (C) 2022 struktur AG +// +// @author Joachim Bauch +// +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: grpc/sfu.proto + +package grpc + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetPublisherIdRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=sessionId,proto3" json:"sessionId,omitempty"` + StreamType string `protobuf:"bytes,2,opt,name=streamType,proto3" json:"streamType,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPublisherIdRequest) Reset() { + *x = GetPublisherIdRequest{} + mi := &file_grpc_sfu_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPublisherIdRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPublisherIdRequest) ProtoMessage() {} + +func (x *GetPublisherIdRequest) ProtoReflect() protoreflect.Message { + mi := &file_grpc_sfu_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetPublisherIdRequest.ProtoReflect.Descriptor instead. +func (*GetPublisherIdRequest) Descriptor() ([]byte, []int) { + return file_grpc_sfu_proto_rawDescGZIP(), []int{0} +} + +func (x *GetPublisherIdRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *GetPublisherIdRequest) GetStreamType() string { + if x != nil { + return x.StreamType + } + return "" +} + +type GetPublisherIdReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + PublisherId string `protobuf:"bytes,1,opt,name=publisherId,proto3" json:"publisherId,omitempty"` + ProxyUrl string `protobuf:"bytes,2,opt,name=proxyUrl,proto3" json:"proxyUrl,omitempty"` + Ip string `protobuf:"bytes,3,opt,name=ip,proto3" json:"ip,omitempty"` + ConnectToken string `protobuf:"bytes,4,opt,name=connectToken,proto3" json:"connectToken,omitempty"` + PublisherToken string `protobuf:"bytes,5,opt,name=publisherToken,proto3" json:"publisherToken,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPublisherIdReply) Reset() { + *x = GetPublisherIdReply{} + mi := &file_grpc_sfu_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPublisherIdReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPublisherIdReply) ProtoMessage() {} + +func (x *GetPublisherIdReply) ProtoReflect() protoreflect.Message { + mi := &file_grpc_sfu_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetPublisherIdReply.ProtoReflect.Descriptor instead. +func (*GetPublisherIdReply) Descriptor() ([]byte, []int) { + return file_grpc_sfu_proto_rawDescGZIP(), []int{1} +} + +func (x *GetPublisherIdReply) GetPublisherId() string { + if x != nil { + return x.PublisherId + } + return "" +} + +func (x *GetPublisherIdReply) GetProxyUrl() string { + if x != nil { + return x.ProxyUrl + } + return "" +} + +func (x *GetPublisherIdReply) GetIp() string { + if x != nil { + return x.Ip + } + return "" +} + +func (x *GetPublisherIdReply) GetConnectToken() string { + if x != nil { + return x.ConnectToken + } + return "" +} + +func (x *GetPublisherIdReply) GetPublisherToken() string { + if x != nil { + return x.PublisherToken + } + return "" +} + +var File_grpc_sfu_proto protoreflect.FileDescriptor + +const file_grpc_sfu_proto_rawDesc = "" + + "\n" + + "\x0egrpc/sfu.proto\x12\x04grpc\"U\n" + + "\x15GetPublisherIdRequest\x12\x1c\n" + + "\tsessionId\x18\x01 \x01(\tR\tsessionId\x12\x1e\n" + + "\n" + + "streamType\x18\x02 \x01(\tR\n" + + "streamType\"\xaf\x01\n" + + "\x13GetPublisherIdReply\x12 \n" + + "\vpublisherId\x18\x01 \x01(\tR\vpublisherId\x12\x1a\n" + + "\bproxyUrl\x18\x02 \x01(\tR\bproxyUrl\x12\x0e\n" + + "\x02ip\x18\x03 \x01(\tR\x02ip\x12\"\n" + + "\fconnectToken\x18\x04 \x01(\tR\fconnectToken\x12&\n" + + "\x0epublisherToken\x18\x05 \x01(\tR\x0epublisherToken2T\n" + + "\x06RpcMcu\x12J\n" + + "\x0eGetPublisherId\x12\x1b.grpc.GetPublisherIdRequest\x1a\x19.grpc.GetPublisherIdReply\"\x00B7Z5github.com/strukturag/nextcloud-spreed-signaling/grpcb\x06proto3" + +var ( + file_grpc_sfu_proto_rawDescOnce sync.Once + file_grpc_sfu_proto_rawDescData []byte +) + +func file_grpc_sfu_proto_rawDescGZIP() []byte { + file_grpc_sfu_proto_rawDescOnce.Do(func() { + file_grpc_sfu_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_grpc_sfu_proto_rawDesc), len(file_grpc_sfu_proto_rawDesc))) + }) + return file_grpc_sfu_proto_rawDescData +} + +var file_grpc_sfu_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_grpc_sfu_proto_goTypes = []any{ + (*GetPublisherIdRequest)(nil), // 0: grpc.GetPublisherIdRequest + (*GetPublisherIdReply)(nil), // 1: grpc.GetPublisherIdReply +} +var file_grpc_sfu_proto_depIdxs = []int32{ + 0, // 0: grpc.RpcMcu.GetPublisherId:input_type -> grpc.GetPublisherIdRequest + 1, // 1: grpc.RpcMcu.GetPublisherId:output_type -> grpc.GetPublisherIdReply + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_grpc_sfu_proto_init() } +func file_grpc_sfu_proto_init() { + if File_grpc_sfu_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_grpc_sfu_proto_rawDesc), len(file_grpc_sfu_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_grpc_sfu_proto_goTypes, + DependencyIndexes: file_grpc_sfu_proto_depIdxs, + MessageInfos: file_grpc_sfu_proto_msgTypes, + }.Build() + File_grpc_sfu_proto = out.File + file_grpc_sfu_proto_goTypes = nil + file_grpc_sfu_proto_depIdxs = nil +} diff --git a/grpc_mcu.proto b/grpc/sfu.proto similarity index 93% rename from grpc_mcu.proto rename to grpc/sfu.proto index b2313d2..e0906c1 100644 --- a/grpc_mcu.proto +++ b/grpc/sfu.proto @@ -21,9 +21,9 @@ */ syntax = "proto3"; -option go_package = "github.com/strukturag/nextcloud-spreed-signaling;signaling"; +option go_package = "github.com/strukturag/nextcloud-spreed-signaling/grpc"; -package signaling; +package grpc; service RpcMcu { rpc GetPublisherId(GetPublisherIdRequest) returns (GetPublisherIdReply) {} @@ -38,4 +38,6 @@ message GetPublisherIdReply { string publisherId = 1; string proxyUrl = 2; string ip = 3; + string connectToken = 4; + string publisherToken = 5; } diff --git a/grpc_mcu_grpc.pb.go b/grpc/sfu_grpc.pb.go similarity index 93% rename from grpc_mcu_grpc.pb.go rename to grpc/sfu_grpc.pb.go index 3b37591..a1c38dc 100644 --- a/grpc_mcu_grpc.pb.go +++ b/grpc/sfu_grpc.pb.go @@ -20,9 +20,9 @@ // along with this program. If not, see . // Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// source: grpc_mcu.proto +// source: grpc/sfu.proto -package signaling +package grpc import ( context "context" @@ -37,7 +37,7 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - RpcMcu_GetPublisherId_FullMethodName = "/signaling.RpcMcu/GetPublisherId" + RpcMcu_GetPublisherId_FullMethodName = "/grpc.RpcMcu/GetPublisherId" ) // RpcMcuClient is the client API for RpcMcu service. @@ -81,7 +81,7 @@ type RpcMcuServer interface { type UnimplementedRpcMcuServer struct{} func (UnimplementedRpcMcuServer) GetPublisherId(context.Context, *GetPublisherIdRequest) (*GetPublisherIdReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetPublisherId not implemented") + return nil, status.Error(codes.Unimplemented, "method GetPublisherId not implemented") } func (UnimplementedRpcMcuServer) mustEmbedUnimplementedRpcMcuServer() {} func (UnimplementedRpcMcuServer) testEmbeddedByValue() {} @@ -94,7 +94,7 @@ type UnsafeRpcMcuServer interface { } func RegisterRpcMcuServer(s grpc.ServiceRegistrar, srv RpcMcuServer) { - // If the following call pancis, it indicates UnimplementedRpcMcuServer was + // If the following call panics, it indicates UnimplementedRpcMcuServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -126,7 +126,7 @@ func _RpcMcu_GetPublisherId_Handler(srv interface{}, ctx context.Context, dec fu // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var RpcMcu_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "signaling.RpcMcu", + ServiceName: "grpc.RpcMcu", HandlerType: (*RpcMcuServer)(nil), Methods: []grpc.MethodDesc{ { @@ -135,5 +135,5 @@ var RpcMcu_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{}, - Metadata: "grpc_mcu.proto", + Metadata: "grpc/sfu.proto", } diff --git a/syscallconn.go b/grpc/syscallconn.go similarity index 99% rename from syscallconn.go rename to grpc/syscallconn.go index db33e69..6f7a13d 100644 --- a/syscallconn.go +++ b/grpc/syscallconn.go @@ -16,7 +16,7 @@ * */ -package signaling +package grpc import ( "net" diff --git a/grpc/test/client.go b/grpc/test/client.go new file mode 100644 index 0000000..fcb22f9 --- /dev/null +++ b/grpc/test/client.go @@ -0,0 +1,77 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "testing" + "time" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns" + dnstest "github.com/strukturag/nextcloud-spreed-signaling/v2/dns/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + etcdtest "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" +) + +func NewClientsForTestWithConfig(t *testing.T, config *goconf.ConfigFile, etcdClient etcd.Client, lookup *dnstest.MockLookup) (*grpc.Clients, *dns.Monitor) { + dnsMonitor := dnstest.NewMonitorForTest(t, time.Hour, lookup) // will be updated manually + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + client, err := grpc.NewClients(ctx, config, etcdClient, dnsMonitor, "0.0.0") + require.NoError(t, err) + t.Cleanup(func() { + client.Close() + }) + + return client, dnsMonitor +} + +func NewClientsForTest(t *testing.T, addr string, lookup *dnstest.MockLookup) (*grpc.Clients, *dns.Monitor) { + config := goconf.NewConfigFile() + config.AddOption("grpc", "targets", addr) + config.AddOption("grpc", "dnsdiscovery", "true") + + return NewClientsForTestWithConfig(t, config, nil, lookup) +} + +func NewClientsWithEtcdForTest(t *testing.T, embedEtcd *etcdtest.EtcdServer, lookup *dnstest.MockLookup) (*grpc.Clients, *dns.Monitor) { + config := goconf.NewConfigFile() + config.AddOption("etcd", "endpoints", embedEtcd.URL().String()) + + config.AddOption("grpc", "targettype", "etcd") + config.AddOption("grpc", "targetprefix", "/grpctargets") + + logger := logtest.NewLoggerForTest(t) + etcdClient, err := etcd.NewClient(logger, config, "") + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, etcdClient.Close()) + }) + + return NewClientsForTestWithConfig(t, config, etcdClient, lookup) +} diff --git a/grpc/test/client_test.go b/grpc/test/client_test.go new file mode 100644 index 0000000..5f5c7f2 --- /dev/null +++ b/grpc/test/client_test.go @@ -0,0 +1,56 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + etcdtest "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd/test" +) + +func TestClientsWithEtcd(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + serverId := "the-test-server-id" + server, addr := NewServerForTest(t) + server.SetServerId(serverId) + + etcd := etcdtest.NewServerForTest(t) + etcd.SetValue("/grpctargets/one", []byte("{\"address\":\""+addr+"\"}")) + + clients, _ := NewClientsWithEtcdForTest(t, etcd, nil) + + require.NoError(clients.WaitForInitialized(t.Context())) + + for _, client := range clients.GetClients() { + if id, version, err := client.GetServerId(t.Context()); assert.NoError(err) { + assert.Equal(serverId, id) + assert.NotEmpty(version) + } + } +} diff --git a/grpc/test/server.go b/grpc/test/server.go new file mode 100644 index 0000000..edb55cb --- /dev/null +++ b/grpc/test/server.go @@ -0,0 +1,122 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "context" + "errors" + "net" + "net/url" + "strconv" + "testing" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" +) + +func NewServerForTestWithConfig(t *testing.T, config *goconf.ConfigFile) (server *grpc.Server, addr string) { + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + for port := 50000; port < 50100; port++ { + addr = net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + config.AddOption("grpc", "listen", addr) + var err error + server, err = grpc.NewServer(ctx, config, "0.0.0") + if test.IsErrorAddressAlreadyInUse(err) { + continue + } + + require.NoError(t, err) + break + } + + require.NotNil(t, server, "could not find free port") + + // Don't match with own server id by default. + server.SetServerId("dont-match") + + go func() { + assert.NoError(t, server.Run(), "could not start GRPC server") + }() + + t.Cleanup(func() { + server.Close() + }) + return server, addr +} + +func NewServerForTest(t *testing.T) (server *grpc.Server, addr string) { + config := goconf.NewConfigFile() + return NewServerForTestWithConfig(t, config) +} + +type MockHub struct { +} + +func (h *MockHub) GetSessionIdByResumeId(resumeId api.PrivateSessionId) api.PublicSessionId { + return "" +} + +func (h *MockHub) GetSessionIdByRoomSessionId(roomSessionId api.RoomSessionId) (api.PublicSessionId, error) { + return "", errors.New("not implemented") +} + +func (h *MockHub) IsSessionIdInCall(sessionId api.PublicSessionId, roomId string, backendUrl string) (bool, bool) { + return false, false +} + +func (h *MockHub) DisconnectSessionByRoomSessionId(sessionId api.PublicSessionId, roomSessionId api.RoomSessionId, reason string) { +} + +func (h *MockHub) GetBackend(u *url.URL) *talk.Backend { + return nil +} + +func (h *MockHub) GetInternalSessions(roomId string, backend *talk.Backend) ([]*grpc.InternalSessionData, []*grpc.VirtualSessionData, bool) { + return nil, nil, false +} + +func (h *MockHub) GetTransientEntries(roomId string, backend *talk.Backend) (api.TransientDataEntries, bool) { + return nil, false +} + +func (h *MockHub) GetPublisherIdForSessionId(ctx context.Context, sessionId api.PublicSessionId, streamType sfu.StreamType) (*grpc.GetPublisherIdReply, error) { + return nil, errors.New("not implemented") +} + +func (h *MockHub) ProxySession(request grpc.RpcSessions_ProxySessionServer) error { + return errors.New("not implemented") +} + +var ( + // Compile-time check that MockHub implements the interface. + _ grpc.ServerHub = &MockHub{} +) diff --git a/grpc/test/server_test.go b/grpc/test/server_test.go new file mode 100644 index 0000000..03cf00a --- /dev/null +++ b/grpc/test/server_test.go @@ -0,0 +1,130 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +type emptyReceiver struct { +} + +func (r *emptyReceiver) RemoteAddr() string { + return "127.0.0.1" +} + +func (r *emptyReceiver) Country() geoip.Country { + return "DE" +} + +func (r *emptyReceiver) UserAgent() string { + return "testing" +} + +func (r *emptyReceiver) OnProxyMessage(message *grpc.ServerSessionMessage) error { + return errors.New("not implemented") +} + +func (r *emptyReceiver) OnProxyClose(err error) { + // Ignore +} + +func TestServer(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + serverId := "the-test-server-id" + server, addr := NewServerForTest(t) + server.SetServerId(serverId) + hub := &MockHub{} + server.SetHub(hub) + + clients, _ := NewClientsForTest(t, addr, nil) + + require.NoError(clients.WaitForInitialized(t.Context())) + + backend := talk.NewCompatBackend(nil) + + for _, client := range clients.GetClients() { + if id, version, err := client.GetServerId(t.Context()); assert.NoError(err) { + assert.Equal(serverId, id) + assert.NotEmpty(version) + } + + reply, err := client.LookupResumeId(t.Context(), "resume-id") + assert.ErrorIs(err, grpc.ErrNoSuchResumeId) + assert.Nil(reply) + + id, err := client.LookupSessionId(t.Context(), "session-id", "") + if s, ok := status.FromError(err); assert.True(ok) { + assert.Equal(codes.Unknown, s.Code()) + assert.Equal("not implemented", s.Message()) + } + assert.Empty(id) + + if incall, err := client.IsSessionInCall(t.Context(), "session-id", "room-id", ""); assert.NoError(err) { + assert.False(incall) + } + + if internal, virtual, err := client.GetInternalSessions(t.Context(), "room-id", nil); assert.NoError(err) { + assert.Empty(internal) + assert.Empty(virtual) + } + + publisherId, proxyUrl, ip, connToken, publisherToken, err := client.GetPublisherId(t.Context(), "session-id", sfu.StreamTypeVideo) + if s, ok := status.FromError(err); assert.True(ok) { + assert.Equal(codes.Unknown, s.Code()) + assert.Equal("not implemented", s.Message()) + } + assert.Empty(publisherId) + assert.Empty(proxyUrl) + assert.Empty(ip) + assert.Empty(connToken) + assert.Empty(publisherToken) + + if count, err := client.GetSessionCount(t.Context(), ""); assert.NoError(err) { + assert.EqualValues(0, count) + } + + if data, err := client.GetTransientData(t.Context(), "room-id", backend); assert.NoError(err) { + assert.Empty(data) + } + + receiver := &emptyReceiver{} + proxy, err := client.ProxySession(t.Context(), "session-id", receiver) + assert.NoError(err) + assert.NotNil(proxy) + } +} diff --git a/grpc_client_test.go b/grpc_client_test.go deleted file mode 100644 index b536a34..0000000 --- a/grpc_client_test.go +++ /dev/null @@ -1,343 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "fmt" - "net" - "os" - "path" - "testing" - "time" - - "github.com/dlintw/goconf" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.etcd.io/etcd/server/v3/embed" -) - -func (c *GrpcClients) getWakeupChannelForTesting() <-chan struct{} { - c.mu.Lock() - defer c.mu.Unlock() - - if c.wakeupChanForTesting != nil { - return c.wakeupChanForTesting - } - - ch := make(chan struct{}, 1) - c.wakeupChanForTesting = ch - return ch -} - -func NewGrpcClientsForTestWithConfig(t *testing.T, config *goconf.ConfigFile, etcdClient *EtcdClient) (*GrpcClients, *DnsMonitor) { - dnsMonitor := newDnsMonitorForTest(t, time.Hour) // will be updated manually - client, err := NewGrpcClients(config, etcdClient, dnsMonitor, "0.0.0") - require.NoError(t, err) - t.Cleanup(func() { - client.Close() - }) - - return client, dnsMonitor -} - -func NewGrpcClientsForTest(t *testing.T, addr string) (*GrpcClients, *DnsMonitor) { - config := goconf.NewConfigFile() - config.AddOption("grpc", "targets", addr) - config.AddOption("grpc", "dnsdiscovery", "true") - - return NewGrpcClientsForTestWithConfig(t, config, nil) -} - -func NewGrpcClientsWithEtcdForTest(t *testing.T, etcd *embed.Etcd) (*GrpcClients, *DnsMonitor) { - config := goconf.NewConfigFile() - config.AddOption("etcd", "endpoints", etcd.Config().ListenClientUrls[0].String()) - - config.AddOption("grpc", "targettype", "etcd") - config.AddOption("grpc", "targetprefix", "/grpctargets") - - etcdClient, err := NewEtcdClient(config, "") - require.NoError(t, err) - t.Cleanup(func() { - assert.NoError(t, etcdClient.Close()) - }) - - return NewGrpcClientsForTestWithConfig(t, config, etcdClient) -} - -func drainWakeupChannel(ch <-chan struct{}) { - for { - select { - case <-ch: - default: - return - } - } -} - -func waitForEvent(ctx context.Context, t *testing.T, ch <-chan struct{}) { - t.Helper() - - select { - case <-ch: - return - case <-ctx.Done(): - assert.Fail(t, "timeout waiting for event") - } -} - -func Test_GrpcClients_EtcdInitial(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { - _, addr1 := NewGrpcServerForTest(t) - _, addr2 := NewGrpcServerForTest(t) - - etcd := NewEtcdForTest(t) - - SetEtcdValue(etcd, "/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) - SetEtcdValue(etcd, "/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) - - client, _ := NewGrpcClientsWithEtcdForTest(t, etcd) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - require.NoError(t, client.WaitForInitialized(ctx)) - - clients := client.GetClients() - assert.Len(t, clients, 2, "Expected two clients, got %+v", clients) - }) -} - -func Test_GrpcClients_EtcdUpdate(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - assert := assert.New(t) - etcd := NewEtcdForTest(t) - client, _ := NewGrpcClientsWithEtcdForTest(t, etcd) - ch := client.getWakeupChannelForTesting() - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - assert.Empty(client.GetClients()) - - drainWakeupChannel(ch) - _, addr1 := NewGrpcServerForTest(t) - SetEtcdValue(etcd, "/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) - waitForEvent(ctx, t, ch) - if clients := client.GetClients(); assert.Len(clients, 1) { - assert.Equal(addr1, clients[0].Target()) - } - - drainWakeupChannel(ch) - _, addr2 := NewGrpcServerForTest(t) - SetEtcdValue(etcd, "/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) - waitForEvent(ctx, t, ch) - if clients := client.GetClients(); assert.Len(clients, 2) { - assert.Equal(addr1, clients[0].Target()) - assert.Equal(addr2, clients[1].Target()) - } - - drainWakeupChannel(ch) - DeleteEtcdValue(etcd, "/grpctargets/one") - waitForEvent(ctx, t, ch) - if clients := client.GetClients(); assert.Len(clients, 1) { - assert.Equal(addr2, clients[0].Target()) - } - - drainWakeupChannel(ch) - _, addr3 := NewGrpcServerForTest(t) - SetEtcdValue(etcd, "/grpctargets/two", []byte("{\"address\":\""+addr3+"\"}")) - waitForEvent(ctx, t, ch) - if clients := client.GetClients(); assert.Len(clients, 1) { - assert.Equal(addr3, clients[0].Target()) - } -} - -func Test_GrpcClients_EtcdIgnoreSelf(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - assert := assert.New(t) - etcd := NewEtcdForTest(t) - client, _ := NewGrpcClientsWithEtcdForTest(t, etcd) - ch := client.getWakeupChannelForTesting() - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - assert.Empty(client.GetClients()) - - drainWakeupChannel(ch) - _, addr1 := NewGrpcServerForTest(t) - SetEtcdValue(etcd, "/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) - waitForEvent(ctx, t, ch) - if clients := client.GetClients(); assert.Len(clients, 1) { - assert.Equal(addr1, clients[0].Target()) - } - - drainWakeupChannel(ch) - server2, addr2 := NewGrpcServerForTest(t) - server2.serverId = GrpcServerId - SetEtcdValue(etcd, "/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) - waitForEvent(ctx, t, ch) - client.selfCheckWaitGroup.Wait() - if clients := client.GetClients(); assert.Len(clients, 1) { - assert.Equal(addr1, clients[0].Target()) - } - - drainWakeupChannel(ch) - DeleteEtcdValue(etcd, "/grpctargets/two") - waitForEvent(ctx, t, ch) - if clients := client.GetClients(); assert.Len(clients, 1) { - assert.Equal(addr1, clients[0].Target()) - } -} - -func Test_GrpcClients_DnsDiscovery(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { - assert := assert.New(t) - lookup := newMockDnsLookupForTest(t) - target := "testgrpc:12345" - ip1 := net.ParseIP("192.168.0.1") - ip2 := net.ParseIP("192.168.0.2") - targetWithIp1 := fmt.Sprintf("%s (%s)", target, ip1) - targetWithIp2 := fmt.Sprintf("%s (%s)", target, ip2) - lookup.Set("testgrpc", []net.IP{ip1}) - client, dnsMonitor := NewGrpcClientsForTest(t, target) - ch := client.getWakeupChannelForTesting() - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - dnsMonitor.checkHostnames() - if clients := client.GetClients(); assert.Len(clients, 1) { - assert.Equal(targetWithIp1, clients[0].Target()) - assert.True(clients[0].ip.Equal(ip1), "Expected IP %s, got %s", ip1, clients[0].ip) - } - - lookup.Set("testgrpc", []net.IP{ip1, ip2}) - drainWakeupChannel(ch) - dnsMonitor.checkHostnames() - waitForEvent(ctx, t, ch) - - if clients := client.GetClients(); assert.Len(clients, 2) { - assert.Equal(targetWithIp1, clients[0].Target()) - assert.True(clients[0].ip.Equal(ip1), "Expected IP %s, got %s", ip1, clients[0].ip) - assert.Equal(targetWithIp2, clients[1].Target()) - assert.True(clients[1].ip.Equal(ip2), "Expected IP %s, got %s", ip2, clients[1].ip) - } - - lookup.Set("testgrpc", []net.IP{ip2}) - drainWakeupChannel(ch) - dnsMonitor.checkHostnames() - waitForEvent(ctx, t, ch) - - if clients := client.GetClients(); assert.Len(clients, 1) { - assert.Equal(targetWithIp2, clients[0].Target()) - assert.True(clients[0].ip.Equal(ip2), "Expected IP %s, got %s", ip2, clients[0].ip) - } - }) -} - -func Test_GrpcClients_DnsDiscoveryInitialFailed(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - assert := assert.New(t) - lookup := newMockDnsLookupForTest(t) - target := "testgrpc:12345" - ip1 := net.ParseIP("192.168.0.1") - targetWithIp1 := fmt.Sprintf("%s (%s)", target, ip1) - client, dnsMonitor := NewGrpcClientsForTest(t, target) - ch := client.getWakeupChannelForTesting() - - testCtx, testCtxCancel := context.WithTimeout(context.Background(), testTimeout) - defer testCtxCancel() - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - require.NoError(t, client.WaitForInitialized(ctx)) - - assert.Empty(client.GetClients()) - - lookup.Set("testgrpc", []net.IP{ip1}) - drainWakeupChannel(ch) - dnsMonitor.checkHostnames() - waitForEvent(testCtx, t, ch) - - if clients := client.GetClients(); assert.Len(clients, 1) { - assert.Equal(targetWithIp1, clients[0].Target()) - assert.True(clients[0].ip.Equal(ip1), "Expected IP %s, got %s", ip1, clients[0].ip) - } -} - -func Test_GrpcClients_Encryption(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { - require := require.New(t) - serverKey, err := rsa.GenerateKey(rand.Reader, 1024) - require.NoError(err) - clientKey, err := rsa.GenerateKey(rand.Reader, 1024) - require.NoError(err) - - serverCert := GenerateSelfSignedCertificateForTesting(t, 1024, "Server cert", serverKey) - clientCert := GenerateSelfSignedCertificateForTesting(t, 1024, "Testing client", clientKey) - - dir := t.TempDir() - serverPrivkeyFile := path.Join(dir, "server-privkey.pem") - serverPubkeyFile := path.Join(dir, "server-pubkey.pem") - serverCertFile := path.Join(dir, "server-cert.pem") - WritePrivateKey(serverKey, serverPrivkeyFile) // nolint - WritePublicKey(&serverKey.PublicKey, serverPubkeyFile) // nolint - os.WriteFile(serverCertFile, serverCert, 0755) // nolint - clientPrivkeyFile := path.Join(dir, "client-privkey.pem") - clientPubkeyFile := path.Join(dir, "client-pubkey.pem") - clientCertFile := path.Join(dir, "client-cert.pem") - WritePrivateKey(clientKey, clientPrivkeyFile) // nolint - WritePublicKey(&clientKey.PublicKey, clientPubkeyFile) // nolint - os.WriteFile(clientCertFile, clientCert, 0755) // nolint - - serverConfig := goconf.NewConfigFile() - serverConfig.AddOption("grpc", "servercertificate", serverCertFile) - serverConfig.AddOption("grpc", "serverkey", serverPrivkeyFile) - serverConfig.AddOption("grpc", "clientca", clientCertFile) - _, addr := NewGrpcServerForTestWithConfig(t, serverConfig) - - clientConfig := goconf.NewConfigFile() - clientConfig.AddOption("grpc", "targets", addr) - clientConfig.AddOption("grpc", "clientcertificate", clientCertFile) - clientConfig.AddOption("grpc", "clientkey", clientPrivkeyFile) - clientConfig.AddOption("grpc", "serverca", serverCertFile) - clients, _ := NewGrpcClientsForTestWithConfig(t, clientConfig, nil) - - ctx, cancel1 := context.WithTimeout(context.Background(), time.Second) - defer cancel1() - - require.NoError(clients.WaitForInitialized(ctx)) - - for _, client := range clients.GetClients() { - _, _, err := client.GetServerId(ctx) - require.NoError(err) - } - }) -} diff --git a/grpc_internal.pb.go b/grpc_internal.pb.go deleted file mode 100644 index eee099c..0000000 --- a/grpc_internal.pb.go +++ /dev/null @@ -1,203 +0,0 @@ -//* -// Standalone signaling server for the Nextcloud Spreed app. -// Copyright (C) 2022 struktur AG -// -// @author Joachim Bauch -// -// @license GNU AGPL version 3 or any later version -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -// Code generated by protoc-gen-go. DO NOT EDIT. -// source: grpc_internal.proto - -package signaling - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type GetServerIdRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetServerIdRequest) Reset() { - *x = GetServerIdRequest{} - mi := &file_grpc_internal_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetServerIdRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetServerIdRequest) ProtoMessage() {} - -func (x *GetServerIdRequest) ProtoReflect() protoreflect.Message { - mi := &file_grpc_internal_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetServerIdRequest.ProtoReflect.Descriptor instead. -func (*GetServerIdRequest) Descriptor() ([]byte, []int) { - return file_grpc_internal_proto_rawDescGZIP(), []int{0} -} - -type GetServerIdReply struct { - state protoimpl.MessageState `protogen:"open.v1"` - ServerId string `protobuf:"bytes,1,opt,name=serverId,proto3" json:"serverId,omitempty"` - Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetServerIdReply) Reset() { - *x = GetServerIdReply{} - mi := &file_grpc_internal_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetServerIdReply) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetServerIdReply) ProtoMessage() {} - -func (x *GetServerIdReply) ProtoReflect() protoreflect.Message { - mi := &file_grpc_internal_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetServerIdReply.ProtoReflect.Descriptor instead. -func (*GetServerIdReply) Descriptor() ([]byte, []int) { - return file_grpc_internal_proto_rawDescGZIP(), []int{1} -} - -func (x *GetServerIdReply) GetServerId() string { - if x != nil { - return x.ServerId - } - return "" -} - -func (x *GetServerIdReply) GetVersion() string { - if x != nil { - return x.Version - } - return "" -} - -var File_grpc_internal_proto protoreflect.FileDescriptor - -var file_grpc_internal_proto_rawDesc = []byte{ - 0x0a, 0x13, 0x67, 0x72, 0x70, 0x63, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, - 0x22, 0x14, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x48, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x49, 0x64, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x32, 0x5a, 0x0a, 0x0b, 0x52, 0x70, 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x12, - 0x4b, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, - 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, - 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x49, 0x64, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x42, 0x3c, 0x5a, 0x3a, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x73, 0x74, 0x72, 0x75, 0x6b, - 0x74, 0x75, 0x72, 0x61, 0x67, 0x2f, 0x6e, 0x65, 0x78, 0x74, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, - 0x73, 0x70, 0x72, 0x65, 0x65, 0x64, 0x2d, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, - 0x3b, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, -} - -var ( - file_grpc_internal_proto_rawDescOnce sync.Once - file_grpc_internal_proto_rawDescData = file_grpc_internal_proto_rawDesc -) - -func file_grpc_internal_proto_rawDescGZIP() []byte { - file_grpc_internal_proto_rawDescOnce.Do(func() { - file_grpc_internal_proto_rawDescData = protoimpl.X.CompressGZIP(file_grpc_internal_proto_rawDescData) - }) - return file_grpc_internal_proto_rawDescData -} - -var file_grpc_internal_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_grpc_internal_proto_goTypes = []any{ - (*GetServerIdRequest)(nil), // 0: signaling.GetServerIdRequest - (*GetServerIdReply)(nil), // 1: signaling.GetServerIdReply -} -var file_grpc_internal_proto_depIdxs = []int32{ - 0, // 0: signaling.RpcInternal.GetServerId:input_type -> signaling.GetServerIdRequest - 1, // 1: signaling.RpcInternal.GetServerId:output_type -> signaling.GetServerIdReply - 1, // [1:2] is the sub-list for method output_type - 0, // [0:1] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_grpc_internal_proto_init() } -func file_grpc_internal_proto_init() { - if File_grpc_internal_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_grpc_internal_proto_rawDesc, - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_grpc_internal_proto_goTypes, - DependencyIndexes: file_grpc_internal_proto_depIdxs, - MessageInfos: file_grpc_internal_proto_msgTypes, - }.Build() - File_grpc_internal_proto = out.File - file_grpc_internal_proto_rawDesc = nil - file_grpc_internal_proto_goTypes = nil - file_grpc_internal_proto_depIdxs = nil -} diff --git a/grpc_mcu.pb.go b/grpc_mcu.pb.go deleted file mode 100644 index 59ef5fc..0000000 --- a/grpc_mcu.pb.go +++ /dev/null @@ -1,232 +0,0 @@ -//* -// Standalone signaling server for the Nextcloud Spreed app. -// Copyright (C) 2022 struktur AG -// -// @author Joachim Bauch -// -// @license GNU AGPL version 3 or any later version -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -// Code generated by protoc-gen-go. DO NOT EDIT. -// source: grpc_mcu.proto - -package signaling - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type GetPublisherIdRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - SessionId string `protobuf:"bytes,1,opt,name=sessionId,proto3" json:"sessionId,omitempty"` - StreamType string `protobuf:"bytes,2,opt,name=streamType,proto3" json:"streamType,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetPublisherIdRequest) Reset() { - *x = GetPublisherIdRequest{} - mi := &file_grpc_mcu_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetPublisherIdRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetPublisherIdRequest) ProtoMessage() {} - -func (x *GetPublisherIdRequest) ProtoReflect() protoreflect.Message { - mi := &file_grpc_mcu_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetPublisherIdRequest.ProtoReflect.Descriptor instead. -func (*GetPublisherIdRequest) Descriptor() ([]byte, []int) { - return file_grpc_mcu_proto_rawDescGZIP(), []int{0} -} - -func (x *GetPublisherIdRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *GetPublisherIdRequest) GetStreamType() string { - if x != nil { - return x.StreamType - } - return "" -} - -type GetPublisherIdReply struct { - state protoimpl.MessageState `protogen:"open.v1"` - PublisherId string `protobuf:"bytes,1,opt,name=publisherId,proto3" json:"publisherId,omitempty"` - ProxyUrl string `protobuf:"bytes,2,opt,name=proxyUrl,proto3" json:"proxyUrl,omitempty"` - Ip string `protobuf:"bytes,3,opt,name=ip,proto3" json:"ip,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetPublisherIdReply) Reset() { - *x = GetPublisherIdReply{} - mi := &file_grpc_mcu_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetPublisherIdReply) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetPublisherIdReply) ProtoMessage() {} - -func (x *GetPublisherIdReply) ProtoReflect() protoreflect.Message { - mi := &file_grpc_mcu_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetPublisherIdReply.ProtoReflect.Descriptor instead. -func (*GetPublisherIdReply) Descriptor() ([]byte, []int) { - return file_grpc_mcu_proto_rawDescGZIP(), []int{1} -} - -func (x *GetPublisherIdReply) GetPublisherId() string { - if x != nil { - return x.PublisherId - } - return "" -} - -func (x *GetPublisherIdReply) GetProxyUrl() string { - if x != nil { - return x.ProxyUrl - } - return "" -} - -func (x *GetPublisherIdReply) GetIp() string { - if x != nil { - return x.Ip - } - return "" -} - -var File_grpc_mcu_proto protoreflect.FileDescriptor - -var file_grpc_mcu_proto_rawDesc = []byte{ - 0x0a, 0x0e, 0x67, 0x72, 0x70, 0x63, 0x5f, 0x6d, 0x63, 0x75, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x12, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x22, 0x55, 0x0a, 0x15, 0x47, - 0x65, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x49, 0x64, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x54, 0x79, 0x70, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x54, 0x79, - 0x70, 0x65, 0x22, 0x63, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, - 0x65, 0x72, 0x49, 0x64, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x20, 0x0a, 0x0b, 0x70, 0x75, 0x62, - 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, - 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, - 0x72, 0x6f, 0x78, 0x79, 0x55, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, - 0x72, 0x6f, 0x78, 0x79, 0x55, 0x72, 0x6c, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x70, 0x32, 0x5e, 0x0a, 0x06, 0x52, 0x70, 0x63, 0x4d, 0x63, - 0x75, 0x12, 0x54, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, - 0x72, 0x49, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x2e, - 0x47, 0x65, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x49, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, - 0x67, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x49, 0x64, - 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x42, 0x3c, 0x5a, 0x3a, 0x67, 0x69, 0x74, 0x68, 0x75, - 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x73, 0x74, 0x72, 0x75, 0x6b, 0x74, 0x75, 0x72, 0x61, 0x67, - 0x2f, 0x6e, 0x65, 0x78, 0x74, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x70, 0x72, 0x65, 0x65, - 0x64, 0x2d, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x3b, 0x73, 0x69, 0x67, 0x6e, - 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} - -var ( - file_grpc_mcu_proto_rawDescOnce sync.Once - file_grpc_mcu_proto_rawDescData = file_grpc_mcu_proto_rawDesc -) - -func file_grpc_mcu_proto_rawDescGZIP() []byte { - file_grpc_mcu_proto_rawDescOnce.Do(func() { - file_grpc_mcu_proto_rawDescData = protoimpl.X.CompressGZIP(file_grpc_mcu_proto_rawDescData) - }) - return file_grpc_mcu_proto_rawDescData -} - -var file_grpc_mcu_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_grpc_mcu_proto_goTypes = []any{ - (*GetPublisherIdRequest)(nil), // 0: signaling.GetPublisherIdRequest - (*GetPublisherIdReply)(nil), // 1: signaling.GetPublisherIdReply -} -var file_grpc_mcu_proto_depIdxs = []int32{ - 0, // 0: signaling.RpcMcu.GetPublisherId:input_type -> signaling.GetPublisherIdRequest - 1, // 1: signaling.RpcMcu.GetPublisherId:output_type -> signaling.GetPublisherIdReply - 1, // [1:2] is the sub-list for method output_type - 0, // [0:1] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_grpc_mcu_proto_init() } -func file_grpc_mcu_proto_init() { - if File_grpc_mcu_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_grpc_mcu_proto_rawDesc, - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_grpc_mcu_proto_goTypes, - DependencyIndexes: file_grpc_mcu_proto_depIdxs, - MessageInfos: file_grpc_mcu_proto_msgTypes, - }.Build() - File_grpc_mcu_proto = out.File - file_grpc_mcu_proto_rawDesc = nil - file_grpc_mcu_proto_goTypes = nil - file_grpc_mcu_proto_depIdxs = nil -} diff --git a/grpc_server.go b/grpc_server.go deleted file mode 100644 index 77c6aec..0000000 --- a/grpc_server.go +++ /dev/null @@ -1,308 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "log" - "net" - "net/url" - "os" - - "github.com/dlintw/goconf" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials" - status "google.golang.org/grpc/status" -) - -var ( - GrpcServerId string -) - -func init() { - RegisterGrpcServerStats() - - hostname, err := os.Hostname() - if err != nil { - hostname = newRandomString(8) - } - md := sha256.New() - md.Write([]byte(fmt.Sprintf("%s-%s-%d", newRandomString(32), hostname, os.Getpid()))) - GrpcServerId = hex.EncodeToString(md.Sum(nil)) -} - -type GrpcServerHub interface { - GetSessionByResumeId(resumeId string) Session - GetSessionByPublicId(sessionId string) Session - GetSessionIdByRoomSessionId(roomSessionId string) (string, error) - GetRoomForBackend(roomId string, backend *Backend) *Room - - GetBackend(u *url.URL) *Backend -} - -type GrpcServer struct { - UnimplementedRpcBackendServer - UnimplementedRpcInternalServer - UnimplementedRpcMcuServer - UnimplementedRpcSessionsServer - - version string - creds credentials.TransportCredentials - conn *grpc.Server - listener net.Listener - serverId string // can be overwritten from tests - - hub GrpcServerHub -} - -func NewGrpcServer(config *goconf.ConfigFile, version string) (*GrpcServer, error) { - var listener net.Listener - if addr, _ := GetStringOptionWithEnv(config, "grpc", "listen"); addr != "" { - var err error - listener, err = net.Listen("tcp", addr) - if err != nil { - return nil, fmt.Errorf("could not create GRPC listener %s: %w", addr, err) - } - } - - creds, err := NewReloadableCredentials(config, true) - if err != nil { - return nil, err - } - - conn := grpc.NewServer(grpc.Creds(creds)) - result := &GrpcServer{ - version: version, - creds: creds, - conn: conn, - listener: listener, - serverId: GrpcServerId, - } - RegisterRpcBackendServer(conn, result) - RegisterRpcInternalServer(conn, result) - RegisterRpcSessionsServer(conn, result) - RegisterRpcMcuServer(conn, result) - return result, nil -} - -func (s *GrpcServer) Run() error { - if s.listener == nil { - return nil - } - - return s.conn.Serve(s.listener) -} - -func (s *GrpcServer) Close() { - s.conn.GracefulStop() - if cr, ok := s.creds.(*reloadableCredentials); ok { - cr.Close() - } -} - -func (s *GrpcServer) LookupResumeId(ctx context.Context, request *LookupResumeIdRequest) (*LookupResumeIdReply, error) { - statsGrpcServerCalls.WithLabelValues("LookupResumeId").Inc() - // TODO: Remove debug logging - log.Printf("Lookup session for resume id %s", request.ResumeId) - session := s.hub.GetSessionByResumeId(request.ResumeId) - if session == nil { - return nil, status.Error(codes.NotFound, "no such room session id") - } - - return &LookupResumeIdReply{ - SessionId: session.PublicId(), - }, nil -} - -func (s *GrpcServer) LookupSessionId(ctx context.Context, request *LookupSessionIdRequest) (*LookupSessionIdReply, error) { - statsGrpcServerCalls.WithLabelValues("LookupSessionId").Inc() - // TODO: Remove debug logging - log.Printf("Lookup session id for room session id %s", request.RoomSessionId) - sid, err := s.hub.GetSessionIdByRoomSessionId(request.RoomSessionId) - if errors.Is(err, ErrNoSuchRoomSession) { - return nil, status.Error(codes.NotFound, "no such room session id") - } else if err != nil { - return nil, err - } - - if sid != "" && request.DisconnectReason != "" { - if session := s.hub.GetSessionByPublicId(sid); session != nil { - log.Printf("Closing session %s because same room session %s connected", session.PublicId(), request.RoomSessionId) - session.LeaveRoom(false) - switch sess := session.(type) { - case *ClientSession: - if client := sess.GetClient(); client != nil { - client.SendByeResponseWithReason(nil, "room_session_reconnected") - } - } - session.Close() - } - } - return &LookupSessionIdReply{ - SessionId: sid, - }, nil -} - -func (s *GrpcServer) IsSessionInCall(ctx context.Context, request *IsSessionInCallRequest) (*IsSessionInCallReply, error) { - statsGrpcServerCalls.WithLabelValues("IsSessionInCall").Inc() - // TODO: Remove debug logging - log.Printf("Check if session %s is in call %s on %s", request.SessionId, request.RoomId, request.BackendUrl) - session := s.hub.GetSessionByPublicId(request.SessionId) - if session == nil { - return nil, status.Error(codes.NotFound, "no such session id") - } - - result := &IsSessionInCallReply{} - room := session.GetRoom() - if room == nil || room.Id() != request.GetRoomId() || room.Backend().url != request.GetBackendUrl() || - (session.ClientType() != HelloClientTypeInternal && !room.IsSessionInCall(session)) { - // Recipient is not in a room, a different room or not in the call. - result.InCall = false - } else { - result.InCall = true - } - return result, nil -} - -func (s *GrpcServer) GetInternalSessions(ctx context.Context, request *GetInternalSessionsRequest) (*GetInternalSessionsReply, error) { - statsGrpcServerCalls.WithLabelValues("GetInternalSessions").Inc() - // TODO: Remove debug logging - log.Printf("Get internal sessions from %s on %s", request.RoomId, request.BackendUrl) - - var u *url.URL - if request.BackendUrl != "" { - var err error - u, err = url.Parse(request.BackendUrl) - if err != nil { - return nil, status.Error(codes.InvalidArgument, "invalid url") - } - } - - backend := s.hub.GetBackend(u) - if backend == nil { - return nil, status.Error(codes.NotFound, "no such backend") - } - - room := s.hub.GetRoomForBackend(request.RoomId, backend) - if room == nil { - return nil, status.Error(codes.NotFound, "no such room") - } - - result := &GetInternalSessionsReply{} - room.mu.RLock() - defer room.mu.RUnlock() - - for session := range room.internalSessions { - result.InternalSessions = append(result.InternalSessions, &InternalSessionData{ - SessionId: session.PublicId(), - InCall: uint32(session.GetInCall()), - Features: session.GetFeatures(), - }) - } - - for session := range room.virtualSessions { - result.VirtualSessions = append(result.VirtualSessions, &VirtualSessionData{ - SessionId: session.PublicId(), - InCall: uint32(session.GetInCall()), - }) - } - - return result, nil -} - -func (s *GrpcServer) GetPublisherId(ctx context.Context, request *GetPublisherIdRequest) (*GetPublisherIdReply, error) { - statsGrpcServerCalls.WithLabelValues("GetPublisherId").Inc() - // TODO: Remove debug logging - log.Printf("Get %s publisher id for session %s", request.StreamType, request.SessionId) - session := s.hub.GetSessionByPublicId(request.SessionId) - if session == nil { - return nil, status.Error(codes.NotFound, "no such session") - } - - clientSession, ok := session.(*ClientSession) - if !ok { - return nil, status.Error(codes.NotFound, "no such session") - } - - publisher := clientSession.GetOrWaitForPublisher(ctx, StreamType(request.StreamType)) - if publisher, ok := publisher.(*mcuProxyPublisher); ok { - reply := &GetPublisherIdReply{ - PublisherId: publisher.Id(), - ProxyUrl: publisher.conn.rawUrl, - } - if ip := publisher.conn.ip; ip != nil { - reply.Ip = ip.String() - } - return reply, nil - } - - return nil, status.Error(codes.NotFound, "no such publisher") -} - -func (s *GrpcServer) GetServerId(ctx context.Context, request *GetServerIdRequest) (*GetServerIdReply, error) { - statsGrpcServerCalls.WithLabelValues("GetServerId").Inc() - return &GetServerIdReply{ - ServerId: s.serverId, - Version: s.version, - }, nil -} - -func (s *GrpcServer) GetSessionCount(ctx context.Context, request *GetSessionCountRequest) (*GetSessionCountReply, error) { - statsGrpcServerCalls.WithLabelValues("SessionCount").Inc() - - u, err := url.Parse(request.Url) - if err != nil { - return nil, status.Error(codes.InvalidArgument, "invalid url") - } - - backend := s.hub.GetBackend(u) - if backend == nil { - return nil, status.Error(codes.NotFound, "no such backend") - } - - return &GetSessionCountReply{ - Count: uint32(backend.Len()), - }, nil -} - -func (s *GrpcServer) ProxySession(request RpcSessions_ProxySessionServer) error { - statsGrpcServerCalls.WithLabelValues("ProxySession").Inc() - hub, ok := s.hub.(*Hub) - if !ok { - return status.Error(codes.Internal, "invalid hub type") - - } - client, err := newRemoteGrpcClient(hub, request) - if err != nil { - return err - } - - sid := hub.registerClient(client) - defer hub.unregisterClient(sid) - - return client.run() -} diff --git a/grpc_server_test.go b/grpc_server_test.go deleted file mode 100644 index e985fd2..0000000 --- a/grpc_server_test.go +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "encoding/pem" - "errors" - "net" - "os" - "path" - "strconv" - "testing" - "time" - - "github.com/dlintw/goconf" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" -) - -func (s *GrpcServer) WaitForCertificateReload(ctx context.Context) error { - c, ok := s.creds.(*reloadableCredentials) - if !ok { - return errors.New("no reloadable credentials found") - } - - return c.WaitForCertificateReload(ctx) -} - -func (s *GrpcServer) WaitForCertPoolReload(ctx context.Context) error { - c, ok := s.creds.(*reloadableCredentials) - if !ok { - return errors.New("no reloadable credentials found") - } - - return c.WaitForCertPoolReload(ctx) -} - -func NewGrpcServerForTestWithConfig(t *testing.T, config *goconf.ConfigFile) (server *GrpcServer, addr string) { - for port := 50000; port < 50100; port++ { - addr = net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) - config.AddOption("grpc", "listen", addr) - var err error - server, err = NewGrpcServer(config, "0.0.0") - if isErrorAddressAlreadyInUse(err) { - continue - } - - require.NoError(t, err) - break - } - - require.NotNil(t, server, "could not find free port") - - // Don't match with own server id by default. - server.serverId = "dont-match" - - go func() { - assert.NoError(t, server.Run(), "could not start GRPC server") - }() - - t.Cleanup(func() { - server.Close() - }) - return server, addr -} - -func NewGrpcServerForTest(t *testing.T) (server *GrpcServer, addr string) { - config := goconf.NewConfigFile() - return NewGrpcServerForTestWithConfig(t, config) -} - -func Test_GrpcServer_ReloadCerts(t *testing.T) { - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - key, err := rsa.GenerateKey(rand.Reader, 1024) - require.NoError(err) - - org1 := "Testing certificate" - cert1 := GenerateSelfSignedCertificateForTesting(t, 1024, org1, key) - - dir := t.TempDir() - privkeyFile := path.Join(dir, "privkey.pem") - pubkeyFile := path.Join(dir, "pubkey.pem") - certFile := path.Join(dir, "cert.pem") - WritePrivateKey(key, privkeyFile) // nolint - WritePublicKey(&key.PublicKey, pubkeyFile) // nolint - os.WriteFile(certFile, cert1, 0755) // nolint - - config := goconf.NewConfigFile() - config.AddOption("grpc", "servercertificate", certFile) - config.AddOption("grpc", "serverkey", privkeyFile) - - UpdateCertificateCheckIntervalForTest(t, 0) - server, addr := NewGrpcServerForTestWithConfig(t, config) - - cp1 := x509.NewCertPool() - if !cp1.AppendCertsFromPEM(cert1) { - require.Fail("could not add certificate") - } - - cfg1 := &tls.Config{ - RootCAs: cp1, - } - conn1, err := tls.Dial("tcp", addr, cfg1) - require.NoError(err) - defer conn1.Close() // nolint - state1 := conn1.ConnectionState() - if certs := state1.PeerCertificates; assert.NotEmpty(certs) { - if assert.NotEmpty(certs[0].Subject.Organization) { - assert.Equal(org1, certs[0].Subject.Organization[0]) - } - } - - org2 := "Updated certificate" - cert2 := GenerateSelfSignedCertificateForTesting(t, 1024, org2, key) - replaceFile(t, certFile, cert2, 0755) - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - require.NoError(server.WaitForCertificateReload(ctx)) - - cp2 := x509.NewCertPool() - if !cp2.AppendCertsFromPEM(cert2) { - require.Fail("could not add certificate") - } - - cfg2 := &tls.Config{ - RootCAs: cp2, - } - conn2, err := tls.Dial("tcp", addr, cfg2) - require.NoError(err) - defer conn2.Close() // nolint - state2 := conn2.ConnectionState() - if certs := state2.PeerCertificates; assert.NotEmpty(certs) { - if assert.NotEmpty(certs[0].Subject.Organization) { - assert.Equal(org2, certs[0].Subject.Organization[0]) - } - } -} - -func Test_GrpcServer_ReloadCA(t *testing.T) { - CatchLogForTest(t) - require := require.New(t) - serverKey, err := rsa.GenerateKey(rand.Reader, 1024) - require.NoError(err) - clientKey, err := rsa.GenerateKey(rand.Reader, 1024) - require.NoError(err) - - serverCert := GenerateSelfSignedCertificateForTesting(t, 1024, "Server cert", serverKey) - org1 := "Testing client" - clientCert1 := GenerateSelfSignedCertificateForTesting(t, 1024, org1, clientKey) - - dir := t.TempDir() - privkeyFile := path.Join(dir, "privkey.pem") - pubkeyFile := path.Join(dir, "pubkey.pem") - certFile := path.Join(dir, "cert.pem") - caFile := path.Join(dir, "ca.pem") - WritePrivateKey(serverKey, privkeyFile) // nolint - WritePublicKey(&serverKey.PublicKey, pubkeyFile) // nolint - os.WriteFile(certFile, serverCert, 0755) // nolint - os.WriteFile(caFile, clientCert1, 0755) // nolint - - config := goconf.NewConfigFile() - config.AddOption("grpc", "servercertificate", certFile) - config.AddOption("grpc", "serverkey", privkeyFile) - config.AddOption("grpc", "clientca", caFile) - - UpdateCertificateCheckIntervalForTest(t, 0) - server, addr := NewGrpcServerForTestWithConfig(t, config) - - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(serverCert) { - require.Fail("could not add certificate") - } - - pair1, err := tls.X509KeyPair(clientCert1, pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(clientKey), - })) - require.NoError(err) - - cfg1 := &tls.Config{ - RootCAs: pool, - Certificates: []tls.Certificate{pair1}, - } - client1, err := NewGrpcClient(addr, nil, grpc.WithTransportCredentials(credentials.NewTLS(cfg1))) - require.NoError(err) - defer client1.Close() // nolint - - ctx1, cancel1 := context.WithTimeout(context.Background(), time.Second) - defer cancel1() - - _, _, err = client1.GetServerId(ctx1) - require.NoError(err) - - org2 := "Updated client" - clientCert2 := GenerateSelfSignedCertificateForTesting(t, 1024, org2, clientKey) - replaceFile(t, caFile, clientCert2, 0755) - - require.NoError(server.WaitForCertPoolReload(ctx1)) - - pair2, err := tls.X509KeyPair(clientCert2, pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(clientKey), - })) - require.NoError(err) - - cfg2 := &tls.Config{ - RootCAs: pool, - Certificates: []tls.Certificate{pair2}, - } - client2, err := NewGrpcClient(addr, nil, grpc.WithTransportCredentials(credentials.NewTLS(cfg2))) - require.NoError(err) - defer client2.Close() // nolint - - ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second) - defer cancel2() - - // This will fail if the CA certificate has not been reloaded by the server. - _, _, err = client2.GetServerId(ctx2) - require.NoError(err) -} diff --git a/internal/as_error.go b/internal/as_error.go new file mode 100644 index 0000000..a7ab70e --- /dev/null +++ b/internal/as_error.go @@ -0,0 +1,50 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "errors" +) + +// AsErrorType finds the first error in err's tree that matches the type E, +// and if one is found, returns that error value and true. Otherwise, it +// returns the zero value of E and false. +// +// The tree consists of err itself, followed by the errors obtained by +// repeatedly calling its Unwrap() error or Unwrap() []error method. +// When err wraps multiple errors, AsErrorType examines err followed by a +// depth-first traversal of its children. +// +// An error err matches the type E if the type assertion err.(E) holds, +// or if the error has a method As(any) bool such that err.As(target) +// returns true when target is a non-nil *E. In the latter case, the As +// method is responsible for setting target. +func AsErrorType[E error](err error) (E, bool) { + var e E + if err == nil { + return e, false + } else if errors.As(err, &e) { + return e, true + } + + return e, false +} diff --git a/internal/as_error_test.go b/internal/as_error_test.go new file mode 100644 index 0000000..5fc2564 --- /dev/null +++ b/internal/as_error_test.go @@ -0,0 +1,61 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type testError struct{} + +func (e testError) Error() string { + return "test error" +} + +func TestAsErrorType(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + + if e, ok := AsErrorType[*testError](nil); assert.False(ok) { + assert.Nil(e) + } + + err1 := &testError{} + if e, ok := AsErrorType[*testError](err1); assert.True(ok) { + assert.Same(err1, e) + } + + err2 := errors.New("other error") + if e, ok := AsErrorType[*testError](err2); assert.False(ok) { + assert.Nil(e) + } + + err3 := fmt.Errorf("wrapped error: %w", err1) + if e, ok := AsErrorType[*testError](err3); assert.True(ok) { + assert.Same(err1, e) + } +} diff --git a/internal/canonicalize_url.go b/internal/canonicalize_url.go new file mode 100644 index 0000000..dabdf31 --- /dev/null +++ b/internal/canonicalize_url.go @@ -0,0 +1,60 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "net/url" + "strings" +) + +func hasStandardPort(u *url.URL) bool { + switch u.Scheme { + case "http": + return u.Port() == "80" + case "https": + return u.Port() == "443" + default: + return false + } +} + +func CanonicalizeUrl(u *url.URL) (*url.URL, bool) { + var changed bool + if strings.Contains(u.Host, ":") && hasStandardPort(u) { + u.Host = u.Hostname() + changed = true + } + return u, changed +} + +func CanonicalizeUrlString(s string) (string, error) { + u, err := url.Parse(s) + if err != nil { + return s, err + } + + if strings.Contains(u.Host, ":") && hasStandardPort(u) { + u.Host = u.Hostname() + s = u.String() + } + return s, nil +} diff --git a/internal/canonicalize_url_test.go b/internal/canonicalize_url_test.go new file mode 100644 index 0000000..9c9b1a1 --- /dev/null +++ b/internal/canonicalize_url_test.go @@ -0,0 +1,127 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCanonicalizeUrl(t *testing.T) { + t.Parallel() + mustParse := func(s string) *url.URL { + t.Helper() + u, err := url.Parse(s) + require.NoError(t, err) + return u + } + testcases := []struct { + url *url.URL + expected *url.URL + }{ + { + url: mustParse("http://server.domain.tld:80/foo/"), + expected: mustParse("http://server.domain.tld/foo/"), + }, + { + url: mustParse("http://server.domain.tld:81/foo/"), + expected: mustParse("http://server.domain.tld:81/foo/"), + }, + { + url: mustParse("https://server.domain.tld:443/foo/"), + expected: mustParse("https://server.domain.tld/foo/"), + }, + { + url: mustParse("https://server.domain.tld:444/foo/"), + expected: mustParse("https://server.domain.tld:444/foo/"), + }, + { + url: mustParse("foo://server.domain.tld:443/foo/"), + expected: mustParse("foo://server.domain.tld:443/foo/"), + }, + } + + assert := assert.New(t) + for idx, tc := range testcases { + expectChanged := tc.url.String() != tc.expected.String() + canonicalized, changed := CanonicalizeUrl(tc.url) + assert.Equal(tc.url, canonicalized) // urls will be changed inplace + if !expectChanged { + assert.False(changed, "testcase %d should not have changed the url", idx) + continue + } + + if assert.True(changed, "testcase %d: should have changed the url", idx) { + assert.Equal(tc.expected, canonicalized, "testcase %d failed", idx) + } + } +} + +func TestCanonicalizeUrlString(t *testing.T) { + t.Parallel() + testcases := []struct { + s string + expected string + err string + }{ + { + s: "http://server.domain.tld:80/foo/", + expected: "http://server.domain.tld/foo/", + }, + { + s: "http://server.domain.tld:81/foo/", + expected: "http://server.domain.tld:81/foo/", + }, + { + s: "https://server.domain.tld:443/foo/", + expected: "https://server.domain.tld/foo/", + }, + { + s: "https://server.domain.tld:444/foo/", + expected: "https://server.domain.tld:444/foo/", + }, + { + s: "foo://server.domain.tld:443/foo/", + expected: "foo://server.domain.tld:443/foo/", + }, + { + s: "://server.domain.tld:443/foo/", + err: "missing protocol", + }, + } + + assert := assert.New(t) + for idx, tc := range testcases { + canonicalized, err := CanonicalizeUrlString(tc.s) + if tc.err != "" { + assert.ErrorContains(err, tc.err, "testcase %d failed", idx) + continue + } + + if assert.NoError(err, "testcase %d: should not have failed", idx) { + assert.Equal(tc.expected, canonicalized, "testcase %d failed", idx) + } + } +} diff --git a/closer.go b/internal/closer.go similarity index 98% rename from closer.go rename to internal/closer.go index ea00769..62ed06f 100644 --- a/closer.go +++ b/internal/closer.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package internal import ( "sync/atomic" diff --git a/closer_test.go b/internal/closer_test.go similarity index 93% rename from closer_test.go rename to internal/closer_test.go index ab23621..519a7cc 100644 --- a/closer_test.go +++ b/internal/closer_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package internal import ( "sync" @@ -29,16 +29,15 @@ import ( ) func TestCloserMulti(t *testing.T) { + t.Parallel() closer := NewCloser() var wg sync.WaitGroup count := 10 - for i := 0; i < count; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range count { + wg.Go(func() { <-closer.C - }() + }) } assert.False(t, closer.IsClosed()) @@ -48,6 +47,7 @@ func TestCloserMulti(t *testing.T) { } func TestCloserCloseBeforeWait(t *testing.T) { + t.Parallel() closer := NewCloser() closer.Close() assert.True(t, closer.IsClosed()) diff --git a/grpc_common_test.go b/internal/crypto_helpers.go similarity index 76% rename from grpc_common_test.go rename to internal/crypto_helpers.go index 878efd0..fb4b18c 100644 --- a/grpc_common_test.go +++ b/internal/crypto_helpers.go @@ -1,6 +1,6 @@ /** * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG + * Copyright (C) 2025 struktur AG * * @author Joachim Bauch * @@ -19,17 +19,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package internal import ( - "context" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" - "errors" - "io/fs" "math/big" "net" "os" @@ -39,23 +36,8 @@ import ( "github.com/stretchr/testify/require" ) -func (c *reloadableCredentials) WaitForCertificateReload(ctx context.Context) error { - if c.loader == nil { - return errors.New("no certificate loaded") - } - - return c.loader.WaitForReload(ctx) -} - -func (c *reloadableCredentials) WaitForCertPoolReload(ctx context.Context) error { - if c.pool == nil { - return errors.New("no certificate pool loaded") - } - - return c.pool.WaitForReload(ctx) -} - -func GenerateSelfSignedCertificateForTesting(t *testing.T, bits int, organization string, key *rsa.PrivateKey) []byte { +func GenerateSelfSignedCertificateForTesting(t *testing.T, organization string, key *rsa.PrivateKey) *x509.Certificate { + t.Helper() template := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{ @@ -70,17 +52,17 @@ func GenerateSelfSignedCertificateForTesting(t *testing.T, bits int, organizatio x509.ExtKeyUsageServerAuth, }, BasicConstraintsValid: true, + DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, } data, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) require.NoError(t, err) - data = pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", - Bytes: data, - }) - return data + cert, err := x509.ParseCertificate(data) + require.NoError(t, err) + + return cert } func WritePrivateKey(key *rsa.PrivateKey, filename string) error { @@ -106,14 +88,28 @@ func WritePublicKey(key *rsa.PublicKey, filename string) error { return os.WriteFile(filename, data, 0755) } -func replaceFile(t *testing.T, filename string, data []byte, perm fs.FileMode) { +func WriteCertificate(cert *x509.Certificate, filename string) error { + data := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + + return os.WriteFile(filename, data, 0755) +} + +func ReplaceCertificate(t *testing.T, filename string, cert *x509.Certificate) { t.Helper() require := require.New(t) oldStat, err := os.Stat(filename) require.NoError(err, "can't stat old file %s", filename) + data := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + for { - require.NoError(os.WriteFile(filename, data, perm), "can't write file %s", filename) + require.NoError(os.WriteFile(filename, data, 0755), "can't write file %s", filename) newStat, err := os.Stat(filename) require.NoError(err, "can't stat new file %s", filename) diff --git a/internal/crypto_helpers_test.go b/internal/crypto_helpers_test.go new file mode 100644 index 0000000..e52b168 --- /dev/null +++ b/internal/crypto_helpers_test.go @@ -0,0 +1,119 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "os" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateSelfSignedCertificateForTesting(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + bits := 1024 + key, err := rsa.GenerateKey(rand.Reader, bits) + require.NoError(err) + cert := GenerateSelfSignedCertificateForTesting(t, "Testing", key) + require.NotNil(cert) + + if assert.Len(cert.Subject.Organization, 1) { + assert.Equal("Testing", cert.Subject.Organization[0]) + } + if assert.Len(cert.DNSNames, 1) { + assert.Equal("localhost", cert.DNSNames[0]) + } + if assert.Len(cert.IPAddresses, 1) { + assert.Equal("127.0.0.1", cert.IPAddresses[0].String()) + } + if assert.IsType(&rsa.PublicKey{}, cert.PublicKey) { + pkey := cert.PublicKey.(*rsa.PublicKey) + assert.Equal(bits/8, pkey.Size()) + } +} + +func TestWriteKeys(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + + dir := t.TempDir() + privateFilename := path.Join(dir, "testing.key") + if assert.NoError(WritePrivateKey(key, privateFilename)) { + if data, err := os.ReadFile(privateFilename); assert.NoError(err) { + if block, rest := pem.Decode(data); assert.Equal("RSA PRIVATE KEY", block.Type) && assert.Empty(rest) { + if parsed, err := x509.ParsePKCS1PrivateKey(block.Bytes); assert.NoError(err) { + assert.True(key.Equal(parsed), "keys should be equal") + } + } + } + } + + publicFilename := path.Join(dir, "testing.pem") + if assert.NoError(WritePublicKey(&key.PublicKey, publicFilename)) { + if data, err := os.ReadFile(publicFilename); assert.NoError(err) { + if block, rest := pem.Decode(data); assert.Equal("RSA PUBLIC KEY", block.Type) && assert.Empty(rest) { + if parsed, err := x509.ParsePKIXPublicKey(block.Bytes); assert.NoError(err) { + assert.True(key.PublicKey.Equal(parsed), "keys should be equal") + } + } + } + } +} + +func TestReplaceCertificate(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + bits := 1024 + key, err := rsa.GenerateKey(rand.Reader, bits) + require.NoError(err) + cert1 := GenerateSelfSignedCertificateForTesting(t, "Testing", key) + require.NotNil(cert1) + + dir := t.TempDir() + filename := path.Join(dir, "testing.crt") + require.NoError(WriteCertificate(cert1, filename)) + stat1, err := os.Stat(filename) + require.NoError(err) + + cert2 := GenerateSelfSignedCertificateForTesting(t, "Testing", key) + require.NotNil(cert2) + ReplaceCertificate(t, filename, cert2) + stat2, err := os.Stat(filename) + require.NoError(err) + + assert.NotEqual(stat1.ModTime(), stat2.ModTime()) +} diff --git a/internal/flags.go b/internal/flags.go new file mode 100644 index 0000000..289bdea --- /dev/null +++ b/internal/flags.go @@ -0,0 +1,48 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2023 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "sync/atomic" +) + +type Flags struct { + flags atomic.Uint32 +} + +func (f *Flags) Add(flags uint32) bool { + old := f.flags.Or(flags) + return old&flags != flags +} + +func (f *Flags) Remove(flags uint32) bool { + old := f.flags.And(^flags) + return old&flags != 0 +} + +func (f *Flags) Set(flags uint32) bool { + return f.flags.Swap(flags) != flags +} + +func (f *Flags) Get() uint32 { + return f.flags.Load() +} diff --git a/flags_test.go b/internal/flags_test.go similarity index 94% rename from flags_test.go rename to internal/flags_test.go index 1665167..84eacaf 100644 --- a/flags_test.go +++ b/internal/flags_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package internal import ( "sync" @@ -30,6 +30,7 @@ import ( ) func TestFlags(t *testing.T) { + t.Parallel() assert := assert.New(t) var f Flags assert.EqualValues(0, f.Get()) @@ -54,15 +55,13 @@ func runConcurrentFlags(t *testing.T, count int, f func()) { start.Add(1) var ready sync.WaitGroup var done sync.WaitGroup - for i := 0; i < count; i++ { - done.Add(1) + for range count { ready.Add(1) - go func() { - defer done.Done() + done.Go(func() { ready.Done() start.Wait() f() - }() + }) } ready.Wait() start.Done() @@ -79,7 +78,7 @@ func TestFlagsConcurrentAdd(t *testing.T) { added.Add(1) } }) - assert.EqualValues(t, 1, added.Load(), "expected only one successfull attempt") + assert.EqualValues(t, 1, added.Load(), "expected only one successful attempt") } func TestFlagsConcurrentRemove(t *testing.T) { @@ -93,7 +92,7 @@ func TestFlagsConcurrentRemove(t *testing.T) { removed.Add(1) } }) - assert.EqualValues(t, 1, removed.Load(), "expected only one successfull attempt") + assert.EqualValues(t, 1, removed.Load(), "expected only one successful attempt") } func TestFlagsConcurrentSet(t *testing.T) { @@ -106,5 +105,5 @@ func TestFlagsConcurrentSet(t *testing.T) { set.Add(1) } }) - assert.EqualValues(t, 1, set.Load(), "expected only one successfull attempt") + assert.EqualValues(t, 1, set.Load(), "expected only one successful attempt") } diff --git a/internal/ips.go b/internal/ips.go new file mode 100644 index 0000000..94f4484 --- /dev/null +++ b/internal/ips.go @@ -0,0 +1,44 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "net" +) + +func IsLoopbackIP(addr string) bool { + ip := net.ParseIP(addr) + if len(ip) == 0 { + return false + } + + return ip.IsLoopback() +} + +func IsPrivateIP(addr string) bool { + ip := net.ParseIP(addr) + if len(ip) == 0 { + return false + } + + return ip.IsPrivate() +} diff --git a/internal/ips_test.go b/internal/ips_test.go new file mode 100644 index 0000000..804e58f --- /dev/null +++ b/internal/ips_test.go @@ -0,0 +1,80 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsLoopbackIP(t *testing.T) { + t.Parallel() + loopback := []string{ + "127.0.0.1", + "127.1.0.1", + "::1", + "::ffff:127.0.0.1", + } + nonLoopback := []string{ + "", + "invalid", + "1.2.3.4", + "::0", + "::2", + } + assert := assert.New(t) + for _, ip := range loopback { + assert.True(IsLoopbackIP(ip), "should be loopback: %s", ip) + } + for _, ip := range nonLoopback { + assert.False(IsLoopbackIP(ip), "should not be loopback: %s", ip) + } +} + +func TestIsPrivateIP(t *testing.T) { + t.Parallel() + private := []string{ + "10.1.2.3", + "172.20.21.22", + "192.168.10.20", + "fdea:aef9:06e3:bb24:1234:1234:1234:1234", + "fd12:3456:789a:1::1", + } + nonPrivate := []string{ + "", + "invalid", + "127.0.0.1", + "1.2.3.4", + "::0", + "::1", + "::2", + "1234:3456:789a:1::1", + } + assert := assert.New(t) + for _, ip := range private { + assert.True(IsPrivateIP(ip), "should be private: %s", ip) + } + for _, ip := range nonPrivate { + assert.False(IsPrivateIP(ip), "should not be private: %s", ip) + } +} diff --git a/internal/make_ptr.go b/internal/make_ptr.go new file mode 100644 index 0000000..b262aa6 --- /dev/null +++ b/internal/make_ptr.go @@ -0,0 +1,26 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +func MakePtr[T any](v T) *T { + return &v +} diff --git a/internal/make_ptr_test.go b/internal/make_ptr_test.go new file mode 100644 index 0000000..1b5874e --- /dev/null +++ b/internal/make_ptr_test.go @@ -0,0 +1,41 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_MakePtr(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + if v := MakePtr(10); assert.NotNil(v) { + assert.Equal(10, *v) + } + + if v := MakePtr("foo"); assert.NotNil(v) { + assert.Equal("foo", *v) + } +} diff --git a/internal/nocopy.go b/internal/nocopy.go new file mode 100644 index 0000000..cc81cc3 --- /dev/null +++ b/internal/nocopy.go @@ -0,0 +1,35 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +// NoCopy may be added to structs which must not be copied +// after the first use. +// +// See https://golang.org/issues/8005#issuecomment-190753527 +// for details. +// +// Note that it must not be embedded, due to the Lock and Unlock methods. +type NoCopy struct{} + +// Lock is a no-op used by -copylocks checker from `go vet`. +func (*NoCopy) Lock() {} +func (*NoCopy) Unlock() {} diff --git a/internal/random_string.go b/internal/random_string.go new file mode 100644 index 0000000..9221823 --- /dev/null +++ b/internal/random_string.go @@ -0,0 +1,34 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "crypto/rand" +) + +func RandomString(size int) string { + s := rand.Text() + for len(s) < size { + s += rand.Text() + } + return s[:size] +} diff --git a/internal/random_string_test.go b/internal/random_string_test.go new file mode 100644 index 0000000..78aca48 --- /dev/null +++ b/internal/random_string_test.go @@ -0,0 +1,41 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRandomString(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + s1 := RandomString(10) + assert.Len(s1, 10) + assert.NotEqual(s1, RandomString(10)) + + s2 := RandomString(123) + assert.Len(s2, 123) + assert.NotEqual(s2, RandomString(123)) +} diff --git a/internal/split_entries.go b/internal/split_entries.go new file mode 100644 index 0000000..3a6b767 --- /dev/null +++ b/internal/split_entries.go @@ -0,0 +1,41 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "iter" + "strings" +) + +// SplitEntries returns an iterator over all non-empty substrings of s separated +// by sep. +func SplitEntries(s string, sep string) iter.Seq[string] { + return func(yield func(entry string) bool) { + for entry := range strings.SplitSeq(s, sep) { + if entry = strings.TrimSpace(entry); entry != "" { + if !yield(entry) { + return + } + } + } + } +} diff --git a/internal/split_entries_test.go b/internal/split_entries_test.go new file mode 100644 index 0000000..0e77147 --- /dev/null +++ b/internal/split_entries_test.go @@ -0,0 +1,68 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitEntries(t *testing.T) { + t.Parallel() + assert := assert.New(t) + testcases := []struct { + s string + sep string + expected []string + }{ + { + "a b", + " ", + []string{"a", "b"}, + }, + { + "a b", + ",", + []string{"a b"}, + }, + { + "a b", + " ", + []string{"a", "b"}, + }, + { + "a,b,", + ",", + []string{"a", "b"}, + }, + { + "a,,b", + ",", + []string{"a", "b"}, + }, + } + for idx, tc := range testcases { + assert.Equal(tc.expected, slices.Collect(SplitEntries(tc.s, tc.sep)), "failed for testcase %d: %s", idx, tc.s) + } +} diff --git a/tools.go b/internal/tools/tools.go similarity index 98% rename from tools.go rename to internal/tools/tools.go index ec075a1..8ffb487 100644 --- a/tools.go +++ b/internal/tools/tools.go @@ -21,7 +21,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package tools // Import applications that would otherwise not be detected by "go mod vendor". import ( diff --git a/vendor_helper_test.go b/internal/tools/vendor_helper_test.go similarity index 98% rename from vendor_helper_test.go rename to internal/tools/vendor_helper_test.go index 8d73f5e..2cab8d3 100644 --- a/vendor_helper_test.go +++ b/internal/tools/vendor_helper_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package tools // Import modules that would otherwise not be detected by "go mod vendor". import ( diff --git a/log/logging.go b/log/logging.go new file mode 100644 index 0000000..cf5cc78 --- /dev/null +++ b/log/logging.go @@ -0,0 +1,60 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package log + +import ( + "context" + "log" + "testing" +) + +type loggerKey struct{} + +var ( + ctxLogger loggerKey = struct{}{} +) + +type Logger interface { + Printf(format string, v ...any) + Println(...any) +} + +// NewLoggerContext returns a derieved context that stores the passed logger. +func NewLoggerContext(ctx context.Context, logger Logger) context.Context { + if logger == nil { + panic("logger is nil") + } + return context.WithValue(ctx, ctxLogger, logger) +} + +// LoggerFromContext returns the logger to use for the passed context. +func LoggerFromContext(ctx context.Context) Logger { + logger := ctx.Value(ctxLogger) + if logger == nil { + if testing.Testing() { + panic("accessed global logger") + } + return log.Default() + } + + return logger.(Logger) +} diff --git a/log/logging_test.go b/log/logging_test.go new file mode 100644 index 0000000..c07e27c --- /dev/null +++ b/log/logging_test.go @@ -0,0 +1,89 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package log + +import ( + "bytes" + "log" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGlobalLogger(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + defer func() { + if err := recover(); assert.NotNil(err) { + assert.Equal("accessed global logger", err) + } + }() + + logger := LoggerFromContext(t.Context()) + assert.Fail("should have paniced", "got logger %+v", logger) +} + +type testLogWriter struct { + mu sync.Mutex + t testing.TB +} + +func (w *testLogWriter) Write(b []byte) (int, error) { + w.t.Helper() + if !bytes.HasSuffix(b, []byte("\n")) { + b = append(b, '\n') + } + w.mu.Lock() + defer w.mu.Unlock() + w.t.Logf("%s", string(b)) + return len(b), nil +} + +func TestLoggerContext(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + testLogger := log.New(&testLogWriter{ + t: t, + }, t.Name()+": ", log.LstdFlags|log.Lmicroseconds|log.Lshortfile) + testLogger.Printf("Hello %s!", "world") + + ctx := NewLoggerContext(t.Context(), testLogger) + logger2 := LoggerFromContext(ctx) + assert.Equal(testLogger, logger2) +} + +func TestNilLoggerContext(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + defer func() { + if err := recover(); assert.NotNil(err) { + assert.Equal("logger is nil", err) + } + }() + + ctx := NewLoggerContext(t.Context(), nil) + assert.Fail("should have paniced", "got context %+v", ctx) +} diff --git a/test_helpers.go b/log/test/log.go similarity index 65% rename from test_helpers.go rename to log/test/log.go index b7f0bdd..75b539f 100644 --- a/test_helpers.go +++ b/log/test/log.go @@ -19,40 +19,28 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package test import ( - "io" - "log" + stdlog "log" "testing" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" ) var ( - prevWriter io.Writer - prevFlags int + testLoggers test.Storage[log.Logger] ) -func init() { - prevWriter = log.Writer() - prevFlags = log.Flags() -} +func NewLoggerForTest(t testing.TB) log.Logger { + t.Helper() -type testLogWriter struct { - t testing.TB -} + logger, found := testLoggers.Get(t) + if !found { + logger = stdlog.New(t.Output(), t.Name()+": ", stdlog.LstdFlags|stdlog.Lmicroseconds|stdlog.Lshortfile) -func (w *testLogWriter) Write(b []byte) (int, error) { - w.t.Helper() - w.t.Logf("%s", string(b)) - return len(b), nil -} - -func CatchLogForTest(t testing.TB) { - t.Cleanup(func() { - log.SetOutput(prevWriter) - log.SetFlags(prevFlags) - }) - - log.SetOutput(&testLogWriter{t}) - log.SetFlags(prevFlags | log.Lshortfile) + testLoggers.Set(t, logger) + } + return logger } diff --git a/log/test/log_test.go b/log/test/log_test.go new file mode 100644 index 0000000..174bff3 --- /dev/null +++ b/log/test/log_test.go @@ -0,0 +1,39 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoggerForTest(t *testing.T) { + t.Parallel() + + log1 := NewLoggerForTest(t) + log2 := NewLoggerForTest(t) + assert.Equal(t, log1, log2) + + log1.Printf("Test output") + log1.Println("Test output") +} diff --git a/mcu_common.go b/mcu_common.go deleted file mode 100644 index af22d4a..0000000 --- a/mcu_common.go +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "fmt" - "log" - "sync/atomic" - "time" - - "github.com/dlintw/goconf" -) - -const ( - McuTypeJanus = "janus" - McuTypeProxy = "proxy" - - McuTypeDefault = McuTypeJanus - - defaultMaxStreamBitrate = 1024 * 1024 - defaultMaxScreenBitrate = 2048 * 1024 -) - -var ( - ErrNotConnected = fmt.Errorf("not connected") -) - -type MediaType int - -const ( - MediaTypeAudio MediaType = 1 << 0 - MediaTypeVideo MediaType = 1 << 1 - MediaTypeScreen MediaType = 1 << 2 -) - -type McuListener interface { - PublicId() string - - OnUpdateOffer(client McuClient, offer map[string]interface{}) - - OnIceCandidate(client McuClient, candidate interface{}) - OnIceCompleted(client McuClient) - - SubscriberSidUpdated(subscriber McuSubscriber) - - PublisherClosed(publisher McuPublisher) - SubscriberClosed(subscriber McuSubscriber) -} - -type McuInitiator interface { - Country() string -} - -type McuSettings interface { - MaxStreamBitrate() int32 - MaxScreenBitrate() int32 - Timeout() time.Duration - - Reload(config *goconf.ConfigFile) -} - -type mcuCommonSettings struct { - maxStreamBitrate atomic.Int32 - maxScreenBitrate atomic.Int32 - - timeout atomic.Int64 -} - -func (s *mcuCommonSettings) MaxStreamBitrate() int32 { - return s.maxStreamBitrate.Load() -} - -func (s *mcuCommonSettings) MaxScreenBitrate() int32 { - return s.maxScreenBitrate.Load() -} - -func (s *mcuCommonSettings) Timeout() time.Duration { - return time.Duration(s.timeout.Load()) -} - -func (s *mcuCommonSettings) setTimeout(timeout time.Duration) { - s.timeout.Store(int64(timeout)) -} - -func (s *mcuCommonSettings) load(config *goconf.ConfigFile) error { - maxStreamBitrate, _ := config.GetInt("mcu", "maxstreambitrate") - if maxStreamBitrate <= 0 { - maxStreamBitrate = defaultMaxStreamBitrate - } - log.Printf("Maximum bandwidth %d bits/sec per publishing stream", maxStreamBitrate) - s.maxStreamBitrate.Store(int32(maxStreamBitrate)) - - maxScreenBitrate, _ := config.GetInt("mcu", "maxscreenbitrate") - if maxScreenBitrate <= 0 { - maxScreenBitrate = defaultMaxScreenBitrate - } - log.Printf("Maximum bandwidth %d bits/sec per screensharing stream", maxScreenBitrate) - s.maxScreenBitrate.Store(int32(maxScreenBitrate)) - return nil -} - -type Mcu interface { - Start(ctx context.Context) error - Stop() - Reload(config *goconf.ConfigFile) - - SetOnConnected(func()) - SetOnDisconnected(func()) - - GetStats() interface{} - - NewPublisher(ctx context.Context, listener McuListener, id string, sid string, streamType StreamType, settings NewPublisherSettings, initiator McuInitiator) (McuPublisher, error) - NewSubscriber(ctx context.Context, listener McuListener, publisher string, streamType StreamType, initiator McuInitiator) (McuSubscriber, error) -} - -// PublisherStream contains the available properties when creating a -// remote publisher in Janus. -type PublisherStream struct { - Mid string `json:"mid"` - Mindex int `json:"mindex"` - Type string `json:"type"` - - Description string `json:"description,omitempty"` - Disabled bool `json:"disabled,omitempty"` - - // For types "audio" and "video" - Codec string `json:"codec,omitempty"` - - // For type "audio" - Stereo bool `json:"stereo,omitempty"` - Fec bool `json:"fec,omitempty"` - Dtx bool `json:"dtx,omitempty"` - - // For type "video" - Simulcast bool `json:"simulcast,omitempty"` - Svc bool `json:"svc,omitempty"` - - ProfileH264 string `json:"h264_profile,omitempty"` - ProfileVP9 string `json:"vp9_profile,omitempty"` - - ExtIdVideoOrientation int `json:"videoorient_ext_id,omitempty"` - ExtIdPlayoutDelay int `json:"playoutdelay_ext_id,omitempty"` -} - -type RemotePublisherController interface { - PublisherId() string - - StartPublishing(ctx context.Context, publisher McuRemotePublisherProperties) error - StopPublishing(ctx context.Context, publisher McuRemotePublisherProperties) error - GetStreams(ctx context.Context) ([]PublisherStream, error) -} - -type RemoteMcu interface { - NewRemotePublisher(ctx context.Context, listener McuListener, controller RemotePublisherController, streamType StreamType) (McuRemotePublisher, error) - NewRemoteSubscriber(ctx context.Context, listener McuListener, publisher McuRemotePublisher) (McuRemoteSubscriber, error) -} - -type StreamType string - -const ( - StreamTypeAudio StreamType = "audio" - StreamTypeVideo StreamType = "video" - StreamTypeScreen StreamType = "screen" -) - -func IsValidStreamType(s string) bool { - switch s { - case string(StreamTypeAudio): - fallthrough - case string(StreamTypeVideo): - fallthrough - case string(StreamTypeScreen): - return true - default: - return false - } -} - -type McuClient interface { - Id() string - Sid() string - StreamType() StreamType - MaxBitrate() int - - Close(ctx context.Context) - - SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) -} - -type McuPublisher interface { - McuClient - - HasMedia(MediaType) bool - SetMedia(MediaType) - - GetStreams(ctx context.Context) ([]PublisherStream, error) - PublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error - UnpublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error -} - -type McuSubscriber interface { - McuClient - - Publisher() string -} - -type McuRemotePublisherProperties interface { - Port() int - RtcpPort() int -} - -type McuRemotePublisher interface { - McuClient - - McuRemotePublisherProperties -} - -type McuRemoteSubscriber interface { - McuSubscriber -} diff --git a/mcu_janus.go b/mcu_janus.go deleted file mode 100644 index 1a68003..0000000 --- a/mcu_janus.go +++ /dev/null @@ -1,896 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log" - "strconv" - "sync" - "sync/atomic" - "time" - - "github.com/dlintw/goconf" - "github.com/notedit/janus-go" -) - -const ( - pluginVideoRoom = "janus.plugin.videoroom" - - keepaliveInterval = 30 * time.Second - - videoPublisherUserId = 1 - screenPublisherUserId = 2 - - initialReconnectInterval = 1 * time.Second - maxReconnectInterval = 32 * time.Second -) - -var ( - ErrRemoteStreamsNotSupported = errors.New("Need Janus 1.1.0 for remote streams") - - streamTypeUserIds = map[StreamType]uint64{ - StreamTypeVideo: videoPublisherUserId, - StreamTypeScreen: screenPublisherUserId, - } -) - -func getStreamId(publisherId string, streamType StreamType) string { - return fmt.Sprintf("%s|%s", publisherId, streamType) -} - -func getPluginValue(data janus.PluginData, pluginName string, key string) interface{} { - if data.Plugin != pluginName { - return nil - } - - return data.Data[key] -} - -func convertIntValue(value interface{}) (uint64, error) { - switch t := value.(type) { - case float64: - if t < 0 { - return 0, fmt.Errorf("Unsupported float64 number: %+v", t) - } - return uint64(t), nil - case uint64: - return t, nil - case int: - if t < 0 { - return 0, fmt.Errorf("Unsupported int number: %+v", t) - } - return uint64(t), nil - case int64: - if t < 0 { - return 0, fmt.Errorf("Unsupported int64 number: %+v", t) - } - return uint64(t), nil - case json.Number: - r, err := t.Int64() - if err != nil { - return 0, err - } else if r < 0 { - return 0, fmt.Errorf("Unsupported JSON number: %+v", t) - } - return uint64(r), nil - default: - return 0, fmt.Errorf("Unknown number type: %+v (%T)", t, t) - } -} - -func getPluginIntValue(data janus.PluginData, pluginName string, key string) uint64 { - val := getPluginValue(data, pluginName, key) - if val == nil { - return 0 - } - - result, err := convertIntValue(val) - if err != nil { - log.Printf("Invalid value %+v for %s: %s", val, key, err) - result = 0 - } - return result -} - -func getPluginStringValue(data janus.PluginData, pluginName string, key string) string { - val := getPluginValue(data, pluginName, key) - if val == nil { - return "" - } - - strVal, ok := val.(string) - if !ok { - return "" - } - - return strVal -} - -// TODO(jojo): Lots of error handling still missing. - -type clientInterface interface { - NotifyReconnected() -} - -type mcuJanusSettings struct { - mcuCommonSettings -} - -func newMcuJanusSettings(config *goconf.ConfigFile) (McuSettings, error) { - settings := &mcuJanusSettings{} - if err := settings.load(config); err != nil { - return nil, err - } - - return settings, nil -} - -func (s *mcuJanusSettings) load(config *goconf.ConfigFile) error { - if err := s.mcuCommonSettings.load(config); err != nil { - return err - } - - mcuTimeoutSeconds, _ := config.GetInt("mcu", "timeout") - if mcuTimeoutSeconds <= 0 { - mcuTimeoutSeconds = defaultMcuTimeoutSeconds - } - mcuTimeout := time.Duration(mcuTimeoutSeconds) * time.Second - log.Printf("Using a timeout of %s for MCU requests", mcuTimeout) - s.setTimeout(mcuTimeout) - return nil -} - -func (s *mcuJanusSettings) Reload(config *goconf.ConfigFile) { - if err := s.load(config); err != nil { - log.Printf("Error reloading MCU settings: %s", err) - } -} - -type mcuJanus struct { - url string - mu sync.Mutex - - settings McuSettings - - createJanusGateway func(ctx context.Context, wsURL string, listener GatewayListener) (JanusGatewayInterface, error) - - gw JanusGatewayInterface - session *JanusSession - handle *JanusHandle - - version int - - closeChan chan struct{} - - muClients sync.Mutex - clients map[clientInterface]bool - clientId atomic.Uint64 - - publishers map[string]*mcuJanusPublisher - publisherCreated Notifier - publisherConnected Notifier - remotePublishers map[string]*mcuJanusRemotePublisher - - reconnectTimer *time.Timer - reconnectInterval time.Duration - - connectedSince time.Time - onConnected atomic.Value - onDisconnected atomic.Value -} - -func emptyOnConnected() {} -func emptyOnDisconnected() {} - -func NewMcuJanus(ctx context.Context, url string, config *goconf.ConfigFile) (Mcu, error) { - settings, err := newMcuJanusSettings(config) - if err != nil { - return nil, err - } - - mcu := &mcuJanus{ - url: url, - settings: settings, - closeChan: make(chan struct{}, 1), - clients: make(map[clientInterface]bool), - - publishers: make(map[string]*mcuJanusPublisher), - remotePublishers: make(map[string]*mcuJanusRemotePublisher), - - createJanusGateway: func(ctx context.Context, wsURL string, listener GatewayListener) (JanusGatewayInterface, error) { - return NewJanusGateway(ctx, wsURL, listener) - }, - reconnectInterval: initialReconnectInterval, - } - mcu.onConnected.Store(emptyOnConnected) - mcu.onDisconnected.Store(emptyOnDisconnected) - - mcu.reconnectTimer = time.AfterFunc(mcu.reconnectInterval, func() { - mcu.doReconnect(context.Background()) - }) - mcu.reconnectTimer.Stop() - if mcu.url != "" { - if err := mcu.reconnect(ctx); err != nil { - return nil, err - } - } - return mcu, nil -} - -func (m *mcuJanus) disconnect() { - if handle := m.handle; handle != nil { - m.handle = nil - m.closeChan <- struct{}{} - if _, err := handle.Detach(context.TODO()); err != nil { - log.Printf("Error detaching handle %d: %s", handle.Id, err) - } - } - if m.session != nil { - if _, err := m.session.Destroy(context.TODO()); err != nil { - log.Printf("Error destroying session %d: %s", m.session.Id, err) - } - m.session = nil - } - if m.gw != nil { - if err := m.gw.Close(); err != nil { - log.Println("Error while closing connection to MCU", err) - } - m.gw = nil - } -} - -func (m *mcuJanus) reconnect(ctx context.Context) error { - m.disconnect() - gw, err := m.createJanusGateway(ctx, m.url, m) - if err != nil { - return err - } - - m.gw = gw - m.reconnectTimer.Stop() - return nil -} - -func (m *mcuJanus) doReconnect(ctx context.Context) { - if err := m.reconnect(ctx); err != nil { - m.scheduleReconnect(err) - return - } - if err := m.Start(ctx); err != nil { - m.scheduleReconnect(err) - return - } - - log.Println("Reconnection to Janus gateway successful") - m.mu.Lock() - clear(m.publishers) - m.publisherCreated.Reset() - m.publisherConnected.Reset() - m.reconnectInterval = initialReconnectInterval - m.mu.Unlock() - - m.muClients.Lock() - for client := range m.clients { - go client.NotifyReconnected() - } - m.muClients.Unlock() -} - -func (m *mcuJanus) scheduleReconnect(err error) { - m.mu.Lock() - defer m.mu.Unlock() - m.reconnectTimer.Reset(m.reconnectInterval) - if err == nil { - log.Printf("Connection to Janus gateway was interrupted, reconnecting in %s", m.reconnectInterval) - } else { - log.Printf("Reconnect to Janus gateway failed (%s), reconnecting in %s", err, m.reconnectInterval) - } - - m.reconnectInterval = m.reconnectInterval * 2 - if m.reconnectInterval > maxReconnectInterval { - m.reconnectInterval = maxReconnectInterval - } -} - -func (m *mcuJanus) ConnectionInterrupted() { - m.scheduleReconnect(nil) - m.notifyOnDisconnected() -} - -func (m *mcuJanus) isMultistream() bool { - return m.version >= 1000 -} - -func (m *mcuJanus) hasRemotePublisher() bool { - return m.version >= 1100 -} - -func (m *mcuJanus) Start(ctx context.Context) error { - if m.url == "" { - if err := m.reconnect(ctx); err != nil { - return err - } - } - info, err := m.gw.Info(ctx) - if err != nil { - return err - } - - log.Printf("Connected to %s %s by %s", info.Name, info.VersionString, info.Author) - plugin, found := info.Plugins[pluginVideoRoom] - if !found { - return fmt.Errorf("Plugin %s is not supported", pluginVideoRoom) - } - - m.version = info.Version - - log.Printf("Found %s %s by %s", plugin.Name, plugin.VersionString, plugin.Author) - if !info.DataChannels { - return fmt.Errorf("Data channels are not supported") - } - - log.Println("Data channels are supported") - if !info.FullTrickle { - log.Println("WARNING: Full-Trickle is NOT enabled in Janus!") - } else { - log.Println("Full-Trickle is enabled") - } - - if m.session, err = m.gw.Create(ctx); err != nil { - m.disconnect() - return err - } - log.Println("Created Janus session", m.session.Id) - m.connectedSince = time.Now() - - if m.handle, err = m.session.Attach(ctx, pluginVideoRoom); err != nil { - m.disconnect() - return err - } - log.Println("Created Janus handle", m.handle.Id) - - go m.run() - - m.notifyOnConnected() - return nil -} - -func (m *mcuJanus) registerClient(client clientInterface) { - m.muClients.Lock() - m.clients[client] = true - m.muClients.Unlock() -} - -func (m *mcuJanus) unregisterClient(client clientInterface) { - m.muClients.Lock() - delete(m.clients, client) - m.muClients.Unlock() -} - -func (m *mcuJanus) run() { - ticker := time.NewTicker(keepaliveInterval) - defer ticker.Stop() - -loop: - for { - select { - case <-ticker.C: - m.sendKeepalive(context.Background()) - case <-m.closeChan: - break loop - } - } -} - -func (m *mcuJanus) Stop() { - m.disconnect() - m.reconnectTimer.Stop() -} - -func (m *mcuJanus) Reload(config *goconf.ConfigFile) { - m.settings.Reload(config) -} - -func (m *mcuJanus) SetOnConnected(f func()) { - if f == nil { - f = emptyOnConnected - } - - m.onConnected.Store(f) -} - -func (m *mcuJanus) notifyOnConnected() { - f := m.onConnected.Load().(func()) - f() -} - -func (m *mcuJanus) SetOnDisconnected(f func()) { - if f == nil { - f = emptyOnDisconnected - } - - m.onDisconnected.Store(f) -} - -func (m *mcuJanus) notifyOnDisconnected() { - f := m.onDisconnected.Load().(func()) - f() -} - -type mcuJanusConnectionStats struct { - Url string `json:"url"` - Connected bool `json:"connected"` - Publishers int64 `json:"publishers"` - Clients int64 `json:"clients"` - Uptime *time.Time `json:"uptime,omitempty"` -} - -func (m *mcuJanus) GetStats() interface{} { - result := mcuJanusConnectionStats{ - Url: m.url, - } - if m.session != nil { - result.Connected = true - result.Uptime = &m.connectedSince - } - m.mu.Lock() - result.Publishers = int64(len(m.publishers)) - m.mu.Unlock() - m.muClients.Lock() - result.Clients = int64(len(m.clients)) - m.muClients.Unlock() - return result -} - -func (m *mcuJanus) sendKeepalive(ctx context.Context) { - if _, err := m.session.KeepAlive(ctx); err != nil { - log.Println("Could not send keepalive request", err) - if e, ok := err.(*janus.ErrorMsg); ok { - switch e.Err.Code { - case JANUS_ERROR_SESSION_NOT_FOUND: - m.scheduleReconnect(err) - } - } - } -} - -func (m *mcuJanus) SubscriberConnected(id string, publisher string, streamType StreamType) { - m.mu.Lock() - defer m.mu.Unlock() - - if p, found := m.publishers[getStreamId(publisher, streamType)]; found { - p.stats.AddSubscriber(id) - } -} - -func (m *mcuJanus) SubscriberDisconnected(id string, publisher string, streamType StreamType) { - m.mu.Lock() - defer m.mu.Unlock() - - if p, found := m.publishers[getStreamId(publisher, streamType)]; found { - p.stats.RemoveSubscriber(id) - } -} - -func (m *mcuJanus) createPublisherRoom(ctx context.Context, handle *JanusHandle, id string, streamType StreamType, settings NewPublisherSettings) (uint64, int, error) { - create_msg := map[string]interface{}{ - "request": "create", - "description": getStreamId(id, streamType), - // We publish every stream in its own Janus room. - "publishers": 1, - // Do not use the video-orientation RTP extension as it breaks video - // orientation changes in Firefox. - "videoorient_ext": false, - } - if codec := settings.AudioCodec; codec != "" { - create_msg["audiocodec"] = codec - } - if codec := settings.VideoCodec; codec != "" { - create_msg["videocodec"] = codec - } - if profile := settings.VP9Profile; profile != "" { - create_msg["vp9_profile"] = profile - } - if profile := settings.H264Profile; profile != "" { - create_msg["h264_profile"] = profile - } - var maxBitrate int - if streamType == StreamTypeScreen { - maxBitrate = int(m.settings.MaxScreenBitrate()) - } else { - maxBitrate = int(m.settings.MaxStreamBitrate()) - } - bitrate := settings.Bitrate - if bitrate <= 0 { - bitrate = maxBitrate - } else { - bitrate = min(bitrate, maxBitrate) - } - create_msg["bitrate"] = bitrate - create_response, err := handle.Request(ctx, create_msg) - if err != nil { - if _, err2 := handle.Detach(ctx); err2 != nil { - log.Printf("Error detaching handle %d: %s", handle.Id, err2) - } - return 0, 0, err - } - - roomId := getPluginIntValue(create_response.PluginData, pluginVideoRoom, "room") - if roomId == 0 { - if _, err := handle.Detach(ctx); err != nil { - log.Printf("Error detaching handle %d: %s", handle.Id, err) - } - return 0, 0, fmt.Errorf("No room id received: %+v", create_response) - } - - log.Println("Created room", roomId, create_response.PluginData) - return roomId, bitrate, nil -} - -func (m *mcuJanus) getOrCreatePublisherHandle(ctx context.Context, id string, streamType StreamType, settings NewPublisherSettings) (*JanusHandle, uint64, uint64, int, error) { - session := m.session - if session == nil { - return nil, 0, 0, 0, ErrNotConnected - } - handle, err := session.Attach(ctx, pluginVideoRoom) - if err != nil { - return nil, 0, 0, 0, err - } - - log.Printf("Attached %s as publisher %d to plugin %s in session %d", streamType, handle.Id, pluginVideoRoom, session.Id) - - roomId, bitrate, err := m.createPublisherRoom(ctx, handle, id, streamType, settings) - if err != nil { - if _, err2 := handle.Detach(ctx); err2 != nil { - log.Printf("Error detaching handle %d: %s", handle.Id, err2) - } - return nil, 0, 0, 0, err - } - - msg := map[string]interface{}{ - "request": "join", - "ptype": "publisher", - "room": roomId, - "id": streamTypeUserIds[streamType], - } - - response, err := handle.Message(ctx, msg, nil) - if err != nil { - if _, err2 := handle.Detach(ctx); err2 != nil { - log.Printf("Error detaching handle %d: %s", handle.Id, err2) - } - return nil, 0, 0, 0, err - } - - return handle, response.Session, roomId, bitrate, nil -} - -func (m *mcuJanus) NewPublisher(ctx context.Context, listener McuListener, id string, sid string, streamType StreamType, settings NewPublisherSettings, initiator McuInitiator) (McuPublisher, error) { - if _, found := streamTypeUserIds[streamType]; !found { - return nil, fmt.Errorf("Unsupported stream type %s", streamType) - } - - handle, session, roomId, maxBitrate, err := m.getOrCreatePublisherHandle(ctx, id, streamType, settings) - if err != nil { - return nil, err - } - - client := &mcuJanusPublisher{ - mcuJanusClient: mcuJanusClient{ - mcu: m, - listener: listener, - - id: m.clientId.Add(1), - session: session, - roomId: roomId, - sid: sid, - streamType: streamType, - maxBitrate: maxBitrate, - - handle: handle, - handleId: handle.Id, - closeChan: make(chan struct{}, 1), - deferred: make(chan func(), 64), - }, - sdpReady: NewCloser(), - id: id, - settings: settings, - } - client.mcuJanusClient.handleEvent = client.handleEvent - client.mcuJanusClient.handleHangup = client.handleHangup - client.mcuJanusClient.handleDetached = client.handleDetached - client.mcuJanusClient.handleConnected = client.handleConnected - client.mcuJanusClient.handleSlowLink = client.handleSlowLink - client.mcuJanusClient.handleMedia = client.handleMedia - - m.registerClient(client) - log.Printf("Publisher %s is using handle %d", client.id, client.handleId) - go client.run(handle, client.closeChan) - m.mu.Lock() - m.publishers[getStreamId(id, streamType)] = client - m.publisherCreated.Notify(getStreamId(id, streamType)) - m.mu.Unlock() - statsPublishersCurrent.WithLabelValues(string(streamType)).Inc() - statsPublishersTotal.WithLabelValues(string(streamType)).Inc() - return client, nil -} - -func (m *mcuJanus) getPublisher(ctx context.Context, publisher string, streamType StreamType) (*mcuJanusPublisher, error) { - // Do the direct check immediately as this should be the normal case. - key := getStreamId(publisher, streamType) - m.mu.Lock() - if result, found := m.publishers[key]; found { - m.mu.Unlock() - return result, nil - } - - waiter := m.publisherCreated.NewWaiter(key) - m.mu.Unlock() - defer m.publisherCreated.Release(waiter) - - for { - m.mu.Lock() - result := m.publishers[key] - m.mu.Unlock() - if result != nil { - return result, nil - } - - if err := waiter.Wait(ctx); err != nil { - return nil, err - } - } -} - -func (m *mcuJanus) getOrCreateSubscriberHandle(ctx context.Context, publisher string, streamType StreamType) (*JanusHandle, *mcuJanusPublisher, error) { - var pub *mcuJanusPublisher - var err error - if pub, err = m.getPublisher(ctx, publisher, streamType); err != nil { - return nil, nil, err - } - - session := m.session - if session == nil { - return nil, nil, ErrNotConnected - } - - handle, err := session.Attach(ctx, pluginVideoRoom) - if err != nil { - return nil, nil, err - } - - log.Printf("Attached subscriber to room %d of publisher %s in plugin %s in session %d as %d", pub.roomId, publisher, pluginVideoRoom, session.Id, handle.Id) - return handle, pub, nil -} - -func (m *mcuJanus) NewSubscriber(ctx context.Context, listener McuListener, publisher string, streamType StreamType, initiator McuInitiator) (McuSubscriber, error) { - if _, found := streamTypeUserIds[streamType]; !found { - return nil, fmt.Errorf("Unsupported stream type %s", streamType) - } - - handle, pub, err := m.getOrCreateSubscriberHandle(ctx, publisher, streamType) - if err != nil { - return nil, err - } - - client := &mcuJanusSubscriber{ - mcuJanusClient: mcuJanusClient{ - mcu: m, - listener: listener, - - id: m.clientId.Add(1), - roomId: pub.roomId, - sid: strconv.FormatUint(handle.Id, 10), - streamType: streamType, - maxBitrate: pub.MaxBitrate(), - - handle: handle, - handleId: handle.Id, - closeChan: make(chan struct{}, 1), - deferred: make(chan func(), 64), - }, - publisher: publisher, - } - client.mcuJanusClient.handleEvent = client.handleEvent - client.mcuJanusClient.handleHangup = client.handleHangup - client.mcuJanusClient.handleDetached = client.handleDetached - client.mcuJanusClient.handleConnected = client.handleConnected - client.mcuJanusClient.handleSlowLink = client.handleSlowLink - client.mcuJanusClient.handleMedia = client.handleMedia - m.registerClient(client) - go client.run(handle, client.closeChan) - statsSubscribersCurrent.WithLabelValues(string(streamType)).Inc() - statsSubscribersTotal.WithLabelValues(string(streamType)).Inc() - return client, nil -} - -func (m *mcuJanus) getOrCreateRemotePublisher(ctx context.Context, controller RemotePublisherController, streamType StreamType, settings NewPublisherSettings) (*mcuJanusRemotePublisher, error) { - m.mu.Lock() - defer m.mu.Unlock() - pub, found := m.remotePublishers[getStreamId(controller.PublisherId(), streamType)] - if found { - return pub, nil - } - - streams, err := controller.GetStreams(ctx) - if err != nil { - return nil, err - } - - if len(streams) == 0 { - return nil, errors.New("remote publisher has no streams") - } - - session := m.session - if session == nil { - return nil, ErrNotConnected - } - - handle, err := session.Attach(ctx, pluginVideoRoom) - if err != nil { - return nil, err - } - - roomId, maxBitrate, err := m.createPublisherRoom(ctx, handle, controller.PublisherId(), streamType, settings) - if err != nil { - if _, err2 := handle.Detach(ctx); err2 != nil { - log.Printf("Error detaching handle %d: %s", handle.Id, err2) - } - return nil, err - } - - response, err := handle.Request(ctx, map[string]interface{}{ - "request": "add_remote_publisher", - "room": roomId, - "id": streamTypeUserIds[streamType], - "streams": streams, - }) - if err != nil { - if _, err2 := handle.Detach(ctx); err2 != nil { - log.Printf("Error detaching handle %d: %s", handle.Id, err2) - } - return nil, err - } - - id := getPluginIntValue(response.PluginData, pluginVideoRoom, "id") - port := getPluginIntValue(response.PluginData, pluginVideoRoom, "port") - rtcp_port := getPluginIntValue(response.PluginData, pluginVideoRoom, "rtcp_port") - - pub = &mcuJanusRemotePublisher{ - mcuJanusPublisher: mcuJanusPublisher{ - mcuJanusClient: mcuJanusClient{ - mcu: m, - - id: id, - session: response.Session, - roomId: roomId, - sid: strconv.FormatUint(handle.Id, 10), - streamType: streamType, - maxBitrate: maxBitrate, - - handle: handle, - handleId: handle.Id, - closeChan: make(chan struct{}, 1), - deferred: make(chan func(), 64), - }, - - sdpReady: NewCloser(), - id: controller.PublisherId(), - settings: settings, - }, - - controller: controller, - - port: int(port), - rtcpPort: int(rtcp_port), - } - pub.mcuJanusClient.handleEvent = pub.handleEvent - pub.mcuJanusClient.handleHangup = pub.handleHangup - pub.mcuJanusClient.handleDetached = pub.handleDetached - pub.mcuJanusClient.handleConnected = pub.handleConnected - pub.mcuJanusClient.handleSlowLink = pub.handleSlowLink - pub.mcuJanusClient.handleMedia = pub.handleMedia - - if err := controller.StartPublishing(ctx, pub); err != nil { - go pub.Close(context.Background()) - return nil, err - } - - m.remotePublishers[getStreamId(controller.PublisherId(), streamType)] = pub - - return pub, nil -} - -func (m *mcuJanus) NewRemotePublisher(ctx context.Context, listener McuListener, controller RemotePublisherController, streamType StreamType) (McuRemotePublisher, error) { - if _, found := streamTypeUserIds[streamType]; !found { - return nil, fmt.Errorf("Unsupported stream type %s", streamType) - } - - if !m.hasRemotePublisher() { - return nil, ErrRemoteStreamsNotSupported - } - - pub, err := m.getOrCreateRemotePublisher(ctx, controller, streamType, NewPublisherSettings{}) - if err != nil { - return nil, err - } - - pub.addRef() - return pub, nil -} - -func (m *mcuJanus) NewRemoteSubscriber(ctx context.Context, listener McuListener, publisher McuRemotePublisher) (McuRemoteSubscriber, error) { - pub, ok := publisher.(*mcuJanusRemotePublisher) - if !ok { - return nil, errors.New("unsupported remote publisher") - } - - session := m.session - if session == nil { - return nil, ErrNotConnected - } - - handle, err := session.Attach(ctx, pluginVideoRoom) - if err != nil { - return nil, err - } - - log.Printf("Attached subscriber to room %d of publisher %s in plugin %s in session %d as %d", pub.roomId, pub.id, pluginVideoRoom, session.Id, handle.Id) - - client := &mcuJanusRemoteSubscriber{ - mcuJanusSubscriber: mcuJanusSubscriber{ - mcuJanusClient: mcuJanusClient{ - mcu: m, - listener: listener, - - id: m.clientId.Add(1), - roomId: pub.roomId, - sid: strconv.FormatUint(handle.Id, 10), - streamType: publisher.StreamType(), - maxBitrate: pub.MaxBitrate(), - - handle: handle, - handleId: handle.Id, - closeChan: make(chan struct{}, 1), - deferred: make(chan func(), 64), - }, - publisher: pub.id, - }, - } - client.remote.Store(pub) - pub.addRef() - client.mcuJanusClient.handleEvent = client.handleEvent - client.mcuJanusClient.handleHangup = client.handleHangup - client.mcuJanusClient.handleDetached = client.handleDetached - client.mcuJanusClient.handleConnected = client.handleConnected - client.mcuJanusClient.handleSlowLink = client.handleSlowLink - client.mcuJanusClient.handleMedia = client.handleMedia - m.registerClient(client) - go client.run(handle, client.closeChan) - statsSubscribersCurrent.WithLabelValues(string(publisher.StreamType())).Inc() - statsSubscribersTotal.WithLabelValues(string(publisher.StreamType())).Inc() - return client, nil -} diff --git a/mcu_janus_client.go b/mcu_janus_client.go deleted file mode 100644 index f1d254b..0000000 --- a/mcu_janus_client.go +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "log" - "reflect" - "strconv" - "sync" - - "github.com/notedit/janus-go" -) - -type mcuJanusClient struct { - mcu *mcuJanus - listener McuListener - mu sync.Mutex // nolint - - id uint64 - session uint64 - roomId uint64 - sid string - streamType StreamType - maxBitrate int - - handle *JanusHandle - handleId uint64 - closeChan chan struct{} - deferred chan func() - - handleEvent func(event *janus.EventMsg) - handleHangup func(event *janus.HangupMsg) - handleDetached func(event *janus.DetachedMsg) - handleConnected func(event *janus.WebRTCUpMsg) - handleSlowLink func(event *janus.SlowLinkMsg) - handleMedia func(event *janus.MediaMsg) -} - -func (c *mcuJanusClient) Id() string { - return strconv.FormatUint(c.id, 10) -} - -func (c *mcuJanusClient) Sid() string { - return c.sid -} - -func (c *mcuJanusClient) StreamType() StreamType { - return c.streamType -} - -func (c *mcuJanusClient) MaxBitrate() int { - return c.maxBitrate -} - -func (c *mcuJanusClient) Close(ctx context.Context) { -} - -func (c *mcuJanusClient) SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) { -} - -func (c *mcuJanusClient) closeClient(ctx context.Context) bool { - if handle := c.handle; handle != nil { - c.handle = nil - close(c.closeChan) - if _, err := handle.Detach(ctx); err != nil { - if e, ok := err.(*janus.ErrorMsg); !ok || e.Err.Code != JANUS_ERROR_HANDLE_NOT_FOUND { - log.Println("Could not detach client", handle.Id, err) - } - } - return true - } - - return false -} - -func (c *mcuJanusClient) run(handle *JanusHandle, closeChan <-chan struct{}) { -loop: - for { - select { - case msg := <-handle.Events: - switch t := msg.(type) { - case *janus.EventMsg: - c.handleEvent(t) - case *janus.HangupMsg: - c.handleHangup(t) - case *janus.DetachedMsg: - c.handleDetached(t) - case *janus.MediaMsg: - c.handleMedia(t) - case *janus.WebRTCUpMsg: - c.handleConnected(t) - case *janus.SlowLinkMsg: - c.handleSlowLink(t) - case *TrickleMsg: - c.handleTrickle(t) - default: - log.Println("Received unsupported event type", msg, reflect.TypeOf(msg)) - } - case f := <-c.deferred: - f() - case <-closeChan: - break loop - } - } -} - -func (c *mcuJanusClient) sendOffer(ctx context.Context, offer map[string]interface{}, callback func(error, map[string]interface{})) { - handle := c.handle - if handle == nil { - callback(ErrNotConnected, nil) - return - } - - configure_msg := map[string]interface{}{ - "request": "configure", - "audio": true, - "video": true, - "data": true, - } - answer_msg, err := handle.Message(ctx, configure_msg, offer) - if err != nil { - callback(err, nil) - return - } - - callback(nil, answer_msg.Jsep) -} - -func (c *mcuJanusClient) sendAnswer(ctx context.Context, answer map[string]interface{}, callback func(error, map[string]interface{})) { - handle := c.handle - if handle == nil { - callback(ErrNotConnected, nil) - return - } - - start_msg := map[string]interface{}{ - "request": "start", - "room": c.roomId, - } - start_response, err := handle.Message(ctx, start_msg, answer) - if err != nil { - callback(err, nil) - return - } - log.Println("Started listener", start_response) - callback(nil, nil) -} - -func (c *mcuJanusClient) sendCandidate(ctx context.Context, candidate interface{}, callback func(error, map[string]interface{})) { - handle := c.handle - if handle == nil { - callback(ErrNotConnected, nil) - return - } - - if _, err := handle.Trickle(ctx, candidate); err != nil { - callback(err, nil) - return - } - callback(nil, nil) -} - -func (c *mcuJanusClient) handleTrickle(event *TrickleMsg) { - if event.Candidate.Completed { - c.listener.OnIceCompleted(c) - } else { - c.listener.OnIceCandidate(c, event.Candidate) - } -} - -func (c *mcuJanusClient) selectStream(ctx context.Context, stream *streamSelection, callback func(error, map[string]interface{})) { - handle := c.handle - if handle == nil { - callback(ErrNotConnected, nil) - return - } - - if stream == nil || !stream.HasValues() { - callback(nil, nil) - return - } - - configure_msg := map[string]interface{}{ - "request": "configure", - } - if stream != nil { - stream.AddToMessage(configure_msg) - } - _, err := handle.Message(ctx, configure_msg, nil) - if err != nil { - callback(err, nil) - return - } - - callback(nil, nil) -} diff --git a/mcu_janus_publisher_test.go b/mcu_janus_publisher_test.go deleted file mode 100644 index ab7d96a..0000000 --- a/mcu_janus_publisher_test.go +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2024 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestGetFmtpValueH264(t *testing.T) { - assert := assert.New(t) - testcases := []struct { - fmtp string - profile string - }{ - { - "", - "", - }, - { - "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f", - "42001f", - }, - { - "level-asymmetry-allowed=1;packetization-mode=0", - "", - }, - { - "level-asymmetry-allowed=1; packetization-mode=0; profile-level-id = 42001f", - "42001f", - }, - } - - for _, tc := range testcases { - value, found := getFmtpValue(tc.fmtp, "profile-level-id") - if !found && tc.profile != "" { - assert.Fail("did not find profile \"%s\" in \"%s\"", tc.profile, tc.fmtp) - } else if found && tc.profile == "" { - assert.Fail("did not expect profile in \"%s\" but got \"%s\"", tc.fmtp, value) - } else if found && tc.profile != value { - assert.Fail("expected profile \"%s\" in \"%s\" but got \"%s\"", tc.profile, tc.fmtp, value) - } - } -} - -func TestGetFmtpValueVP9(t *testing.T) { - assert := assert.New(t) - testcases := []struct { - fmtp string - profile string - }{ - { - "", - "", - }, - { - "profile-id=0", - "0", - }, - { - "profile-id = 0", - "0", - }, - } - - for _, tc := range testcases { - value, found := getFmtpValue(tc.fmtp, "profile-id") - if !found && tc.profile != "" { - assert.Fail("did not find profile \"%s\" in \"%s\"", tc.profile, tc.fmtp) - } else if found && tc.profile == "" { - assert.Fail("did not expect profile in \"%s\" but got \"%s\"", tc.fmtp, value) - } else if found && tc.profile != value { - assert.Fail("expected profile \"%s\" in \"%s\" but got \"%s\"", tc.profile, tc.fmtp, value) - } - } -} diff --git a/mcu_janus_remote_publisher.go b/mcu_janus_remote_publisher.go deleted file mode 100644 index 9a3575b..0000000 --- a/mcu_janus_remote_publisher.go +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2024 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "log" - "sync/atomic" - - "github.com/notedit/janus-go" -) - -type mcuJanusRemotePublisher struct { - mcuJanusPublisher - - ref atomic.Int64 - - controller RemotePublisherController - - port int - rtcpPort int -} - -func (p *mcuJanusRemotePublisher) addRef() int64 { - return p.ref.Add(1) -} - -func (p *mcuJanusRemotePublisher) release() bool { - return p.ref.Add(-1) == 0 -} - -func (p *mcuJanusRemotePublisher) Port() int { - return p.port -} - -func (p *mcuJanusRemotePublisher) RtcpPort() int { - return p.rtcpPort -} - -func (p *mcuJanusRemotePublisher) handleEvent(event *janus.EventMsg) { - if videoroom := getPluginStringValue(event.Plugindata, pluginVideoRoom, "videoroom"); videoroom != "" { - ctx := context.TODO() - switch videoroom { - case "destroyed": - log.Printf("Remote publisher %d: associated room has been destroyed, closing", p.handleId) - go p.Close(ctx) - case "slow_link": - // Ignore, processed through "handleSlowLink" in the general events. - default: - log.Printf("Unsupported videoroom remote publisher event in %d: %+v", p.handleId, event) - } - } else { - log.Printf("Unsupported remote publisher event in %d: %+v", p.handleId, event) - } -} - -func (p *mcuJanusRemotePublisher) handleHangup(event *janus.HangupMsg) { - log.Printf("Remote publisher %d received hangup (%s), closing", p.handleId, event.Reason) - go p.Close(context.Background()) -} - -func (p *mcuJanusRemotePublisher) handleDetached(event *janus.DetachedMsg) { - log.Printf("Remote publisher %d received detached, closing", p.handleId) - go p.Close(context.Background()) -} - -func (p *mcuJanusRemotePublisher) handleConnected(event *janus.WebRTCUpMsg) { - log.Printf("Remote publisher %d received connected", p.handleId) - p.mcu.publisherConnected.Notify(getStreamId(p.id, p.streamType)) -} - -func (p *mcuJanusRemotePublisher) handleSlowLink(event *janus.SlowLinkMsg) { - if event.Uplink { - log.Printf("Remote publisher %s (%d) is reporting %d lost packets on the uplink (Janus -> client)", p.listener.PublicId(), p.handleId, event.Lost) - } else { - log.Printf("Remote publisher %s (%d) is reporting %d lost packets on the downlink (client -> Janus)", p.listener.PublicId(), p.handleId, event.Lost) - } -} - -func (p *mcuJanusRemotePublisher) NotifyReconnected() { - ctx := context.TODO() - handle, session, roomId, _, err := p.mcu.getOrCreatePublisherHandle(ctx, p.id, p.streamType, p.settings) - if err != nil { - log.Printf("Could not reconnect remote publisher %s: %s", p.id, err) - // TODO(jojo): Retry - return - } - - p.handle = handle - p.handleId = handle.Id - p.session = session - p.roomId = roomId - - log.Printf("Remote publisher %s reconnected on handle %d", p.id, p.handleId) -} - -func (p *mcuJanusRemotePublisher) Close(ctx context.Context) { - if !p.release() { - return - } - - if err := p.controller.StopPublishing(ctx, p); err != nil { - log.Printf("Error stopping remote publisher %s in room %d: %s", p.id, p.roomId, err) - } - - p.mu.Lock() - if handle := p.handle; handle != nil { - response, err := p.handle.Request(ctx, map[string]interface{}{ - "request": "remove_remote_publisher", - "room": p.roomId, - "id": streamTypeUserIds[p.streamType], - }) - if err != nil { - log.Printf("Error removing remote publisher %s in room %d: %s", p.id, p.roomId, err) - } else { - log.Printf("Removed remote publisher: %+v", response) - } - if p.roomId != 0 { - destroy_msg := map[string]interface{}{ - "request": "destroy", - "room": p.roomId, - } - if _, err := handle.Request(ctx, destroy_msg); err != nil { - log.Printf("Error destroying room %d: %s", p.roomId, err) - } else { - log.Printf("Room %d destroyed", p.roomId) - } - p.mcu.mu.Lock() - delete(p.mcu.remotePublishers, getStreamId(p.id, p.streamType)) - p.mcu.mu.Unlock() - p.roomId = 0 - } - } - - p.closeClient(ctx) - p.mu.Unlock() -} diff --git a/mcu_janus_subscriber.go b/mcu_janus_subscriber.go deleted file mode 100644 index b79575f..0000000 --- a/mcu_janus_subscriber.go +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "fmt" - "log" - "strconv" - - "github.com/notedit/janus-go" -) - -type mcuJanusSubscriber struct { - mcuJanusClient - - publisher string -} - -func (p *mcuJanusSubscriber) Publisher() string { - return p.publisher -} - -func (p *mcuJanusSubscriber) handleEvent(event *janus.EventMsg) { - if videoroom := getPluginStringValue(event.Plugindata, pluginVideoRoom, "videoroom"); videoroom != "" { - ctx := context.TODO() - switch videoroom { - case "destroyed": - log.Printf("Subscriber %d: associated room has been destroyed, closing", p.handleId) - go p.Close(ctx) - case "event": - // Handle renegotiations, but ignore other events like selected - // substream / temporal layer. - if getPluginStringValue(event.Plugindata, pluginVideoRoom, "configured") == "ok" && - event.Jsep != nil && event.Jsep["type"] == "offer" && event.Jsep["sdp"] != nil { - p.listener.OnUpdateOffer(p, event.Jsep) - } - case "slow_link": - // Ignore, processed through "handleSlowLink" in the general events. - default: - log.Printf("Unsupported videoroom event %s for subscriber %d: %+v", videoroom, p.handleId, event) - } - } else { - log.Printf("Unsupported event for subscriber %d: %+v", p.handleId, event) - } -} - -func (p *mcuJanusSubscriber) handleHangup(event *janus.HangupMsg) { - log.Printf("Subscriber %d received hangup (%s), closing", p.handleId, event.Reason) - go p.Close(context.Background()) -} - -func (p *mcuJanusSubscriber) handleDetached(event *janus.DetachedMsg) { - log.Printf("Subscriber %d received detached, closing", p.handleId) - go p.Close(context.Background()) -} - -func (p *mcuJanusSubscriber) handleConnected(event *janus.WebRTCUpMsg) { - log.Printf("Subscriber %d received connected", p.handleId) - p.mcu.SubscriberConnected(p.Id(), p.publisher, p.streamType) -} - -func (p *mcuJanusSubscriber) handleSlowLink(event *janus.SlowLinkMsg) { - if event.Uplink { - log.Printf("Subscriber %s (%d) is reporting %d lost packets on the uplink (Janus -> client)", p.listener.PublicId(), p.handleId, event.Lost) - } else { - log.Printf("Subscriber %s (%d) is reporting %d lost packets on the downlink (client -> Janus)", p.listener.PublicId(), p.handleId, event.Lost) - } -} - -func (p *mcuJanusSubscriber) handleMedia(event *janus.MediaMsg) { - // Only triggered for publishers -} - -func (p *mcuJanusSubscriber) NotifyReconnected() { - ctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) - defer cancel() - handle, pub, err := p.mcu.getOrCreateSubscriberHandle(ctx, p.publisher, p.streamType) - if err != nil { - // TODO(jojo): Retry? - log.Printf("Could not reconnect subscriber for publisher %s: %s", p.publisher, err) - p.Close(context.Background()) - return - } - - p.handle = handle - p.handleId = handle.Id - p.roomId = pub.roomId - p.sid = strconv.FormatUint(handle.Id, 10) - p.listener.SubscriberSidUpdated(p) - log.Printf("Subscriber %d for publisher %s reconnected on handle %d", p.id, p.publisher, p.handleId) -} - -func (p *mcuJanusSubscriber) Close(ctx context.Context) { - p.mu.Lock() - closed := p.closeClient(ctx) - p.mu.Unlock() - - if closed { - p.mcu.SubscriberDisconnected(p.Id(), p.publisher, p.streamType) - statsSubscribersCurrent.WithLabelValues(string(p.streamType)).Dec() - } - p.mcu.unregisterClient(p) - p.listener.SubscriberClosed(p) - p.mcuJanusClient.Close(ctx) -} - -func (p *mcuJanusSubscriber) joinRoom(ctx context.Context, stream *streamSelection, callback func(error, map[string]interface{})) { - handle := p.handle - if handle == nil { - callback(ErrNotConnected, nil) - return - } - - waiter := p.mcu.publisherConnected.NewWaiter(getStreamId(p.publisher, p.streamType)) - defer p.mcu.publisherConnected.Release(waiter) - - loggedNotPublishingYet := false -retry: - join_msg := map[string]interface{}{ - "request": "join", - "ptype": "subscriber", - "room": p.roomId, - } - if p.mcu.isMultistream() { - join_msg["streams"] = []map[string]interface{}{ - { - "feed": streamTypeUserIds[p.streamType], - }, - } - } else { - join_msg["feed"] = streamTypeUserIds[p.streamType] - } - if stream != nil { - stream.AddToMessage(join_msg) - } - join_response, err := handle.Message(ctx, join_msg, nil) - if err != nil { - callback(err, nil) - return - } - - if error_code := getPluginIntValue(join_response.Plugindata, pluginVideoRoom, "error_code"); error_code > 0 { - switch error_code { - case JANUS_VIDEOROOM_ERROR_ALREADY_JOINED: - // The subscriber is already connected to the room. This can happen - // if a client leaves a call but keeps the subscriber objects active. - // On joining the call again, the subscriber tries to join on the - // MCU which will fail because he is still connected. - // To get a new Offer SDP, we have to tear down the session on the - // MCU and join again. - p.mu.Lock() - p.closeClient(ctx) - p.mu.Unlock() - - var pub *mcuJanusPublisher - handle, pub, err = p.mcu.getOrCreateSubscriberHandle(ctx, p.publisher, p.streamType) - if err != nil { - // Reconnection didn't work, need to unregister/remove subscriber - // so a new object will be created if the request is retried. - p.mcu.unregisterClient(p) - p.listener.SubscriberClosed(p) - callback(fmt.Errorf("Already connected as subscriber for %s, error during re-joining: %s", p.streamType, err), nil) - return - } - - p.handle = handle - p.handleId = handle.Id - p.roomId = pub.roomId - p.sid = strconv.FormatUint(handle.Id, 10) - p.listener.SubscriberSidUpdated(p) - p.closeChan = make(chan struct{}, 1) - go p.run(p.handle, p.closeChan) - log.Printf("Already connected subscriber %d for %s, leaving and re-joining on handle %d", p.id, p.streamType, p.handleId) - goto retry - case JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM: - fallthrough - case JANUS_VIDEOROOM_ERROR_NO_SUCH_FEED: - switch error_code { - case JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM: - log.Printf("Publisher %s not created yet for %s, wait and retry to join room %d as subscriber", p.publisher, p.streamType, p.roomId) - case JANUS_VIDEOROOM_ERROR_NO_SUCH_FEED: - log.Printf("Publisher %s not sending yet for %s, wait and retry to join room %d as subscriber", p.publisher, p.streamType, p.roomId) - } - - if !loggedNotPublishingYet { - loggedNotPublishingYet = true - statsWaitingForPublisherTotal.WithLabelValues(string(p.streamType)).Inc() - } - - if err := waiter.Wait(ctx); err != nil { - callback(err, nil) - return - } - log.Printf("Retry subscribing %s from %s", p.streamType, p.publisher) - goto retry - default: - // TODO(jojo): Should we handle other errors, too? - callback(fmt.Errorf("Error joining room as subscriber: %+v", join_response), nil) - return - } - } - //log.Println("Joined as listener", join_response) - - p.session = join_response.Session - callback(nil, join_response.Jsep) -} - -func (p *mcuJanusSubscriber) update(ctx context.Context, stream *streamSelection, callback func(error, map[string]interface{})) { - handle := p.handle - if handle == nil { - callback(ErrNotConnected, nil) - return - } - - configure_msg := map[string]interface{}{ - "request": "configure", - "update": true, - } - if stream != nil { - stream.AddToMessage(configure_msg) - } - configure_response, err := handle.Message(ctx, configure_msg, nil) - if err != nil { - callback(err, nil) - return - } - - callback(nil, configure_response.Jsep) -} - -func (p *mcuJanusSubscriber) SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) { - statsMcuMessagesTotal.WithLabelValues(data.Type).Inc() - jsep_msg := data.Payload - switch data.Type { - case "requestoffer": - fallthrough - case "sendoffer": - p.deferred <- func() { - msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) - defer cancel() - - stream, err := parseStreamSelection(jsep_msg) - if err != nil { - go callback(err, nil) - return - } - - if data.Sid == "" || data.Sid != p.Sid() { - p.joinRoom(msgctx, stream, callback) - } else { - p.update(msgctx, stream, callback) - } - } - case "answer": - p.deferred <- func() { - msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) - defer cancel() - - if data.Sid == "" || data.Sid == p.Sid() { - p.sendAnswer(msgctx, jsep_msg, callback) - } else { - go callback(fmt.Errorf("Answer message sid (%s) does not match subscriber sid (%s)", data.Sid, p.Sid()), nil) - } - } - case "candidate": - p.deferred <- func() { - msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) - defer cancel() - - if data.Sid == "" || data.Sid == p.Sid() { - p.sendCandidate(msgctx, jsep_msg["candidate"], callback) - } else { - go callback(fmt.Errorf("Candidate message sid (%s) does not match subscriber sid (%s)", data.Sid, p.Sid()), nil) - } - } - case "endOfCandidates": - // Ignore - case "selectStream": - stream, err := parseStreamSelection(jsep_msg) - if err != nil { - go callback(err, nil) - return - } - - if stream == nil || !stream.HasValues() { - // Nothing to do - go callback(nil, nil) - return - } - - p.deferred <- func() { - msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) - defer cancel() - - p.selectStream(msgctx, stream, callback) - } - default: - // Return error asynchronously - go callback(fmt.Errorf("Unsupported message type: %s", data.Type), nil) - } -} diff --git a/mcu_janus_test.go b/mcu_janus_test.go deleted file mode 100644 index f7407d8..0000000 --- a/mcu_janus_test.go +++ /dev/null @@ -1,584 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2024 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "encoding/json" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/dlintw/goconf" - "github.com/notedit/janus-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type TestJanusHandle struct { - id uint64 -} - -type TestJanusRoom struct { - id uint64 -} - -type TestJanusHandler func(room *TestJanusRoom, body map[string]interface{}) (interface{}, *janus.ErrorMsg) - -type TestJanusGateway struct { - t *testing.T - - sid atomic.Uint64 - tid atomic.Uint64 - hid atomic.Uint64 - rid atomic.Uint64 - mu sync.Mutex - - sessions map[uint64]*JanusSession - transactions map[uint64]*transaction - handles map[uint64]*TestJanusHandle - rooms map[uint64]*TestJanusRoom - handlers map[string]TestJanusHandler -} - -func NewTestJanusGateway(t *testing.T) *TestJanusGateway { - gateway := &TestJanusGateway{ - t: t, - - sessions: make(map[uint64]*JanusSession), - transactions: make(map[uint64]*transaction), - handles: make(map[uint64]*TestJanusHandle), - rooms: make(map[uint64]*TestJanusRoom), - handlers: make(map[string]TestJanusHandler), - } - - t.Cleanup(func() { - assert := assert.New(t) - gateway.mu.Lock() - defer gateway.mu.Unlock() - assert.Len(gateway.sessions, 0) - assert.Len(gateway.transactions, 0) - assert.Len(gateway.handles, 0) - assert.Len(gateway.rooms, 0) - }) - - return gateway -} - -func (g *TestJanusGateway) registerHandlers(handlers map[string]TestJanusHandler) { - g.mu.Lock() - defer g.mu.Unlock() - for name, handler := range handlers { - g.handlers[name] = handler - } -} - -func (g *TestJanusGateway) Info(ctx context.Context) (*InfoMsg, error) { - return &InfoMsg{ - Name: "TestJanus", - Version: 1400, - VersionString: "1.4.0", - Author: "struktur AG", - DataChannels: true, - FullTrickle: true, - Plugins: map[string]janus.PluginInfo{ - pluginVideoRoom: { - Name: "Test VideoRoom plugin", - VersionString: "0.0.0", - Author: "struktur AG", - }, - }, - }, nil -} - -func (g *TestJanusGateway) Create(ctx context.Context) (*JanusSession, error) { - sid := g.sid.Add(1) - session := &JanusSession{ - Id: sid, - Handles: make(map[uint64]*JanusHandle), - gateway: g, - } - g.mu.Lock() - defer g.mu.Unlock() - g.sessions[sid] = session - return session, nil -} - -func (g *TestJanusGateway) Close() error { - return nil -} - -func (g *TestJanusGateway) processMessage(session *JanusSession, handle *TestJanusHandle, body map[string]interface{}) interface{} { - request := body["request"].(string) - switch request { - case "create": - room := &TestJanusRoom{ - id: g.rid.Add(1), - } - g.rooms[room.id] = room - - return &janus.SuccessMsg{ - PluginData: janus.PluginData{ - Plugin: pluginVideoRoom, - Data: map[string]interface{}{ - "room": room.id, - }, - }, - } - case "join": - rid := body["room"].(float64) - room := g.rooms[uint64(rid)] - if room == nil { - return &janus.ErrorMsg{ - Err: janus.ErrorData{ - Code: JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM, - Reason: "Room not found", - }, - } - } - - assert.Equal(g.t, "publisher", body["ptype"]) - return &janus.EventMsg{ - Session: session.Id, - Handle: handle.id, - Plugindata: janus.PluginData{ - Plugin: pluginVideoRoom, - Data: map[string]interface{}{ - "room": room.id, - }, - }, - } - case "destroy": - rid := body["room"].(float64) - room := g.rooms[uint64(rid)] - if room == nil { - return &janus.ErrorMsg{ - Err: janus.ErrorData{ - Code: JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM, - Reason: "Room not found", - }, - } - } - - delete(g.rooms, uint64(rid)) - - return &janus.SuccessMsg{ - PluginData: janus.PluginData{ - Plugin: pluginVideoRoom, - Data: map[string]interface{}{}, - }, - } - default: - rid := body["room"].(float64) - room := g.rooms[uint64(rid)] - if room == nil { - return &janus.ErrorMsg{ - Err: janus.ErrorData{ - Code: JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM, - Reason: "Room not found", - }, - } - } - - handler, found := g.handlers[request] - if found { - var err *janus.ErrorMsg - result, err := handler(room, body) - if err != nil { - result = err - } - return result - } - } - - return nil -} - -func (g *TestJanusGateway) processRequest(msg map[string]interface{}) interface{} { - method, found := msg["janus"] - if !found { - return nil - } - - sid := msg["session_id"].(float64) - g.mu.Lock() - defer g.mu.Unlock() - session := g.sessions[uint64(sid)] - if session == nil { - return &janus.ErrorMsg{ - Err: janus.ErrorData{ - Code: JANUS_ERROR_SESSION_NOT_FOUND, - Reason: "Session not found", - }, - } - } - - switch method { - case "attach": - handle := &TestJanusHandle{ - id: g.hid.Add(1), - } - - g.handles[handle.id] = handle - - return &janus.SuccessMsg{ - Data: janus.SuccessData{ - ID: handle.id, - }, - } - case "detach": - hid := msg["handle_id"].(float64) - handle, found := g.handles[uint64(hid)] - if found { - delete(g.handles, handle.id) - } - if handle == nil { - return &janus.ErrorMsg{ - Err: janus.ErrorData{ - Code: JANUS_ERROR_HANDLE_NOT_FOUND, - Reason: "Handle not found", - }, - } - } - - return &janus.AckMsg{} - case "destroy": - delete(g.sessions, session.Id) - return &janus.AckMsg{} - case "message": - hid := msg["handle_id"].(float64) - handle, found := g.handles[uint64(hid)] - if !found { - return &janus.ErrorMsg{ - Err: janus.ErrorData{ - Code: JANUS_ERROR_HANDLE_NOT_FOUND, - Reason: "Handle not found", - }, - } - } - - body := msg["body"].(map[string]interface{}) - return g.processMessage(session, handle, body) - } - - return nil -} - -func (g *TestJanusGateway) send(msg map[string]interface{}, t *transaction) (uint64, error) { - tid := g.tid.Add(1) - - data, err := json.Marshal(msg) - require.NoError(g.t, err) - err = json.Unmarshal(data, &msg) - require.NoError(g.t, err) - - go t.run() - - g.mu.Lock() - defer g.mu.Unlock() - g.transactions[tid] = t - - go func() { - result := g.processRequest(msg) - if !assert.NotNil(g.t, result, "Unsupported request %+v", msg) { - result = &janus.ErrorMsg{ - Err: janus.ErrorData{ - Code: JANUS_ERROR_UNKNOWN, - Reason: "Not implemented", - }, - } - } - - t.add(result) - }() - - return tid, nil -} - -func (g *TestJanusGateway) removeTransaction(id uint64) { - g.mu.Lock() - defer g.mu.Unlock() - delete(g.transactions, id) -} - -func (g *TestJanusGateway) removeSession(session *JanusSession) { - g.mu.Lock() - defer g.mu.Unlock() - delete(g.sessions, session.Id) -} - -func newMcuJanusForTesting(t *testing.T) (*mcuJanus, *TestJanusGateway) { - gateway := NewTestJanusGateway(t) - - config := goconf.NewConfigFile() - mcu, err := NewMcuJanus(context.Background(), "", config) - require.NoError(t, err) - t.Cleanup(func() { - mcu.Stop() - }) - - mcuJanus := mcu.(*mcuJanus) - mcuJanus.createJanusGateway = func(ctx context.Context, wsURL string, listener GatewayListener) (JanusGatewayInterface, error) { - return gateway, nil - } - require.NoError(t, mcu.Start(context.Background())) - return mcuJanus, gateway -} - -type TestMcuListener struct { - id string -} - -func (t *TestMcuListener) PublicId() string { - return t.id -} - -func (t *TestMcuListener) OnUpdateOffer(client McuClient, offer map[string]interface{}) { - -} - -func (t *TestMcuListener) OnIceCandidate(client McuClient, candidate interface{}) { - -} - -func (t *TestMcuListener) OnIceCompleted(client McuClient) { - -} - -func (t *TestMcuListener) SubscriberSidUpdated(subscriber McuSubscriber) { - -} - -func (t *TestMcuListener) PublisherClosed(publisher McuPublisher) { - -} - -func (t *TestMcuListener) SubscriberClosed(subscriber McuSubscriber) { - -} - -type TestMcuController struct { - id string -} - -func (c *TestMcuController) PublisherId() string { - return c.id -} - -func (c *TestMcuController) StartPublishing(ctx context.Context, publisher McuRemotePublisherProperties) error { - // TODO: Check parameters? - return nil -} - -func (c *TestMcuController) StopPublishing(ctx context.Context, publisher McuRemotePublisherProperties) error { - // TODO: Check parameters? - return nil -} - -func (c *TestMcuController) GetStreams(ctx context.Context) ([]PublisherStream, error) { - streams := []PublisherStream{ - { - Mid: "0", - Mindex: 0, - Type: "audio", - Codec: "opus", - }, - } - return streams, nil -} - -type TestMcuInitiator struct { - country string -} - -func (i *TestMcuInitiator) Country() string { - return i.country -} - -func Test_JanusPublisherSubscriber(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - require := require.New(t) - - mcu, gateway := newMcuJanusForTesting(t) - gateway.registerHandlers(map[string]TestJanusHandler{}) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "publisher-id" - listener1 := &TestMcuListener{ - id: pubId, - } - - settings1 := NewPublisherSettings{} - initiator1 := &TestMcuInitiator{ - country: "DE", - } - - pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", StreamTypeVideo, settings1, initiator1) - require.NoError(err) - defer pub.Close(context.Background()) - - listener2 := &TestMcuListener{ - id: pubId, - } - - initiator2 := &TestMcuInitiator{ - country: "DE", - } - sub, err := mcu.NewSubscriber(ctx, listener2, pubId, StreamTypeVideo, initiator2) - require.NoError(err) - defer sub.Close(context.Background()) -} - -func Test_JanusSubscriberPublisher(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - require := require.New(t) - - mcu, gateway := newMcuJanusForTesting(t) - gateway.registerHandlers(map[string]TestJanusHandler{}) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "publisher-id" - listener1 := &TestMcuListener{ - id: pubId, - } - - settings1 := NewPublisherSettings{} - initiator1 := &TestMcuInitiator{ - country: "DE", - } - - ready := make(chan struct{}) - done := make(chan struct{}) - - go func() { - defer close(done) - time.Sleep(100 * time.Millisecond) - pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", StreamTypeVideo, settings1, initiator1) - require.NoError(err) - defer func() { - <-ready - pub.Close(context.Background()) - }() - }() - - listener2 := &TestMcuListener{ - id: pubId, - } - - initiator2 := &TestMcuInitiator{ - country: "DE", - } - sub, err := mcu.NewSubscriber(ctx, listener2, pubId, StreamTypeVideo, initiator2) - require.NoError(err) - defer sub.Close(context.Background()) - close(ready) - <-done -} - -func Test_JanusRemotePublisher(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - assert := assert.New(t) - require := require.New(t) - - var added atomic.Int32 - var removed atomic.Int32 - - mcu, gateway := newMcuJanusForTesting(t) - gateway.registerHandlers(map[string]TestJanusHandler{ - "add_remote_publisher": func(room *TestJanusRoom, body map[string]interface{}) (interface{}, *janus.ErrorMsg) { - assert.EqualValues(1, room.id) - if streams := body["streams"].([]interface{}); assert.Len(streams, 1) { - stream := streams[0].(map[string]interface{}) - assert.Equal("0", stream["mid"]) - assert.EqualValues(0, stream["mindex"]) - assert.Equal("audio", stream["type"]) - assert.Equal("opus", stream["codec"]) - } - added.Add(1) - return &janus.SuccessMsg{ - PluginData: janus.PluginData{ - Plugin: pluginVideoRoom, - Data: map[string]interface{}{ - "id": 12345, - "port": 10000, - "rtcp_port": 10001, - }, - }, - }, nil - }, - "remove_remote_publisher": func(room *TestJanusRoom, body map[string]interface{}) (interface{}, *janus.ErrorMsg) { - assert.EqualValues(1, room.id) - removed.Add(1) - return &janus.SuccessMsg{ - PluginData: janus.PluginData{ - Plugin: pluginVideoRoom, - Data: map[string]interface{}{}, - }, - }, nil - }, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - listener1 := &TestMcuListener{ - id: "publisher-id", - } - - controller := &TestMcuController{ - id: listener1.id, - } - - pub, err := mcu.NewRemotePublisher(ctx, listener1, controller, StreamTypeVideo) - require.NoError(err) - defer pub.Close(context.Background()) - - assert.EqualValues(1, added.Load()) - assert.EqualValues(0, removed.Load()) - - listener2 := &TestMcuListener{ - id: "subscriber-id", - } - - sub, err := mcu.NewRemoteSubscriber(ctx, listener2, pub) - require.NoError(err) - defer sub.Close(context.Background()) - - pub.Close(context.Background()) - - assert.EqualValues(1, added.Load()) - // The publisher is ref-counted, and still referenced by the subscriber. - assert.EqualValues(0, removed.Load()) - - sub.Close(context.Background()) - - assert.EqualValues(1, added.Load()) - assert.EqualValues(1, removed.Load()) -} diff --git a/mcu_proxy.go b/mcu_proxy.go deleted file mode 100644 index 0d8c537..0000000 --- a/mcu_proxy.go +++ /dev/null @@ -1,2136 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2020 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "crypto/rsa" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "log" - "net" - "net/http" - "net/url" - "os" - "slices" - "sort" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/dlintw/goconf" - "github.com/golang-jwt/jwt/v5" - "github.com/gorilla/websocket" -) - -const ( - closeTimeout = time.Second - - proxyDebugMessages = false - - // Very high value so the connections get sorted at the end. - loadNotConnected = 1000000 - - // Sort connections by load every 10 publishing requests or once per second. - connectionSortRequests = 10 - connectionSortInterval = time.Second - - proxyUrlTypeStatic = "static" - proxyUrlTypeEtcd = "etcd" - - initialWaitDelay = time.Second - maxWaitDelay = 8 * time.Second - - defaultProxyTimeoutSeconds = 2 - - rttLogDuration = 500 * time.Millisecond -) - -type McuProxy interface { - AddConnection(ignoreErrors bool, url string, ips ...net.IP) error - KeepConnection(url string, ips ...net.IP) - RemoveConnection(url string, ips ...net.IP) -} - -type mcuProxyPubSubCommon struct { - sid string - streamType StreamType - maxBitrate int - proxyId string - conn *mcuProxyConnection - listener McuListener -} - -func (c *mcuProxyPubSubCommon) Id() string { - return c.proxyId -} - -func (c *mcuProxyPubSubCommon) Sid() string { - return c.sid -} - -func (c *mcuProxyPubSubCommon) StreamType() StreamType { - return c.streamType -} - -func (c *mcuProxyPubSubCommon) MaxBitrate() int { - return c.maxBitrate -} - -func (c *mcuProxyPubSubCommon) doSendMessage(ctx context.Context, msg *ProxyClientMessage, callback func(error, map[string]interface{})) { - c.conn.performAsyncRequest(ctx, msg, func(err error, response *ProxyServerMessage) { - if err != nil { - callback(err, nil) - return - } - - if proxyDebugMessages { - log.Printf("Response from %s: %+v", c.conn, response) - } - if response.Type == "error" { - callback(response.Error, nil) - } else if response.Payload != nil { - callback(nil, response.Payload.Payload) - } else { - callback(nil, nil) - } - }) -} - -func (c *mcuProxyPubSubCommon) doProcessPayload(client McuClient, msg *PayloadProxyServerMessage) { - switch msg.Type { - case "offer": - c.listener.OnUpdateOffer(client, msg.Payload["offer"].(map[string]interface{})) - case "candidate": - c.listener.OnIceCandidate(client, msg.Payload["candidate"]) - default: - log.Printf("Unsupported payload from %s: %+v", c.conn, msg) - } -} - -type mcuProxyPublisher struct { - mcuProxyPubSubCommon - - id string - settings NewPublisherSettings -} - -func newMcuProxyPublisher(id string, sid string, streamType StreamType, maxBitrate int, settings NewPublisherSettings, proxyId string, conn *mcuProxyConnection, listener McuListener) *mcuProxyPublisher { - return &mcuProxyPublisher{ - mcuProxyPubSubCommon: mcuProxyPubSubCommon{ - sid: sid, - streamType: streamType, - maxBitrate: maxBitrate, - proxyId: proxyId, - conn: conn, - listener: listener, - }, - id: id, - settings: settings, - } -} - -func (p *mcuProxyPublisher) HasMedia(mt MediaType) bool { - return (p.settings.MediaTypes & mt) == mt -} - -func (p *mcuProxyPublisher) SetMedia(mt MediaType) { - // TODO: Also update mediaTypes on proxy. - p.settings.MediaTypes = mt -} - -func (p *mcuProxyPublisher) NotifyClosed() { - log.Printf("Publisher %s at %s was closed", p.proxyId, p.conn) - p.listener.PublisherClosed(p) - p.conn.removePublisher(p) -} - -func (p *mcuProxyPublisher) Close(ctx context.Context) { - p.NotifyClosed() - - msg := &ProxyClientMessage{ - Type: "command", - Command: &CommandProxyClientMessage{ - Type: "delete-publisher", - ClientId: p.proxyId, - }, - } - - if response, err := p.conn.performSyncRequest(ctx, msg); err != nil { - log.Printf("Could not delete publisher %s at %s: %s", p.proxyId, p.conn, err) - return - } else if response.Type == "error" { - log.Printf("Could not delete publisher %s at %s: %s", p.proxyId, p.conn, response.Error) - return - } - - log.Printf("Deleted publisher %s at %s", p.proxyId, p.conn) -} - -func (p *mcuProxyPublisher) SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) { - msg := &ProxyClientMessage{ - Type: "payload", - Payload: &PayloadProxyClientMessage{ - Type: data.Type, - ClientId: p.proxyId, - Sid: data.Sid, - Payload: data.Payload, - }, - } - - p.doSendMessage(ctx, msg, callback) -} - -func (p *mcuProxyPublisher) ProcessPayload(msg *PayloadProxyServerMessage) { - p.doProcessPayload(p, msg) -} - -func (p *mcuProxyPublisher) ProcessEvent(msg *EventProxyServerMessage) { - switch msg.Type { - case "ice-completed": - p.listener.OnIceCompleted(p) - case "publisher-closed": - p.NotifyClosed() - default: - log.Printf("Unsupported event from %s: %+v", p.conn, msg) - } -} - -func (p *mcuProxyPublisher) GetStreams(ctx context.Context) ([]PublisherStream, error) { - return nil, errors.New("not implemented") -} - -func (p *mcuProxyPublisher) PublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error { - return errors.New("remote publishing not supported for proxy publishers") -} - -func (p *mcuProxyPublisher) UnpublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error { - return errors.New("remote publishing not supported for proxy publishers") -} - -type mcuProxySubscriber struct { - mcuProxyPubSubCommon - - publisherId string - publisherConn *mcuProxyConnection -} - -func newMcuProxySubscriber(publisherId string, sid string, streamType StreamType, maxBitrate int, proxyId string, conn *mcuProxyConnection, listener McuListener, publisherConn *mcuProxyConnection) *mcuProxySubscriber { - return &mcuProxySubscriber{ - mcuProxyPubSubCommon: mcuProxyPubSubCommon{ - sid: sid, - streamType: streamType, - maxBitrate: maxBitrate, - proxyId: proxyId, - conn: conn, - listener: listener, - }, - - publisherId: publisherId, - publisherConn: publisherConn, - } -} - -func (s *mcuProxySubscriber) Publisher() string { - return s.publisherId -} - -func (s *mcuProxySubscriber) NotifyClosed() { - if s.publisherConn != nil { - log.Printf("Remote subscriber %s at %s (forwarded to %s) was closed", s.proxyId, s.conn, s.publisherConn) - } else { - log.Printf("Subscriber %s at %s was closed", s.proxyId, s.conn) - } - s.listener.SubscriberClosed(s) - s.conn.removeSubscriber(s) -} - -func (s *mcuProxySubscriber) Close(ctx context.Context) { - s.NotifyClosed() - - msg := &ProxyClientMessage{ - Type: "command", - Command: &CommandProxyClientMessage{ - Type: "delete-subscriber", - ClientId: s.proxyId, - }, - } - - if response, err := s.conn.performSyncRequest(ctx, msg); err != nil { - if s.publisherConn != nil { - log.Printf("Could not delete remote subscriber %s at %s (forwarded to %s): %s", s.proxyId, s.conn, s.publisherConn, err) - } else { - log.Printf("Could not delete subscriber %s at %s: %s", s.proxyId, s.conn, err) - } - return - } else if response.Type == "error" { - if s.publisherConn != nil { - log.Printf("Could not delete remote subscriber %s at %s (forwarded to %s): %s", s.proxyId, s.conn, s.publisherConn, response.Error) - } else { - log.Printf("Could not delete subscriber %s at %s: %s", s.proxyId, s.conn, response.Error) - } - return - } - - if s.publisherConn != nil { - log.Printf("Deleted remote subscriber %s at %s (forwarded to %s)", s.proxyId, s.conn, s.publisherConn) - } else { - log.Printf("Deleted subscriber %s at %s", s.proxyId, s.conn) - } -} - -func (s *mcuProxySubscriber) SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) { - msg := &ProxyClientMessage{ - Type: "payload", - Payload: &PayloadProxyClientMessage{ - Type: data.Type, - ClientId: s.proxyId, - Sid: data.Sid, - Payload: data.Payload, - }, - } - - s.doSendMessage(ctx, msg, callback) -} - -func (s *mcuProxySubscriber) ProcessPayload(msg *PayloadProxyServerMessage) { - s.doProcessPayload(s, msg) -} - -func (s *mcuProxySubscriber) ProcessEvent(msg *EventProxyServerMessage) { - switch msg.Type { - case "ice-completed": - s.listener.OnIceCompleted(s) - case "subscriber-sid-updated": - s.sid = msg.Sid - s.listener.SubscriberSidUpdated(s) - case "subscriber-closed": - s.NotifyClosed() - default: - log.Printf("Unsupported event from %s: %+v", s.conn, msg) - } -} - -type mcuProxyConnection struct { - proxy *mcuProxy - rawUrl string - url *url.URL - ip net.IP - - load atomic.Int64 - bandwidth atomic.Pointer[EventProxyServerBandwidth] - mu sync.Mutex - closer *Closer - closedDone *Closer - closed atomic.Bool - conn *websocket.Conn - - connectedSince time.Time - reconnectTimer *time.Timer - reconnectInterval atomic.Int64 - shutdownScheduled atomic.Bool - closeScheduled atomic.Bool - trackClose atomic.Bool - temporary atomic.Bool - - connectedNotifier SingleNotifier - - msgId atomic.Int64 - helloMsgId string - sessionId atomic.Value - country atomic.Value - - callbacks map[string]func(*ProxyServerMessage) - - publishersLock sync.RWMutex - publishers map[string]*mcuProxyPublisher - publisherIds map[string]string - - subscribersLock sync.RWMutex - subscribers map[string]*mcuProxySubscriber -} - -func newMcuProxyConnection(proxy *mcuProxy, baseUrl string, ip net.IP) (*mcuProxyConnection, error) { - parsed, err := url.Parse(baseUrl) - if err != nil { - return nil, err - } - - conn := &mcuProxyConnection{ - proxy: proxy, - rawUrl: baseUrl, - url: parsed, - ip: ip, - closer: NewCloser(), - closedDone: NewCloser(), - callbacks: make(map[string]func(*ProxyServerMessage)), - publishers: make(map[string]*mcuProxyPublisher), - publisherIds: make(map[string]string), - subscribers: make(map[string]*mcuProxySubscriber), - } - conn.reconnectInterval.Store(int64(initialReconnectInterval)) - conn.load.Store(loadNotConnected) - conn.bandwidth.Store(nil) - conn.country.Store("") - return conn, nil -} - -func (c *mcuProxyConnection) String() string { - if c.ip != nil { - return fmt.Sprintf("%s (%s)", c.rawUrl, c.ip) - } - - return c.rawUrl -} - -func (c *mcuProxyConnection) IsSameCountry(initiator McuInitiator) bool { - if initiator == nil { - return true - } - - initiatorCountry := initiator.Country() - if initiatorCountry == "" { - return true - } - - connCountry := c.Country() - if connCountry == "" { - return true - } - - return initiatorCountry == connCountry -} - -func (c *mcuProxyConnection) IsSameContinent(initiator McuInitiator) bool { - if initiator == nil { - return true - } - - initiatorCountry := initiator.Country() - if initiatorCountry == "" { - return true - } - - connCountry := c.Country() - if connCountry == "" { - return true - } - - initiatorContinents, found := ContinentMap[initiatorCountry] - if found { - m := c.proxy.getContinentsMap() - // Map continents to other continents (e.g. use Europe for Africa). - for _, continent := range initiatorContinents { - if toAdd, found := m[continent]; found { - initiatorContinents = append(initiatorContinents, toAdd...) - } - } - - } - connContinents := ContinentMap[connCountry] - return ContinentsOverlap(initiatorContinents, connContinents) -} - -type mcuProxyConnectionStats struct { - Url string `json:"url"` - IP net.IP `json:"ip,omitempty"` - Connected bool `json:"connected"` - Publishers int64 `json:"publishers"` - Clients int64 `json:"clients"` - Load *int64 `json:"load,omitempty"` - Shutdown *bool `json:"shutdown,omitempty"` - Temporary *bool `json:"temporary,omitempty"` - Uptime *time.Time `json:"uptime,omitempty"` -} - -func (c *mcuProxyConnection) GetStats() *mcuProxyConnectionStats { - result := &mcuProxyConnectionStats{ - Url: c.url.String(), - IP: c.ip, - } - c.mu.Lock() - if c.conn != nil { - result.Connected = true - result.Uptime = &c.connectedSince - load := c.Load() - result.Load = &load - shutdown := c.IsShutdownScheduled() - result.Shutdown = &shutdown - temporary := c.IsTemporary() - result.Temporary = &temporary - } - c.mu.Unlock() - c.publishersLock.RLock() - result.Publishers = int64(len(c.publishers)) - c.publishersLock.RUnlock() - c.subscribersLock.RLock() - result.Clients = int64(len(c.subscribers)) - c.subscribersLock.RUnlock() - result.Clients += result.Publishers - return result -} - -func (c *mcuProxyConnection) Load() int64 { - return c.load.Load() -} - -func (c *mcuProxyConnection) Bandwidth() *EventProxyServerBandwidth { - return c.bandwidth.Load() -} - -func (c *mcuProxyConnection) Country() string { - return c.country.Load().(string) -} - -func (c *mcuProxyConnection) SessionId() string { - sid := c.sessionId.Load() - if sid == nil { - return "" - } - - return sid.(string) -} - -func (c *mcuProxyConnection) IsConnected() bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.conn != nil && c.SessionId() != "" -} - -func (c *mcuProxyConnection) IsTemporary() bool { - return c.temporary.Load() -} - -func (c *mcuProxyConnection) setTemporary() { - c.temporary.Store(true) -} - -func (c *mcuProxyConnection) clearTemporary() { - c.temporary.Store(false) -} - -func (c *mcuProxyConnection) IsShutdownScheduled() bool { - return c.shutdownScheduled.Load() || c.closeScheduled.Load() -} - -func (c *mcuProxyConnection) readPump() { - defer func() { - if !c.closed.Load() { - c.scheduleReconnect() - } else { - c.closedDone.Close() - } - }() - defer c.close() - defer func() { - c.load.Store(loadNotConnected) - c.bandwidth.Store(nil) - }() - - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - - conn.SetPongHandler(func(msg string) error { - now := time.Now() - conn.SetReadDeadline(now.Add(pongWait)) // nolint - if msg == "" { - return nil - } - if ts, err := strconv.ParseInt(msg, 10, 64); err == nil { - rtt := now.Sub(time.Unix(0, ts)) - if rtt >= rttLogDuration { - rtt_ms := rtt.Nanoseconds() / time.Millisecond.Nanoseconds() - log.Printf("Proxy at %s has RTT of %d ms (%s)", c, rtt_ms, rtt) - } - } - return nil - }) - - for { - conn.SetReadDeadline(time.Now().Add(pongWait)) // nolint - _, message, err := conn.ReadMessage() - if err != nil { - if errors.Is(err, websocket.ErrCloseSent) { - break - } else if _, ok := err.(*websocket.CloseError); !ok || websocket.IsUnexpectedCloseError(err, - websocket.CloseNormalClosure, - websocket.CloseGoingAway, - websocket.CloseNoStatusReceived) { - log.Printf("Error reading from %s: %v", c, err) - } - break - } - - var msg ProxyServerMessage - if err := json.Unmarshal(message, &msg); err != nil { - log.Printf("Error unmarshaling %s from %s: %s", string(message), c, err) - continue - } - - c.processMessage(&msg) - } -} - -func (c *mcuProxyConnection) sendPing() bool { - c.mu.Lock() - defer c.mu.Unlock() - if c.conn == nil { - return false - } - - now := time.Now() - msg := strconv.FormatInt(now.UnixNano(), 10) - c.conn.SetWriteDeadline(now.Add(writeWait)) // nolint - if err := c.conn.WriteMessage(websocket.PingMessage, []byte(msg)); err != nil { - log.Printf("Could not send ping to proxy at %s: %v", c, err) - go c.scheduleReconnect() - return false - } - - return true -} - -func (c *mcuProxyConnection) writePump() { - ticker := time.NewTicker(pingPeriod) - defer func() { - ticker.Stop() - }() - - c.reconnectTimer = time.NewTimer(0) - defer c.reconnectTimer.Stop() - for { - select { - case <-c.reconnectTimer.C: - c.reconnect() - case <-ticker.C: - c.sendPing() - case <-c.closer.C: - return - } - } -} - -func (c *mcuProxyConnection) start() { - go c.writePump() -} - -func (c *mcuProxyConnection) sendClose() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn == nil { - return ErrNotConnected - } - - c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint - return c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) -} - -func (c *mcuProxyConnection) stop(ctx context.Context) { - if !c.closed.CompareAndSwap(false, true) { - return - } - - c.closer.Close() - if err := c.sendClose(); err != nil { - if err != ErrNotConnected { - log.Printf("Could not send close message to %s: %s", c, err) - } - c.close() - return - } - - select { - case <-c.closedDone.C: - case <-ctx.Done(): - if err := ctx.Err(); err != nil { - log.Printf("Error waiting for connection to %s get closed: %s", c, err) - c.close() - } - } -} - -func (c *mcuProxyConnection) close() { - c.mu.Lock() - defer c.mu.Unlock() - - c.connectedNotifier.Reset() - - if c.conn != nil { - c.conn.Close() - c.conn = nil - if c.trackClose.CompareAndSwap(true, false) { - statsConnectedProxyBackendsCurrent.WithLabelValues(c.Country()).Dec() - } - } -} - -func (c *mcuProxyConnection) stopCloseIfEmpty() { - c.closeScheduled.Store(false) -} - -func (c *mcuProxyConnection) closeIfEmpty() bool { - c.closeScheduled.Store(true) - - var total int64 - c.publishersLock.RLock() - total += int64(len(c.publishers)) - c.publishersLock.RUnlock() - c.subscribersLock.RLock() - total += int64(len(c.subscribers)) - c.subscribersLock.RUnlock() - if total > 0 { - // Connection will be closed once all clients have disconnected. - log.Printf("Connection to %s is still used by %d clients, defer closing", c, total) - return false - } - - go func() { - ctx, cancel := context.WithTimeout(context.Background(), closeTimeout) - defer cancel() - - log.Printf("All clients disconnected, closing connection to %s", c) - c.stop(ctx) - - c.proxy.removeConnection(c) - }() - return true -} - -func (c *mcuProxyConnection) scheduleReconnect() { - if err := c.sendClose(); err != nil && err != ErrNotConnected { - log.Printf("Could not send close message to %s: %s", c, err) - } - c.close() - - if c.IsShutdownScheduled() { - c.proxy.removeConnection(c) - return - } - - interval := c.reconnectInterval.Load() - c.reconnectTimer.Reset(time.Duration(interval)) - - interval = interval * 2 - if interval > int64(maxReconnectInterval) { - interval = int64(maxReconnectInterval) - } - c.reconnectInterval.Store(interval) -} - -func (c *mcuProxyConnection) reconnect() { - u, err := c.url.Parse("proxy") - if err != nil { - log.Printf("Could not resolve url to proxy at %s: %s", c, err) - c.scheduleReconnect() - return - } - if u.Scheme == "http" { - u.Scheme = "ws" - } else if u.Scheme == "https" { - u.Scheme = "wss" - } - - dialer := c.proxy.dialer - if c.ip != nil { - dialer = &websocket.Dialer{ - Proxy: http.ProxyFromEnvironment, - HandshakeTimeout: c.proxy.dialer.HandshakeTimeout, - TLSClientConfig: c.proxy.dialer.TLSClientConfig, - - // Override DNS lookup and connect to custom IP address. - NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - if _, port, err := net.SplitHostPort(addr); err == nil { - addr = net.JoinHostPort(c.ip.String(), port) - } - - return net.Dial(network, addr) - }, - } - } - conn, _, err := dialer.Dial(u.String(), nil) - if err != nil { - log.Printf("Could not connect to %s: %s", c, err) - c.scheduleReconnect() - return - } - - if c.IsShutdownScheduled() { - c.proxy.removeConnection(c) - return - } - - log.Printf("Connected to %s", c) - c.closed.Store(false) - - c.mu.Lock() - c.connectedSince = time.Now() - c.conn = conn - c.mu.Unlock() - - c.reconnectInterval.Store(int64(initialReconnectInterval)) - c.shutdownScheduled.Store(false) - if err := c.sendHello(); err != nil { - log.Printf("Could not send hello request to %s: %s", c, err) - c.scheduleReconnect() - return - } - - if !c.sendPing() { - return - } - - go c.readPump() -} - -func (c *mcuProxyConnection) waitUntilConnected(ctx context.Context) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn != nil { - return nil - } - - waiter := c.connectedNotifier.NewWaiter() - defer c.connectedNotifier.Release(waiter) - - c.mu.Unlock() - defer c.mu.Lock() - return waiter.Wait(ctx) -} - -func (c *mcuProxyConnection) removePublisher(publisher *mcuProxyPublisher) { - c.proxy.removePublisher(publisher) - - c.publishersLock.Lock() - defer c.publishersLock.Unlock() - - if _, found := c.publishers[publisher.proxyId]; found { - delete(c.publishers, publisher.proxyId) - statsPublishersCurrent.WithLabelValues(string(publisher.StreamType())).Dec() - } - delete(c.publisherIds, getStreamId(publisher.id, publisher.StreamType())) - - if len(c.publishers) == 0 && (c.closeScheduled.Load() || c.IsTemporary()) { - go c.closeIfEmpty() - } -} - -func (c *mcuProxyConnection) clearPublishers() { - c.publishersLock.Lock() - defer c.publishersLock.Unlock() - - go func(publishers map[string]*mcuProxyPublisher) { - for _, publisher := range publishers { - publisher.NotifyClosed() - } - }(c.publishers) - // Can't use clear(...) here as the map is processed by the goroutine above. - c.publishers = make(map[string]*mcuProxyPublisher) - clear(c.publisherIds) - - if c.closeScheduled.Load() || c.IsTemporary() { - go c.closeIfEmpty() - } -} - -func (c *mcuProxyConnection) removeSubscriber(subscriber *mcuProxySubscriber) { - c.subscribersLock.Lock() - defer c.subscribersLock.Unlock() - - if _, found := c.subscribers[subscriber.proxyId]; found { - delete(c.subscribers, subscriber.proxyId) - statsSubscribersCurrent.WithLabelValues(string(subscriber.StreamType())).Dec() - } - - if len(c.subscribers) == 0 && (c.closeScheduled.Load() || c.IsTemporary()) { - go c.closeIfEmpty() - } -} - -func (c *mcuProxyConnection) clearSubscribers() { - c.subscribersLock.Lock() - defer c.subscribersLock.Unlock() - - go func(subscribers map[string]*mcuProxySubscriber) { - for _, subscriber := range subscribers { - subscriber.NotifyClosed() - } - }(c.subscribers) - // Can't use clear(...) here as the map is processed by the goroutine above. - c.subscribers = make(map[string]*mcuProxySubscriber) - - if c.closeScheduled.Load() || c.IsTemporary() { - go c.closeIfEmpty() - } -} - -func (c *mcuProxyConnection) clearCallbacks() { - c.mu.Lock() - defer c.mu.Unlock() - - clear(c.callbacks) -} - -func (c *mcuProxyConnection) getCallback(id string) func(*ProxyServerMessage) { - c.mu.Lock() - defer c.mu.Unlock() - - callback, found := c.callbacks[id] - if found { - delete(c.callbacks, id) - } - return callback -} - -func (c *mcuProxyConnection) processMessage(msg *ProxyServerMessage) { - if c.helloMsgId != "" && msg.Id == c.helloMsgId { - c.helloMsgId = "" - switch msg.Type { - case "error": - if msg.Error.Code == "no_such_session" { - log.Printf("Session %s could not be resumed on %s, registering new", c.SessionId(), c) - c.clearPublishers() - c.clearSubscribers() - c.clearCallbacks() - c.sessionId.Store("") - if err := c.sendHello(); err != nil { - log.Printf("Could not send hello request to %s: %s", c, err) - c.scheduleReconnect() - } - return - } - - log.Printf("Hello connection to %s failed with %+v, reconnecting", c, msg.Error) - c.scheduleReconnect() - case "hello": - resumed := c.SessionId() == msg.Hello.SessionId - c.sessionId.Store(msg.Hello.SessionId) - country := "" - if msg.Hello.Server != nil { - if country = msg.Hello.Server.Country; country != "" && !IsValidCountry(country) { - log.Printf("Proxy %s sent invalid country %s in hello response", c, country) - country = "" - } - } - c.country.Store(country) - if resumed { - log.Printf("Resumed session %s on %s", c.SessionId(), c) - } else if country != "" { - log.Printf("Received session %s from %s (in %s)", c.SessionId(), c, country) - } else { - log.Printf("Received session %s from %s", c.SessionId(), c) - } - if c.trackClose.CompareAndSwap(false, true) { - statsConnectedProxyBackendsCurrent.WithLabelValues(c.Country()).Inc() - } - - c.connectedNotifier.Notify() - default: - log.Printf("Received unsupported hello response %+v from %s, reconnecting", msg, c) - c.scheduleReconnect() - } - return - } - - if proxyDebugMessages { - log.Printf("Received from %s: %+v", c, msg) - } - callback := c.getCallback(msg.Id) - if callback != nil { - callback(msg) - return - } - - switch msg.Type { - case "payload": - c.processPayload(msg) - case "event": - c.processEvent(msg) - case "bye": - c.processBye(msg) - default: - log.Printf("Unsupported message received from %s: %+v", c, msg) - } -} - -func (c *mcuProxyConnection) processPayload(msg *ProxyServerMessage) { - payload := msg.Payload - c.publishersLock.RLock() - publisher, found := c.publishers[payload.ClientId] - c.publishersLock.RUnlock() - if found { - publisher.ProcessPayload(payload) - return - } - - c.subscribersLock.RLock() - subscriber, found := c.subscribers[payload.ClientId] - c.subscribersLock.RUnlock() - if found { - subscriber.ProcessPayload(payload) - return - } - - log.Printf("Received payload for unknown client %+v from %s", payload, c) -} - -func (c *mcuProxyConnection) processEvent(msg *ProxyServerMessage) { - event := msg.Event - switch event.Type { - case "backend-disconnected": - log.Printf("Upstream backend at %s got disconnected, reset MCU objects", c) - c.clearPublishers() - c.clearSubscribers() - c.clearCallbacks() - // TODO: Should we also reconnect? - return - case "backend-connected": - log.Printf("Upstream backend at %s is connected", c) - return - case "update-load": - if proxyDebugMessages { - log.Printf("Load of %s now at %d (%s)", c, event.Load, event.Bandwidth) - } - c.load.Store(event.Load) - c.bandwidth.Store(event.Bandwidth) - statsProxyBackendLoadCurrent.WithLabelValues(c.url.String()).Set(float64(event.Load)) - return - case "shutdown-scheduled": - log.Printf("Proxy %s is scheduled to shutdown", c) - c.shutdownScheduled.Store(true) - return - } - - if proxyDebugMessages { - log.Printf("Process event from %s: %+v", c, event) - } - c.publishersLock.RLock() - publisher, found := c.publishers[event.ClientId] - c.publishersLock.RUnlock() - if found { - publisher.ProcessEvent(event) - return - } - - c.subscribersLock.RLock() - subscriber, found := c.subscribers[event.ClientId] - c.subscribersLock.RUnlock() - if found { - subscriber.ProcessEvent(event) - return - } - - log.Printf("Received event for unknown client %+v from %s", event, c) -} - -func (c *mcuProxyConnection) processBye(msg *ProxyServerMessage) { - bye := msg.Bye - switch bye.Reason { - case "session_resumed": - log.Printf("Session %s on %s was resumed by other client, resetting", c.SessionId(), c) - c.sessionId.Store("") - case "session_expired": - log.Printf("Session %s expired on %s, resetting", c.SessionId(), c) - c.sessionId.Store("") - case "session_closed": - log.Printf("Session %s was closed on %s, resetting", c.SessionId(), c) - c.sessionId.Store("") - default: - log.Printf("Received bye with unsupported reason from %s %+v", c, bye) - } -} - -func (c *mcuProxyConnection) sendHello() error { - c.helloMsgId = strconv.FormatInt(c.msgId.Add(1), 10) - msg := &ProxyClientMessage{ - Id: c.helloMsgId, - Type: "hello", - Hello: &HelloProxyClientMessage{ - Version: "1.0", - }, - } - if sessionId := c.SessionId(); sessionId != "" { - msg.Hello.ResumeId = sessionId - } else { - tokenString, err := c.proxy.createToken("") - if err != nil { - return err - } - - msg.Hello.Token = tokenString - } - return c.sendMessage(msg) -} - -func (c *mcuProxyConnection) sendMessage(msg *ProxyClientMessage) error { - c.mu.Lock() - defer c.mu.Unlock() - - return c.sendMessageLocked(msg) -} - -func (c *mcuProxyConnection) sendMessageLocked(msg *ProxyClientMessage) error { - if proxyDebugMessages { - log.Printf("Send message to %s: %+v", c, msg) - } - if c.conn == nil { - return ErrNotConnected - } - c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint - return c.conn.WriteJSON(msg) -} - -func (c *mcuProxyConnection) performAsyncRequest(ctx context.Context, msg *ProxyClientMessage, callback func(err error, response *ProxyServerMessage)) { - msgId := strconv.FormatInt(c.msgId.Add(1), 10) - msg.Id = msgId - - c.mu.Lock() - defer c.mu.Unlock() - c.callbacks[msgId] = func(msg *ProxyServerMessage) { - callback(nil, msg) - } - if err := c.sendMessageLocked(msg); err != nil { - delete(c.callbacks, msgId) - go callback(err, nil) - return - } -} - -func (c *mcuProxyConnection) performSyncRequest(ctx context.Context, msg *ProxyClientMessage) (*ProxyServerMessage, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - - errChan := make(chan error, 1) - responseChan := make(chan *ProxyServerMessage, 1) - c.performAsyncRequest(ctx, msg, func(err error, response *ProxyServerMessage) { - if err != nil { - errChan <- err - } else { - responseChan <- response - } - }) - - select { - case <-ctx.Done(): - return nil, ctx.Err() - case err := <-errChan: - return nil, err - case response := <-responseChan: - return response, nil - } -} - -func (c *mcuProxyConnection) newPublisher(ctx context.Context, listener McuListener, id string, sid string, streamType StreamType, settings NewPublisherSettings) (McuPublisher, error) { - msg := &ProxyClientMessage{ - Type: "command", - Command: &CommandProxyClientMessage{ - Type: "create-publisher", - Sid: sid, - StreamType: streamType, - PublisherSettings: &settings, - // Include for older version of the signaling proxy. - Bitrate: settings.Bitrate, - MediaTypes: settings.MediaTypes, - }, - } - - response, err := c.performSyncRequest(ctx, msg) - if err != nil { - // TODO: Cancel request - return nil, err - } else if response.Type == "error" { - return nil, fmt.Errorf("Error creating %s publisher for %s on %s: %+v", streamType, id, c, response.Error) - } - - proxyId := response.Command.Id - log.Printf("Created %s publisher %s on %s for %s", streamType, proxyId, c, id) - publisher := newMcuProxyPublisher(id, sid, streamType, response.Command.Bitrate, settings, proxyId, c, listener) - c.publishersLock.Lock() - c.publishers[proxyId] = publisher - c.publisherIds[getStreamId(id, streamType)] = proxyId - c.publishersLock.Unlock() - statsPublishersCurrent.WithLabelValues(string(streamType)).Inc() - statsPublishersTotal.WithLabelValues(string(streamType)).Inc() - return publisher, nil -} - -func (c *mcuProxyConnection) newSubscriber(ctx context.Context, listener McuListener, publisherId string, publisherSessionId string, streamType StreamType) (McuSubscriber, error) { - msg := &ProxyClientMessage{ - Type: "command", - Command: &CommandProxyClientMessage{ - Type: "create-subscriber", - StreamType: streamType, - PublisherId: publisherId, - }, - } - - response, err := c.performSyncRequest(ctx, msg) - if err != nil { - // TODO: Cancel request - return nil, err - } else if response.Type == "error" { - return nil, fmt.Errorf("Error creating %s subscriber for %s on %s: %+v", streamType, publisherSessionId, c, response.Error) - } - - proxyId := response.Command.Id - log.Printf("Created %s subscriber %s on %s for %s", streamType, proxyId, c, publisherSessionId) - subscriber := newMcuProxySubscriber(publisherSessionId, response.Command.Sid, streamType, response.Command.Bitrate, proxyId, c, listener, nil) - c.subscribersLock.Lock() - c.subscribers[proxyId] = subscriber - c.subscribersLock.Unlock() - statsSubscribersCurrent.WithLabelValues(string(streamType)).Inc() - statsSubscribersTotal.WithLabelValues(string(streamType)).Inc() - return subscriber, nil -} - -func (c *mcuProxyConnection) newRemoteSubscriber(ctx context.Context, listener McuListener, publisherId string, publisherSessionId string, streamType StreamType, publisherConn *mcuProxyConnection) (McuSubscriber, error) { - if c == publisherConn { - return c.newSubscriber(ctx, listener, publisherId, publisherSessionId, streamType) - } - - remoteToken, err := c.proxy.createToken(publisherId) - if err != nil { - return nil, err - } - - msg := &ProxyClientMessage{ - Type: "command", - Command: &CommandProxyClientMessage{ - Type: "create-subscriber", - StreamType: streamType, - PublisherId: publisherId, - - RemoteUrl: publisherConn.rawUrl, - RemoteToken: remoteToken, - }, - } - - response, err := c.performSyncRequest(ctx, msg) - if err != nil { - // TODO: Cancel request - return nil, err - } else if response.Type == "error" { - return nil, fmt.Errorf("Error creating remote %s subscriber for %s on %s (forwarded to %s): %+v", streamType, publisherSessionId, c, publisherConn, response.Error) - } - - proxyId := response.Command.Id - log.Printf("Created remote %s subscriber %s on %s for %s (forwarded to %s)", streamType, proxyId, c, publisherSessionId, publisherConn) - subscriber := newMcuProxySubscriber(publisherSessionId, response.Command.Sid, streamType, response.Command.Bitrate, proxyId, c, listener, publisherConn) - c.subscribersLock.Lock() - c.subscribers[proxyId] = subscriber - c.subscribersLock.Unlock() - statsSubscribersCurrent.WithLabelValues(string(streamType)).Inc() - statsSubscribersTotal.WithLabelValues(string(streamType)).Inc() - return subscriber, nil -} - -type mcuProxySettings struct { - mcuCommonSettings -} - -func newMcuProxySettings(config *goconf.ConfigFile) (McuSettings, error) { - settings := &mcuProxySettings{} - if err := settings.load(config); err != nil { - return nil, err - } - - return settings, nil -} - -func (s *mcuProxySettings) load(config *goconf.ConfigFile) error { - if err := s.mcuCommonSettings.load(config); err != nil { - return err - } - - proxyTimeoutSeconds, _ := config.GetInt("mcu", "proxytimeout") - if proxyTimeoutSeconds <= 0 { - proxyTimeoutSeconds = defaultProxyTimeoutSeconds - } - proxyTimeout := time.Duration(proxyTimeoutSeconds) * time.Second - log.Printf("Using a timeout of %s for proxy requests", proxyTimeout) - s.setTimeout(proxyTimeout) - return nil -} - -func (s *mcuProxySettings) Reload(config *goconf.ConfigFile) { - if err := s.load(config); err != nil { - log.Printf("Error reloading proxy settings: %s", err) - } -} - -type mcuProxy struct { - urlType string - tokenId string - tokenKey *rsa.PrivateKey - config ProxyConfig - - dialer *websocket.Dialer - connections []*mcuProxyConnection - connectionsMap map[string][]*mcuProxyConnection - connectionsMu sync.RWMutex - connRequests atomic.Int64 - nextSort atomic.Int64 - - settings McuSettings - - mu sync.RWMutex - publishers map[string]*mcuProxyConnection - - publisherWaiters ChannelWaiters - - continentsMap atomic.Value - - rpcClients *GrpcClients -} - -func NewMcuProxy(config *goconf.ConfigFile, etcdClient *EtcdClient, rpcClients *GrpcClients, dnsMonitor *DnsMonitor) (Mcu, error) { - urlType, _ := config.GetString("mcu", "urltype") - if urlType == "" { - urlType = proxyUrlTypeStatic - } - - tokenId, _ := config.GetString("mcu", "token_id") - if tokenId == "" { - return nil, fmt.Errorf("No token id configured") - } - tokenKeyFilename, _ := config.GetString("mcu", "token_key") - if tokenKeyFilename == "" { - return nil, fmt.Errorf("No token key configured") - } - tokenKeyData, err := os.ReadFile(tokenKeyFilename) - if err != nil { - return nil, fmt.Errorf("Could not read private key from %s: %s", tokenKeyFilename, err) - } - tokenKey, err := jwt.ParseRSAPrivateKeyFromPEM(tokenKeyData) - if err != nil { - return nil, fmt.Errorf("Could not parse private key from %s: %s", tokenKeyFilename, err) - } - - settings, err := newMcuProxySettings((config)) - if err != nil { - return nil, err - } - - mcu := &mcuProxy{ - urlType: urlType, - tokenId: tokenId, - tokenKey: tokenKey, - - dialer: &websocket.Dialer{ - Proxy: http.ProxyFromEnvironment, - HandshakeTimeout: settings.Timeout(), - }, - connectionsMap: make(map[string][]*mcuProxyConnection), - settings: settings, - - publishers: make(map[string]*mcuProxyConnection), - - rpcClients: rpcClients, - } - - if err := mcu.loadContinentsMap(config); err != nil { - return nil, err - } - - skipverify, _ := config.GetBool("mcu", "skipverify") - if skipverify { - log.Println("WARNING: MCU verification is disabled!") - mcu.dialer.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: skipverify, - } - } - - switch urlType { - case proxyUrlTypeStatic: - mcu.config, err = NewProxyConfigStatic(config, mcu, dnsMonitor) - case proxyUrlTypeEtcd: - mcu.config, err = NewProxyConfigEtcd(config, etcdClient, mcu) - default: - err = fmt.Errorf("Unsupported proxy URL type %s", urlType) - } - if err != nil { - return nil, err - } - - return mcu, nil -} - -func (m *mcuProxy) loadContinentsMap(config *goconf.ConfigFile) error { - options, err := GetStringOptions(config, "continent-overrides", false) - if err != nil { - return err - } - - if len(options) == 0 { - m.setContinentsMap(nil) - return nil - } - - continentsMap := make(map[string][]string) - for option, value := range options { - option = strings.ToUpper(strings.TrimSpace(option)) - if !IsValidContinent(option) { - log.Printf("Ignore unknown continent %s", option) - continue - } - - var values []string - for _, v := range strings.Split(value, ",") { - v = strings.ToUpper(strings.TrimSpace(v)) - if !IsValidContinent(v) { - log.Printf("Ignore unknown continent %s for override %s", v, option) - continue - } - values = append(values, v) - } - if len(values) == 0 { - log.Printf("No valid values found for continent override %s, ignoring", option) - continue - } - - continentsMap[option] = values - log.Printf("Mapping users on continent %s to %s", option, values) - } - - m.setContinentsMap(continentsMap) - return nil -} - -func (m *mcuProxy) Start(ctx context.Context) error { - return m.config.Start() -} - -func (m *mcuProxy) Stop() { - m.connectionsMu.RLock() - defer m.connectionsMu.RUnlock() - - ctx, cancel := context.WithTimeout(context.Background(), closeTimeout) - defer cancel() - for _, c := range m.connections { - c.stop(ctx) - } - - m.config.Stop() -} - -func (m *mcuProxy) createToken(subject string) (string, error) { - claims := &TokenClaims{ - jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(time.Now()), - Issuer: m.tokenId, - Subject: subject, - }, - } - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - tokenString, err := token.SignedString(m.tokenKey) - if err != nil { - return "", err - } - - return tokenString, nil -} - -func (m *mcuProxy) hasConnections() bool { - m.connectionsMu.RLock() - defer m.connectionsMu.RUnlock() - for _, conn := range m.connections { - if conn.IsConnected() { - return true - } - } - return false -} - -func (m *mcuProxy) WaitForConnections(ctx context.Context) error { - ticker := time.NewTicker(10 * time.Millisecond) - defer ticker.Stop() - - for !m.hasConnections() { - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - } - } - return nil -} - -func (m *mcuProxy) AddConnection(ignoreErrors bool, url string, ips ...net.IP) error { - m.connectionsMu.Lock() - defer m.connectionsMu.Unlock() - - var conns []*mcuProxyConnection - if len(ips) == 0 { - conn, err := newMcuProxyConnection(m, url, nil) - if err != nil { - if ignoreErrors { - log.Printf("Could not create proxy connection to %s: %s", url, err) - return nil - } - - return err - } - - conns = append(conns, conn) - } else { - for _, ip := range ips { - conn, err := newMcuProxyConnection(m, url, ip) - if err != nil { - if ignoreErrors { - log.Printf("Could not create proxy connection to %s (%s): %s", url, ip, err) - continue - } - - return err - } - - conns = append(conns, conn) - } - } - - for _, conn := range conns { - log.Printf("Adding new connection to %s", conn) - conn.start() - - m.connections = append(m.connections, conn) - if existing, found := m.connectionsMap[url]; found { - m.connectionsMap[url] = append(existing, conn) - } else { - m.connectionsMap[url] = []*mcuProxyConnection{conn} - } - } - - m.nextSort.Store(0) - return nil -} - -func containsIP(ips []net.IP, ip net.IP) bool { - for _, i := range ips { - if i.Equal(ip) { - return true - } - } - - return false -} - -func (m *mcuProxy) iterateConnections(url string, ips []net.IP, f func(conn *mcuProxyConnection)) { - m.connectionsMu.Lock() - defer m.connectionsMu.Unlock() - - conns, found := m.connectionsMap[url] - if !found { - return - } - - var toRemove []*mcuProxyConnection - if len(ips) == 0 { - toRemove = conns - } else { - for _, conn := range conns { - if containsIP(ips, conn.ip) { - toRemove = append(toRemove, conn) - } - } - } - - for _, conn := range toRemove { - f(conn) - } -} - -func (m *mcuProxy) RemoveConnection(url string, ips ...net.IP) { - m.iterateConnections(url, ips, func(conn *mcuProxyConnection) { - log.Printf("Removing connection to %s", conn) - conn.closeIfEmpty() - }) -} - -func (m *mcuProxy) KeepConnection(url string, ips ...net.IP) { - m.iterateConnections(url, ips, func(conn *mcuProxyConnection) { - conn.stopCloseIfEmpty() - conn.clearTemporary() - }) -} - -func (m *mcuProxy) Reload(config *goconf.ConfigFile) { - m.settings.Reload(config) - - if m.settings.Timeout() != m.dialer.HandshakeTimeout { - m.dialer.HandshakeTimeout = m.settings.Timeout() - } - - if err := m.loadContinentsMap(config); err != nil { - log.Printf("Error loading continents map: %s", err) - } - - if err := m.config.Reload(config); err != nil { - log.Printf("could not reload proxy configuration: %s", err) - } -} - -func (m *mcuProxy) removeConnection(c *mcuProxyConnection) { - m.connectionsMu.Lock() - defer m.connectionsMu.Unlock() - - if conns, found := m.connectionsMap[c.rawUrl]; found { - for idx, conn := range conns { - if conn == c { - conns = append(conns[:idx], conns[idx+1:]...) - break - } - } - if len(conns) == 0 { - delete(m.connectionsMap, c.rawUrl) - m.connections = nil - for _, conns := range m.connectionsMap { - m.connections = append(m.connections, conns...) - } - } else { - m.connectionsMap[c.rawUrl] = conns - } - - m.nextSort.Store(0) - } -} - -func (m *mcuProxy) SetOnConnected(f func()) { - // Not supported. -} - -func (m *mcuProxy) SetOnDisconnected(f func()) { - // Not supported. -} - -type mcuProxyStats struct { - Publishers int64 `json:"publishers"` - Clients int64 `json:"clients"` - Details []*mcuProxyConnectionStats `json:"details"` -} - -func (m *mcuProxy) GetStats() interface{} { - result := &mcuProxyStats{} - - m.connectionsMu.RLock() - defer m.connectionsMu.RUnlock() - - for _, conn := range m.connections { - stats := conn.GetStats() - result.Publishers += stats.Publishers - result.Clients += stats.Clients - result.Details = append(result.Details, stats) - } - return result -} - -func (m *mcuProxy) getContinentsMap() map[string][]string { - continentsMap := m.continentsMap.Load() - if continentsMap == nil { - return nil - } - return continentsMap.(map[string][]string) -} - -func (m *mcuProxy) setContinentsMap(continentsMap map[string][]string) { - if continentsMap == nil { - continentsMap = make(map[string][]string) - } - m.continentsMap.Store(continentsMap) -} - -type mcuProxyConnectionsList []*mcuProxyConnection - -func (l mcuProxyConnectionsList) Len() int { - return len(l) -} - -func (l mcuProxyConnectionsList) Less(i, j int) bool { - return l[i].Load() < l[j].Load() -} - -func (l mcuProxyConnectionsList) Swap(i, j int) { - l[i], l[j] = l[j], l[i] -} - -func (l mcuProxyConnectionsList) Sort() { - sort.Sort(l) -} - -func ContinentsOverlap(a, b []string) bool { - if len(a) == 0 || len(b) == 0 { - return false - } - - for _, checkA := range a { - for _, checkB := range b { - if checkA == checkB { - return true - } - } - } - return false -} - -func sortConnectionsForCountry(connections []*mcuProxyConnection, country string, continentMap map[string][]string) []*mcuProxyConnection { - // Move connections in the same country to the start of the list. - sorted := make(mcuProxyConnectionsList, 0, len(connections)) - unprocessed := make(mcuProxyConnectionsList, 0, len(connections)) - for _, conn := range connections { - if country == conn.Country() { - sorted = append(sorted, conn) - } else { - unprocessed = append(unprocessed, conn) - } - } - if continents, found := ContinentMap[country]; found && len(unprocessed) > 1 { - remaining := make(mcuProxyConnectionsList, 0, len(unprocessed)) - // Map continents to other continents (e.g. use Europe for Africa). - for _, continent := range continents { - if toAdd, found := continentMap[continent]; found { - continents = append(continents, toAdd...) - } - } - - // Next up are connections on the same or mapped continent. - for _, conn := range unprocessed { - connCountry := conn.Country() - if IsValidCountry(connCountry) { - connContinents := ContinentMap[connCountry] - if ContinentsOverlap(continents, connContinents) { - sorted = append(sorted, conn) - } else { - remaining = append(remaining, conn) - } - } else { - remaining = append(remaining, conn) - } - } - unprocessed = remaining - } - // Add all other connections by load. - sorted = append(sorted, unprocessed...) - return sorted -} - -func (m *mcuProxy) getSortedConnections(initiator McuInitiator) []*mcuProxyConnection { - m.connectionsMu.RLock() - connections := m.connections - m.connectionsMu.RUnlock() - if len(connections) < 2 { - return connections - } - - // Connections are re-sorted every requests or - // every . - now := time.Now().UnixNano() - if m.connRequests.Add(1)%connectionSortRequests == 0 || m.nextSort.Load() <= now { - m.nextSort.Store(now + int64(connectionSortInterval)) - - sorted := make(mcuProxyConnectionsList, len(connections)) - copy(sorted, connections) - - sorted.Sort() - - m.connectionsMu.Lock() - m.connections = sorted - m.connectionsMu.Unlock() - connections = sorted - } - - if initiator != nil { - if country := initiator.Country(); IsValidCountry(country) { - connections = sortConnectionsForCountry(connections, country, m.getContinentsMap()) - } - } - return connections -} - -func (m *mcuProxy) removePublisher(publisher *mcuProxyPublisher) { - m.mu.Lock() - defer m.mu.Unlock() - - delete(m.publishers, getStreamId(publisher.id, publisher.StreamType())) -} - -func (m *mcuProxy) createPublisher(ctx context.Context, listener McuListener, id string, sid string, streamType StreamType, settings NewPublisherSettings, initiator McuInitiator, connections []*mcuProxyConnection, isAllowed func(c *mcuProxyConnection) bool) McuPublisher { - var maxBitrate int - if streamType == StreamTypeScreen { - maxBitrate = int(m.settings.MaxScreenBitrate()) - } else { - maxBitrate = int(m.settings.MaxStreamBitrate()) - } - - publisherSettings := settings - if publisherSettings.Bitrate <= 0 { - publisherSettings.Bitrate = maxBitrate - } else { - publisherSettings.Bitrate = min(publisherSettings.Bitrate, maxBitrate) - } - - for _, conn := range connections { - if !isAllowed(conn) || conn.IsShutdownScheduled() || conn.IsTemporary() { - continue - } - - subctx, cancel := context.WithTimeout(ctx, m.settings.Timeout()) - defer cancel() - - publisher, err := conn.newPublisher(subctx, listener, id, sid, streamType, publisherSettings) - if err != nil { - log.Printf("Could not create %s publisher for %s on %s: %s", streamType, id, conn, err) - continue - } - - m.mu.Lock() - m.publishers[getStreamId(id, streamType)] = conn - m.mu.Unlock() - m.publisherWaiters.Wakeup() - return publisher - } - - return nil -} - -func (m *mcuProxy) NewPublisher(ctx context.Context, listener McuListener, id string, sid string, streamType StreamType, settings NewPublisherSettings, initiator McuInitiator) (McuPublisher, error) { - connections := m.getSortedConnections(initiator) - publisher := m.createPublisher(ctx, listener, id, sid, streamType, settings, initiator, connections, func(c *mcuProxyConnection) bool { - bw := c.Bandwidth() - return bw == nil || bw.AllowIncoming() - }) - if publisher == nil { - // No proxy has available bandwidth, select one with the lowest currently used bandwidth. - connections2 := make([]*mcuProxyConnection, 0, len(connections)) - for _, c := range connections { - if c.Bandwidth() != nil { - connections2 = append(connections2, c) - } - } - slices.SortFunc(connections2, func(a *mcuProxyConnection, b *mcuProxyConnection) int { - var incoming_a *float64 - if bw := a.Bandwidth(); bw != nil { - incoming_a = bw.Incoming - } - - var incoming_b *float64 - if bw := b.Bandwidth(); bw != nil { - incoming_b = bw.Incoming - } - - if incoming_a == nil && incoming_b == nil { - return 0 - } else if incoming_a == nil && incoming_b != nil { - return -1 - } else if incoming_a != nil && incoming_b == nil { - return -1 - } else if *incoming_a < *incoming_b { - return -1 - } else if *incoming_a > *incoming_b { - return 1 - } - return 0 - }) - publisher = m.createPublisher(ctx, listener, id, sid, streamType, settings, initiator, connections2, func(c *mcuProxyConnection) bool { - return true - }) - } - - if publisher == nil { - statsProxyNobackendAvailableTotal.WithLabelValues(string(streamType)).Inc() - return nil, fmt.Errorf("No MCU connection available") - } - - return publisher, nil -} - -func (m *mcuProxy) getPublisherConnection(publisher string, streamType StreamType) *mcuProxyConnection { - m.mu.RLock() - defer m.mu.RUnlock() - - return m.publishers[getStreamId(publisher, streamType)] -} - -func (m *mcuProxy) waitForPublisherConnection(ctx context.Context, publisher string, streamType StreamType) *mcuProxyConnection { - m.mu.Lock() - defer m.mu.Unlock() - - conn := m.publishers[getStreamId(publisher, streamType)] - if conn != nil { - // Publisher was created while waiting for lock. - return conn - } - - ch := make(chan struct{}, 1) - id := m.publisherWaiters.Add(ch) - defer m.publisherWaiters.Remove(id) - - statsWaitingForPublisherTotal.WithLabelValues(string(streamType)).Inc() - for { - m.mu.Unlock() - select { - case <-ch: - m.mu.Lock() - conn = m.publishers[getStreamId(publisher, streamType)] - if conn != nil { - return conn - } - case <-ctx.Done(): - m.mu.Lock() - return nil - } - } -} - -type proxyPublisherInfo struct { - id string - conn *mcuProxyConnection - err error -} - -func (m *mcuProxy) createSubscriber(ctx context.Context, listener McuListener, id string, publisher string, streamType StreamType, publisherConn *mcuProxyConnection, connections []*mcuProxyConnection, isAllowed func(c *mcuProxyConnection) bool) McuSubscriber { - for _, conn := range connections { - if !isAllowed(conn) || conn.IsShutdownScheduled() || conn.IsTemporary() { - continue - } - - var subscriber McuSubscriber - var err error - if conn == publisherConn { - subscriber, err = conn.newSubscriber(ctx, listener, id, publisher, streamType) - } else { - subscriber, err = conn.newRemoteSubscriber(ctx, listener, id, publisher, streamType, publisherConn) - } - if err != nil { - log.Printf("Could not create subscriber for %s publisher %s on %s: %s", streamType, publisher, conn, err) - continue - } - - return subscriber - } - - return nil -} - -func (m *mcuProxy) NewSubscriber(ctx context.Context, listener McuListener, publisher string, streamType StreamType, initiator McuInitiator) (McuSubscriber, error) { - var publisherInfo *proxyPublisherInfo - if conn := m.getPublisherConnection(publisher, streamType); conn != nil { - // Fast common path: publisher is available locally. - conn.publishersLock.Lock() - id, found := conn.publisherIds[getStreamId(publisher, streamType)] - conn.publishersLock.Unlock() - if !found { - return nil, fmt.Errorf("Unknown publisher %s", publisher) - } - - publisherInfo = &proxyPublisherInfo{ - id: id, - conn: conn, - } - } else { - log.Printf("No %s publisher %s found yet, deferring", streamType, publisher) - ch := make(chan *proxyPublisherInfo, 1) - getctx, cancel := context.WithCancel(ctx) - defer cancel() - - var wg sync.WaitGroup - - // Wait for publisher to be created locally. - wg.Add(1) - go func() { - defer wg.Done() - if conn := m.waitForPublisherConnection(getctx, publisher, streamType); conn != nil { - cancel() // Cancel pending RPC calls. - - conn.publishersLock.Lock() - id, found := conn.publisherIds[getStreamId(publisher, streamType)] - conn.publishersLock.Unlock() - if !found { - ch <- &proxyPublisherInfo{ - err: fmt.Errorf("Unknown id for local %s publisher %s", streamType, publisher), - } - return - } - - ch <- &proxyPublisherInfo{ - id: id, - conn: conn, - } - } - }() - - // Wait for publisher to be created on one of the other servers in the cluster. - if clients := m.rpcClients.GetClients(); len(clients) > 0 { - for _, client := range clients { - wg.Add(1) - go func(client *GrpcClient) { - defer wg.Done() - id, url, ip, err := client.GetPublisherId(getctx, publisher, streamType) - if errors.Is(err, context.Canceled) { - return - } else if err != nil { - log.Printf("Error getting %s publisher id %s from %s: %s", streamType, publisher, client.Target(), err) - return - } else if id == "" { - // Publisher not found on other server - return - } - - cancel() // Cancel pending RPC calls. - log.Printf("Found publisher id %s through %s on proxy %s", id, client.Target(), url) - - m.connectionsMu.RLock() - connections := m.connections - m.connectionsMu.RUnlock() - var publisherConn *mcuProxyConnection - for _, conn := range connections { - if conn.rawUrl != url || !ip.Equal(conn.ip) { - continue - } - - // Simple case, signaling server has a connection to the same endpoint - publisherConn = conn - break - } - - if publisherConn == nil { - publisherConn, err = newMcuProxyConnection(m, url, ip) - if err != nil { - log.Printf("Could not create temporary connection to %s for %s publisher %s: %s", url, streamType, publisher, err) - return - } - - publisherConn.setTemporary() - publisherConn.start() - if err := publisherConn.waitUntilConnected(ctx); err != nil { - log.Printf("Could not establish new connection to %s: %s", publisherConn, err) - publisherConn.closeIfEmpty() - return - } - - m.connectionsMu.Lock() - m.connections = append(m.connections, publisherConn) - conns, found := m.connectionsMap[url] - if found { - conns = append(conns, publisherConn) - } else { - conns = []*mcuProxyConnection{publisherConn} - } - m.connectionsMap[url] = conns - m.connectionsMu.Unlock() - } - - ch <- &proxyPublisherInfo{ - id: id, - conn: publisherConn, - } - }(client) - } - } - - wg.Wait() - select { - case ch <- &proxyPublisherInfo{ - err: fmt.Errorf("No %s publisher %s found", streamType, publisher), - }: - default: - } - - select { - case info := <-ch: - publisherInfo = info - case <-ctx.Done(): - return nil, fmt.Errorf("No %s publisher %s found", streamType, publisher) - } - } - - if publisherInfo.err != nil { - return nil, publisherInfo.err - } - - bw := publisherInfo.conn.Bandwidth() - allowOutgoing := bw == nil || bw.AllowOutgoing() - if !allowOutgoing || !publisherInfo.conn.IsSameCountry(initiator) { - connections := m.getSortedConnections(initiator) - if !allowOutgoing || len(connections) > 0 && !connections[0].IsSameCountry(publisherInfo.conn) { - // Connect to remote publisher through "closer" gateway. - subscriber := m.createSubscriber(ctx, listener, publisherInfo.id, publisher, streamType, publisherInfo.conn, connections, func(c *mcuProxyConnection) bool { - bw := c.Bandwidth() - return bw == nil || bw.AllowOutgoing() - }) - if subscriber == nil { - connections2 := make([]*mcuProxyConnection, 0, len(connections)) - for _, c := range connections { - if c.Bandwidth() != nil { - connections2 = append(connections2, c) - } - } - slices.SortFunc(connections2, func(a *mcuProxyConnection, b *mcuProxyConnection) int { - var outgoing_a *float64 - if bw := a.Bandwidth(); bw != nil { - outgoing_a = bw.Outgoing - } - - var outgoing_b *float64 - if bw := b.Bandwidth(); bw != nil { - outgoing_b = bw.Outgoing - } - - if outgoing_a == nil && outgoing_b == nil { - return 0 - } else if outgoing_a == nil && outgoing_b != nil { - return -1 - } else if outgoing_a != nil && outgoing_b == nil { - return -1 - } else if *outgoing_a < *outgoing_b { - return -1 - } else if *outgoing_a > *outgoing_b { - return 1 - } - return 0 - }) - subscriber = m.createSubscriber(ctx, listener, publisherInfo.id, publisher, streamType, publisherInfo.conn, connections2, func(c *mcuProxyConnection) bool { - return true - }) - } - if subscriber != nil { - return subscriber, nil - } - } - } - - subscriber, err := publisherInfo.conn.newSubscriber(ctx, listener, publisherInfo.id, publisher, streamType) - if err != nil { - if publisherInfo.conn.IsTemporary() { - publisherInfo.conn.closeIfEmpty() - } - log.Printf("Could not create subscriber for %s publisher %s on %s: %s", streamType, publisher, publisherInfo.conn, err) - return nil, err - } - - return subscriber, nil -} diff --git a/mcu_proxy_test.go b/mcu_proxy_test.go deleted file mode 100644 index 73c3260..0000000 --- a/mcu_proxy_test.go +++ /dev/null @@ -1,1808 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2020 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "path" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/dlintw/goconf" - "github.com/gorilla/websocket" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.etcd.io/etcd/server/v3/embed" -) - -func TestMcuProxyStats(t *testing.T) { - collectAndLint(t, proxyMcuStats...) -} - -func newProxyConnectionWithCountry(country string) *mcuProxyConnection { - conn := &mcuProxyConnection{} - conn.country.Store(country) - return conn -} - -func Test_sortConnectionsForCountry(t *testing.T) { - conn_de := newProxyConnectionWithCountry("DE") - conn_at := newProxyConnectionWithCountry("AT") - conn_jp := newProxyConnectionWithCountry("JP") - conn_us := newProxyConnectionWithCountry("US") - - testcases := map[string][][]*mcuProxyConnection{ - // Direct country match - "DE": { - {conn_at, conn_jp, conn_de}, - {conn_de, conn_at, conn_jp}, - }, - // Direct country match - "AT": { - {conn_at, conn_jp, conn_de}, - {conn_at, conn_de, conn_jp}, - }, - // Continent match - "CH": { - {conn_de, conn_jp, conn_at}, - {conn_de, conn_at, conn_jp}, - }, - // Direct country match - "JP": { - {conn_de, conn_jp, conn_at}, - {conn_jp, conn_de, conn_at}, - }, - // Continent match - "CN": { - {conn_de, conn_jp, conn_at}, - {conn_jp, conn_de, conn_at}, - }, - // Continent match - "RU": { - {conn_us, conn_de, conn_jp, conn_at}, - {conn_de, conn_at, conn_us, conn_jp}, - }, - // No match - "AU": { - {conn_us, conn_de, conn_jp, conn_at}, - {conn_us, conn_de, conn_jp, conn_at}, - }, - } - - for country, test := range testcases { - country := country - test := test - t.Run(country, func(t *testing.T) { - sorted := sortConnectionsForCountry(test[0], country, nil) - for idx, conn := range sorted { - if test[1][idx] != conn { - assert.Fail(t, "Index %d for %s: expected %s, got %s", idx, country, test[1][idx].Country(), conn.Country()) - } - } - }) - } -} - -func Test_sortConnectionsForCountryWithOverride(t *testing.T) { - conn_de := newProxyConnectionWithCountry("DE") - conn_at := newProxyConnectionWithCountry("AT") - conn_jp := newProxyConnectionWithCountry("JP") - conn_us := newProxyConnectionWithCountry("US") - - testcases := map[string][][]*mcuProxyConnection{ - // Direct country match - "DE": { - {conn_at, conn_jp, conn_de}, - {conn_de, conn_at, conn_jp}, - }, - // Direct country match - "AT": { - {conn_at, conn_jp, conn_de}, - {conn_at, conn_de, conn_jp}, - }, - // Continent match - "CH": { - {conn_de, conn_jp, conn_at}, - {conn_de, conn_at, conn_jp}, - }, - // Direct country match - "JP": { - {conn_de, conn_jp, conn_at}, - {conn_jp, conn_de, conn_at}, - }, - // Continent match - "CN": { - {conn_de, conn_jp, conn_at}, - {conn_jp, conn_de, conn_at}, - }, - // Continent match - "RU": { - {conn_us, conn_de, conn_jp, conn_at}, - {conn_de, conn_at, conn_us, conn_jp}, - }, - // No match - "AR": { - {conn_us, conn_de, conn_jp, conn_at}, - {conn_us, conn_de, conn_jp, conn_at}, - }, - // No match but override (OC -> AS / NA) - "AU": { - {conn_us, conn_jp}, - {conn_us, conn_jp}, - }, - // No match but override (AF -> EU) - "ZA": { - {conn_de, conn_at}, - {conn_de, conn_at}, - }, - } - - continentMap := map[string][]string{ - // Use European connections for Africa. - "AF": {"EU"}, - // Use Asian and North American connections for Oceania. - "OC": {"AS", "NA"}, - } - for country, test := range testcases { - country := country - test := test - t.Run(country, func(t *testing.T) { - sorted := sortConnectionsForCountry(test[0], country, continentMap) - for idx, conn := range sorted { - if test[1][idx] != conn { - assert.Fail(t, "Index %d for %s: expected %s, got %s", idx, country, test[1][idx].Country(), conn.Country()) - } - } - }) - } -} - -type proxyServerClientHandler func(msg *ProxyClientMessage) (*ProxyServerMessage, error) - -type testProxyServerPublisher struct { - id string -} - -type testProxyServerSubscriber struct { - id string - sid string - pub *testProxyServerPublisher - - remoteUrl string -} - -type testProxyServerClient struct { - t *testing.T - - server *TestProxyServerHandler - ws *websocket.Conn - processMessage proxyServerClientHandler - - mu sync.Mutex - sessionId string -} - -func (c *testProxyServerClient) processHello(msg *ProxyClientMessage) (*ProxyServerMessage, error) { - if msg.Type != "hello" { - return nil, fmt.Errorf("expected hello, got %+v", msg) - } - - response := &ProxyServerMessage{ - Id: msg.Id, - Type: "hello", - Hello: &HelloProxyServerMessage{ - Version: "1.0", - SessionId: c.sessionId, - Server: &WelcomeServerMessage{ - Version: "1.0", - Country: c.server.country, - }, - }, - } - c.processMessage = c.processRegularMessage - return response, nil -} - -func (c *testProxyServerClient) processRegularMessage(msg *ProxyClientMessage) (*ProxyServerMessage, error) { - var handler proxyServerClientHandler - switch msg.Type { - case "command": - handler = c.processCommandMessage - } - - if handler == nil { - response := msg.NewWrappedErrorServerMessage(fmt.Errorf("type \"%s\" is not implemented", msg.Type)) - return response, nil - } - - return handler(msg) -} - -func (c *testProxyServerClient) processCommandMessage(msg *ProxyClientMessage) (*ProxyServerMessage, error) { - var response *ProxyServerMessage - switch msg.Command.Type { - case "create-publisher": - pub := c.server.createPublisher() - - if assert.NotNil(c.t, msg.Command.PublisherSettings) { - if assert.NotEqualValues(c.t, 0, msg.Command.PublisherSettings.Bitrate) { - assert.EqualValues(c.t, msg.Command.Bitrate, msg.Command.PublisherSettings.Bitrate) - } - assert.EqualValues(c.t, msg.Command.MediaTypes, msg.Command.PublisherSettings.MediaTypes) - if strings.Contains(c.t.Name(), "Codecs") { - assert.Equal(c.t, "opus,g722", msg.Command.PublisherSettings.AudioCodec) - assert.Equal(c.t, "vp9,vp8,av1", msg.Command.PublisherSettings.VideoCodec) - } else { - assert.Empty(c.t, msg.Command.PublisherSettings.AudioCodec) - assert.Empty(c.t, msg.Command.PublisherSettings.VideoCodec) - } - } - - response = &ProxyServerMessage{ - Id: msg.Id, - Type: "command", - Command: &CommandProxyServerMessage{ - Id: pub.id, - Bitrate: msg.Command.Bitrate, - }, - } - c.server.updateLoad(1) - case "delete-publisher": - if pub, found := c.server.deletePublisher(msg.Command.ClientId); !found { - response = msg.NewWrappedErrorServerMessage(fmt.Errorf("publisher %s not found", msg.Command.ClientId)) - } else { - response = &ProxyServerMessage{ - Id: msg.Id, - Type: "command", - Command: &CommandProxyServerMessage{ - Id: pub.id, - }, - } - c.server.updateLoad(-1) - } - case "create-subscriber": - var pub *testProxyServerPublisher - if msg.Command.RemoteUrl != "" { - for _, server := range c.server.servers { - if server.URL != msg.Command.RemoteUrl { - continue - } - - pub = server.getPublisher(msg.Command.PublisherId) - break - } - } else { - pub = c.server.getPublisher(msg.Command.PublisherId) - } - - if pub == nil { - response = msg.NewWrappedErrorServerMessage(fmt.Errorf("publisher %s not found", msg.Command.PublisherId)) - } else { - sub := c.server.createSubscriber(pub) - response = &ProxyServerMessage{ - Id: msg.Id, - Type: "command", - Command: &CommandProxyServerMessage{ - Id: sub.id, - Sid: sub.sid, - }, - } - c.server.updateLoad(1) - } - case "delete-subscriber": - if sub, found := c.server.deleteSubscriber(msg.Command.ClientId); !found { - response = msg.NewWrappedErrorServerMessage(fmt.Errorf("subscriber %s not found", msg.Command.ClientId)) - } else { - if msg.Command.RemoteUrl != sub.remoteUrl { - response = msg.NewWrappedErrorServerMessage(fmt.Errorf("remote subscriber %s not found", msg.Command.ClientId)) - return response, nil - } - - response = &ProxyServerMessage{ - Id: msg.Id, - Type: "command", - Command: &CommandProxyServerMessage{ - Id: sub.id, - }, - } - c.server.updateLoad(-1) - } - } - if response == nil { - response = msg.NewWrappedErrorServerMessage(fmt.Errorf("command \"%s\" is not implemented", msg.Command.Type)) - } - - return response, nil -} - -func (c *testProxyServerClient) close() { - c.mu.Lock() - defer c.mu.Unlock() - - c.ws.Close() - c.ws = nil -} - -func (c *testProxyServerClient) handleSendMessageError(fmt string, msg *ProxyServerMessage, err error) { - c.t.Helper() - - if !errors.Is(err, websocket.ErrCloseSent) || msg.Type != "event" || msg.Event.Type != "update-load" { - assert.Fail(c.t, fmt, msg, err) - } -} - -func (c *testProxyServerClient) sendMessage(msg *ProxyServerMessage) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.ws == nil { - return - } - - data, err := json.Marshal(msg) - if err != nil { - c.handleSendMessageError("error marshalling %+v: %s", msg, err) - return - } - - w, err := c.ws.NextWriter(websocket.TextMessage) - if err != nil { - c.handleSendMessageError("error creating writer for %+v: %s", msg, err) - return - } - - if _, err := w.Write(data); err != nil { - c.handleSendMessageError("error sending %+v: %s", msg, err) - return - } - - if err := w.Close(); err != nil { - c.handleSendMessageError("error during close of sending %+v: %s", msg, err) - } -} - -func (c *testProxyServerClient) run() { - defer func() { - c.mu.Lock() - defer c.mu.Unlock() - - c.server.removeClient(c) - c.ws = nil - }() - c.processMessage = c.processHello - assert := assert.New(c.t) - for { - c.mu.Lock() - ws := c.ws - c.mu.Unlock() - if ws == nil { - break - } - - msgType, reader, err := ws.NextReader() - if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure) { - assert.NoError(err) - } - return - } - - body, err := io.ReadAll(reader) - if !assert.NoError(err) { - continue - } - - if !assert.Equal(websocket.TextMessage, msgType, "unexpected message type for %s", string(body)) { - continue - } - - var msg ProxyClientMessage - if err := json.Unmarshal(body, &msg); !assert.NoError(err, "could not decode message %s", string(body)) { - continue - } - - if err := msg.CheckValid(); !assert.NoError(err, "invalid message %s", string(body)) { - continue - } - - response, err := c.processMessage(&msg) - if !assert.NoError(err) { - continue - } - - c.sendMessage(response) - if response.Type == "hello" { - c.server.sendLoad(c) - } - } -} - -type TestProxyServerHandler struct { - t *testing.T - - URL string - server *httptest.Server - servers []*TestProxyServerHandler - upgrader *websocket.Upgrader - country string - - mu sync.Mutex - load atomic.Int64 - incoming atomic.Pointer[float64] - outgoing atomic.Pointer[float64] - clients map[string]*testProxyServerClient - publishers map[string]*testProxyServerPublisher - subscribers map[string]*testProxyServerSubscriber -} - -func (h *TestProxyServerHandler) createPublisher() *testProxyServerPublisher { - h.mu.Lock() - defer h.mu.Unlock() - pub := &testProxyServerPublisher{ - id: newRandomString(32), - } - - for { - if _, found := h.publishers[pub.id]; !found { - break - } - - pub.id = newRandomString(32) - } - h.publishers[pub.id] = pub - return pub -} - -func (h *TestProxyServerHandler) getPublisher(id string) *testProxyServerPublisher { - h.mu.Lock() - defer h.mu.Unlock() - - return h.publishers[id] -} - -func (h *TestProxyServerHandler) deletePublisher(id string) (*testProxyServerPublisher, bool) { - h.mu.Lock() - defer h.mu.Unlock() - - pub, found := h.publishers[id] - if !found { - return nil, false - } - - delete(h.publishers, id) - return pub, true -} - -func (h *TestProxyServerHandler) createSubscriber(pub *testProxyServerPublisher) *testProxyServerSubscriber { - h.mu.Lock() - defer h.mu.Unlock() - - sub := &testProxyServerSubscriber{ - id: newRandomString(32), - sid: newRandomString(8), - pub: pub, - } - - for { - if _, found := h.subscribers[sub.id]; !found { - break - } - - sub.id = newRandomString(32) - } - h.subscribers[sub.id] = sub - return sub -} - -func (h *TestProxyServerHandler) deleteSubscriber(id string) (*testProxyServerSubscriber, bool) { - h.mu.Lock() - defer h.mu.Unlock() - - sub, found := h.subscribers[id] - if !found { - return nil, false - } - - delete(h.subscribers, id) - return sub, true -} - -func (h *TestProxyServerHandler) UpdateBandwidth(incoming float64, outgoing float64) { - h.incoming.Store(&incoming) - h.outgoing.Store(&outgoing) - - h.mu.Lock() - defer h.mu.Unlock() - - msg := h.getLoadMessage(h.load.Load()) - for _, c := range h.clients { - c.sendMessage(msg) - } -} - -func (h *TestProxyServerHandler) Clear(incoming bool, outgoing bool) { - if incoming { - h.incoming.Store(nil) - } - if outgoing { - h.outgoing.Store(nil) - } - - h.mu.Lock() - defer h.mu.Unlock() - - msg := h.getLoadMessage(h.load.Load()) - for _, c := range h.clients { - c.sendMessage(msg) - } -} - -func (h *TestProxyServerHandler) getLoadMessage(load int64) *ProxyServerMessage { - msg := &ProxyServerMessage{ - Type: "event", - Event: &EventProxyServerMessage{ - Type: "update-load", - Load: load, - }, - } - - incoming := h.incoming.Load() - outgoing := h.outgoing.Load() - if incoming != nil || outgoing != nil { - msg.Event.Bandwidth = &EventProxyServerBandwidth{ - Incoming: incoming, - Outgoing: outgoing, - } - } - return msg -} - -func (h *TestProxyServerHandler) updateLoad(delta int64) { - if delta == 0 { - return - } - - load := h.load.Add(delta) - - h.mu.Lock() - defer h.mu.Unlock() - - msg := h.getLoadMessage(load) - for _, c := range h.clients { - go c.sendMessage(msg) - } -} - -func (h *TestProxyServerHandler) sendLoad(c *testProxyServerClient) { - msg := h.getLoadMessage(h.load.Load()) - c.sendMessage(msg) -} - -func (h *TestProxyServerHandler) removeClient(client *testProxyServerClient) { - h.mu.Lock() - defer h.mu.Unlock() - delete(h.clients, client.sessionId) -} - -func (h *TestProxyServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ws, err := h.upgrader.Upgrade(w, r, nil) - if !assert.NoError(h.t, err) { - return - } - - client := &testProxyServerClient{ - t: h.t, - server: h, - ws: ws, - sessionId: newRandomString(32), - } - - h.mu.Lock() - h.clients[client.sessionId] = client - h.mu.Unlock() - - go client.run() -} - -func NewProxyServerForTest(t *testing.T, country string) *TestProxyServerHandler { - t.Helper() - - upgrader := websocket.Upgrader{} - proxyHandler := &TestProxyServerHandler{ - t: t, - upgrader: &upgrader, - country: country, - clients: make(map[string]*testProxyServerClient), - publishers: make(map[string]*testProxyServerPublisher), - subscribers: make(map[string]*testProxyServerSubscriber), - } - server := httptest.NewServer(proxyHandler) - proxyHandler.server = server - proxyHandler.URL = server.URL - t.Cleanup(func() { - server.Close() - proxyHandler.mu.Lock() - defer proxyHandler.mu.Unlock() - for _, c := range proxyHandler.clients { - c.close() - } - }) - - return proxyHandler -} - -type proxyTestOptions struct { - etcd *embed.Etcd - servers []*TestProxyServerHandler -} - -func newMcuProxyForTestWithOptions(t *testing.T, options proxyTestOptions) *mcuProxy { - t.Helper() - require := require.New(t) - if options.etcd == nil { - options.etcd = NewEtcdForTest(t) - } - grpcClients, dnsMonitor := NewGrpcClientsWithEtcdForTest(t, options.etcd) - - tokenKey, err := rsa.GenerateKey(rand.Reader, 1024) - require.NoError(err) - dir := t.TempDir() - privkeyFile := path.Join(dir, "privkey.pem") - pubkeyFile := path.Join(dir, "pubkey.pem") - WritePrivateKey(tokenKey, privkeyFile) // nolint - WritePublicKey(&tokenKey.PublicKey, pubkeyFile) // nolint - - cfg := goconf.NewConfigFile() - cfg.AddOption("mcu", "urltype", "static") - cfg.AddOption("mcu", "proxytimeout", fmt.Sprintf("%d", int(testTimeout.Seconds()))) - var urls []string - waitingMap := make(map[string]bool) - if len(options.servers) == 0 { - options.servers = []*TestProxyServerHandler{ - NewProxyServerForTest(t, "DE"), - } - } - for _, s := range options.servers { - s.servers = options.servers - urls = append(urls, s.URL) - waitingMap[s.URL] = true - } - cfg.AddOption("mcu", "url", strings.Join(urls, " ")) - cfg.AddOption("mcu", "token_id", "test-token") - cfg.AddOption("mcu", "token_key", privkeyFile) - - etcdConfig := goconf.NewConfigFile() - etcdConfig.AddOption("etcd", "endpoints", options.etcd.Config().ListenClientUrls[0].String()) - etcdConfig.AddOption("etcd", "loglevel", "error") - - etcdClient, err := NewEtcdClient(etcdConfig, "") - require.NoError(err) - t.Cleanup(func() { - assert.NoError(t, etcdClient.Close()) - }) - - mcu, err := NewMcuProxy(cfg, etcdClient, grpcClients, dnsMonitor) - require.NoError(err) - t.Cleanup(func() { - mcu.Stop() - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - require.NoError(mcu.Start(ctx)) - - proxy := mcu.(*mcuProxy) - - require.NoError(proxy.WaitForConnections(ctx)) - - for len(waitingMap) > 0 { - require.NoError(ctx.Err()) - - for u := range waitingMap { - proxy.connectionsMu.RLock() - connections := proxy.connections - proxy.connectionsMu.RUnlock() - for _, c := range connections { - if c.rawUrl == u && c.IsConnected() && c.SessionId() != "" { - delete(waitingMap, u) - break - } - } - } - - time.Sleep(time.Millisecond) - } - - return proxy -} - -func newMcuProxyForTestWithServers(t *testing.T, servers []*TestProxyServerHandler) *mcuProxy { - t.Helper() - - return newMcuProxyForTestWithOptions(t, proxyTestOptions{ - servers: servers, - }) -} - -func newMcuProxyForTest(t *testing.T) *mcuProxy { - t.Helper() - server := NewProxyServerForTest(t, "DE") - - return newMcuProxyForTestWithServers(t, []*TestProxyServerHandler{server}) -} - -func Test_ProxyPublisherSubscriber(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - mcu := newMcuProxyForTest(t) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - - pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubInitiator) - require.NoError(t, err) - - defer pub.Close(context.Background()) - - subListener := &MockMcuListener{ - publicId: "subscriber-public", - } - subInitiator := &MockMcuInitiator{ - country: "DE", - } - sub, err := mcu.NewSubscriber(ctx, subListener, pubId, StreamTypeVideo, subInitiator) - require.NoError(t, err) - - defer sub.Close(context.Background()) -} - -func Test_ProxyPublisherCodecs(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - mcu := newMcuProxyForTest(t) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - - pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - AudioCodec: "opus,g722", - VideoCodec: "vp9,vp8,av1", - }, pubInitiator) - require.NoError(t, err) - - defer pub.Close(context.Background()) -} - -func Test_ProxyWaitForPublisher(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - mcu := newMcuProxyForTest(t) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - - subListener := &MockMcuListener{ - publicId: "subscriber-public", - } - subInitiator := &MockMcuInitiator{ - country: "DE", - } - done := make(chan struct{}) - go func() { - defer close(done) - sub, err := mcu.NewSubscriber(ctx, subListener, pubId, StreamTypeVideo, subInitiator) - if !assert.NoError(t, err) { - return - } - - defer sub.Close(context.Background()) - }() - - // Give subscriber goroutine some time to start - time.Sleep(100 * time.Millisecond) - - pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubInitiator) - require.NoError(t, err) - - select { - case <-done: - case <-ctx.Done(): - assert.NoError(t, ctx.Err()) - } - defer pub.Close(context.Background()) -} - -func Test_ProxyPublisherBandwidth(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - server1 := NewProxyServerForTest(t, "DE") - server2 := NewProxyServerForTest(t, "DE") - mcu := newMcuProxyForTestWithServers(t, []*TestProxyServerHandler{ - server1, - server2, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pub1Id := "the-publisher-1" - pub1Sid := "1234567890" - pub1Listener := &MockMcuListener{ - publicId: pub1Id + "-public", - } - pub1Initiator := &MockMcuInitiator{ - country: "DE", - } - pub1, err := mcu.NewPublisher(ctx, pub1Listener, pub1Id, pub1Sid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pub1Initiator) - require.NoError(t, err) - - defer pub1.Close(context.Background()) - - if pub1.(*mcuProxyPublisher).conn.rawUrl == server1.URL { - server1.UpdateBandwidth(100, 0) - } else { - server2.UpdateBandwidth(100, 0) - } - - // Wait until proxy has been updated - for ctx.Err() == nil { - mcu.connectionsMu.RLock() - connections := mcu.connections - mcu.connectionsMu.RUnlock() - missing := true - for _, c := range connections { - if c.Bandwidth() != nil { - missing = false - break - } - } - if !missing { - break - } - time.Sleep(time.Millisecond) - } - - pub2Id := "the-publisher-2" - pub2id := "1234567890" - pub2Listener := &MockMcuListener{ - publicId: pub2Id + "-public", - } - pub2Initiator := &MockMcuInitiator{ - country: "DE", - } - pub2, err := mcu.NewPublisher(ctx, pub2Listener, pub2Id, pub2id, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pub2Initiator) - require.NoError(t, err) - - defer pub2.Close(context.Background()) - - assert.NotEqual(t, pub1.(*mcuProxyPublisher).conn.rawUrl, pub2.(*mcuProxyPublisher).conn.rawUrl) -} - -func Test_ProxyPublisherBandwidthOverload(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - server1 := NewProxyServerForTest(t, "DE") - server2 := NewProxyServerForTest(t, "DE") - mcu := newMcuProxyForTestWithServers(t, []*TestProxyServerHandler{ - server1, - server2, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pub1Id := "the-publisher-1" - pub1Sid := "1234567890" - pub1Listener := &MockMcuListener{ - publicId: pub1Id + "-public", - } - pub1Initiator := &MockMcuInitiator{ - country: "DE", - } - pub1, err := mcu.NewPublisher(ctx, pub1Listener, pub1Id, pub1Sid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pub1Initiator) - require.NoError(t, err) - - defer pub1.Close(context.Background()) - - // If all servers are bandwidth loaded, select the one with the least usage. - if pub1.(*mcuProxyPublisher).conn.rawUrl == server1.URL { - server1.UpdateBandwidth(100, 0) - server2.UpdateBandwidth(102, 0) - } else { - server1.UpdateBandwidth(102, 0) - server2.UpdateBandwidth(100, 0) - } - - // Wait until proxy has been updated - for ctx.Err() == nil { - mcu.connectionsMu.RLock() - connections := mcu.connections - mcu.connectionsMu.RUnlock() - missing := false - for _, c := range connections { - if c.Bandwidth() == nil { - missing = true - break - } - } - if !missing { - break - } - time.Sleep(time.Millisecond) - } - - pub2Id := "the-publisher-2" - pub2id := "1234567890" - pub2Listener := &MockMcuListener{ - publicId: pub2Id + "-public", - } - pub2Initiator := &MockMcuInitiator{ - country: "DE", - } - pub2, err := mcu.NewPublisher(ctx, pub2Listener, pub2Id, pub2id, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pub2Initiator) - require.NoError(t, err) - - defer pub2.Close(context.Background()) - - assert.Equal(t, pub1.(*mcuProxyPublisher).conn.rawUrl, pub2.(*mcuProxyPublisher).conn.rawUrl) -} - -func Test_ProxyPublisherLoad(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - server1 := NewProxyServerForTest(t, "DE") - server2 := NewProxyServerForTest(t, "DE") - mcu := newMcuProxyForTestWithServers(t, []*TestProxyServerHandler{ - server1, - server2, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pub1Id := "the-publisher-1" - pub1Sid := "1234567890" - pub1Listener := &MockMcuListener{ - publicId: pub1Id + "-public", - } - pub1Initiator := &MockMcuInitiator{ - country: "DE", - } - pub1, err := mcu.NewPublisher(ctx, pub1Listener, pub1Id, pub1Sid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pub1Initiator) - require.NoError(t, err) - - defer pub1.Close(context.Background()) - - // Make sure connections are re-sorted. - mcu.nextSort.Store(0) - time.Sleep(100 * time.Millisecond) - - pub2Id := "the-publisher-2" - pub2id := "1234567890" - pub2Listener := &MockMcuListener{ - publicId: pub2Id + "-public", - } - pub2Initiator := &MockMcuInitiator{ - country: "DE", - } - pub2, err := mcu.NewPublisher(ctx, pub2Listener, pub2Id, pub2id, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pub2Initiator) - require.NoError(t, err) - - defer pub2.Close(context.Background()) - - assert.NotEqual(t, pub1.(*mcuProxyPublisher).conn.rawUrl, pub2.(*mcuProxyPublisher).conn.rawUrl) -} - -func Test_ProxyPublisherCountry(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - serverDE := NewProxyServerForTest(t, "DE") - serverUS := NewProxyServerForTest(t, "US") - mcu := newMcuProxyForTestWithServers(t, []*TestProxyServerHandler{ - serverDE, - serverUS, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubDEId := "the-publisher-de" - pubDESid := "1234567890" - pubDEListener := &MockMcuListener{ - publicId: pubDEId + "-public", - } - pubDEInitiator := &MockMcuInitiator{ - country: "DE", - } - pubDE, err := mcu.NewPublisher(ctx, pubDEListener, pubDEId, pubDESid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubDEInitiator) - require.NoError(t, err) - - defer pubDE.Close(context.Background()) - - assert.Equal(t, serverDE.URL, pubDE.(*mcuProxyPublisher).conn.rawUrl) - - pubUSId := "the-publisher-us" - pubUSSid := "1234567890" - pubUSListener := &MockMcuListener{ - publicId: pubUSId + "-public", - } - pubUSInitiator := &MockMcuInitiator{ - country: "US", - } - pubUS, err := mcu.NewPublisher(ctx, pubUSListener, pubUSId, pubUSSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubUSInitiator) - require.NoError(t, err) - - defer pubUS.Close(context.Background()) - - assert.Equal(t, serverUS.URL, pubUS.(*mcuProxyPublisher).conn.rawUrl) -} - -func Test_ProxyPublisherContinent(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - serverDE := NewProxyServerForTest(t, "DE") - serverUS := NewProxyServerForTest(t, "US") - mcu := newMcuProxyForTestWithServers(t, []*TestProxyServerHandler{ - serverDE, - serverUS, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubDEId := "the-publisher-de" - pubDESid := "1234567890" - pubDEListener := &MockMcuListener{ - publicId: pubDEId + "-public", - } - pubDEInitiator := &MockMcuInitiator{ - country: "DE", - } - pubDE, err := mcu.NewPublisher(ctx, pubDEListener, pubDEId, pubDESid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubDEInitiator) - require.NoError(t, err) - - defer pubDE.Close(context.Background()) - - assert.Equal(t, serverDE.URL, pubDE.(*mcuProxyPublisher).conn.rawUrl) - - pubFRId := "the-publisher-fr" - pubFRSid := "1234567890" - pubFRListener := &MockMcuListener{ - publicId: pubFRId + "-public", - } - pubFRInitiator := &MockMcuInitiator{ - country: "FR", - } - pubFR, err := mcu.NewPublisher(ctx, pubFRListener, pubFRId, pubFRSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubFRInitiator) - require.NoError(t, err) - - defer pubFR.Close(context.Background()) - - assert.Equal(t, serverDE.URL, pubFR.(*mcuProxyPublisher).conn.rawUrl) -} - -func Test_ProxySubscriberCountry(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - serverDE := NewProxyServerForTest(t, "DE") - serverUS := NewProxyServerForTest(t, "US") - mcu := newMcuProxyForTestWithServers(t, []*TestProxyServerHandler{ - serverDE, - serverUS, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubInitiator) - require.NoError(t, err) - - defer pub.Close(context.Background()) - - assert.Equal(t, serverDE.URL, pub.(*mcuProxyPublisher).conn.rawUrl) - - subListener := &MockMcuListener{ - publicId: "subscriber-public", - } - subInitiator := &MockMcuInitiator{ - country: "US", - } - sub, err := mcu.NewSubscriber(ctx, subListener, pubId, StreamTypeVideo, subInitiator) - require.NoError(t, err) - - defer sub.Close(context.Background()) - - assert.Equal(t, serverUS.URL, sub.(*mcuProxySubscriber).conn.rawUrl) -} - -func Test_ProxySubscriberContinent(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - serverDE := NewProxyServerForTest(t, "DE") - serverUS := NewProxyServerForTest(t, "US") - mcu := newMcuProxyForTestWithServers(t, []*TestProxyServerHandler{ - serverDE, - serverUS, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubInitiator) - require.NoError(t, err) - - defer pub.Close(context.Background()) - - assert.Equal(t, serverDE.URL, pub.(*mcuProxyPublisher).conn.rawUrl) - - subListener := &MockMcuListener{ - publicId: "subscriber-public", - } - subInitiator := &MockMcuInitiator{ - country: "FR", - } - sub, err := mcu.NewSubscriber(ctx, subListener, pubId, StreamTypeVideo, subInitiator) - require.NoError(t, err) - - defer sub.Close(context.Background()) - - assert.Equal(t, serverDE.URL, sub.(*mcuProxySubscriber).conn.rawUrl) -} - -func Test_ProxySubscriberBandwidth(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - serverDE := NewProxyServerForTest(t, "DE") - serverUS := NewProxyServerForTest(t, "US") - mcu := newMcuProxyForTestWithServers(t, []*TestProxyServerHandler{ - serverDE, - serverUS, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubInitiator) - require.NoError(t, err) - - defer pub.Close(context.Background()) - - assert.Equal(t, serverDE.URL, pub.(*mcuProxyPublisher).conn.rawUrl) - - serverDE.UpdateBandwidth(0, 100) - - // Wait until proxy has been updated - for ctx.Err() == nil { - mcu.connectionsMu.RLock() - connections := mcu.connections - mcu.connectionsMu.RUnlock() - missing := true - for _, c := range connections { - if c.Bandwidth() != nil { - missing = false - break - } - } - if !missing { - break - } - time.Sleep(time.Millisecond) - } - - subListener := &MockMcuListener{ - publicId: "subscriber-public", - } - subInitiator := &MockMcuInitiator{ - country: "US", - } - sub, err := mcu.NewSubscriber(ctx, subListener, pubId, StreamTypeVideo, subInitiator) - require.NoError(t, err) - - defer sub.Close(context.Background()) - - assert.Equal(t, serverUS.URL, sub.(*mcuProxySubscriber).conn.rawUrl) -} - -func Test_ProxySubscriberBandwidthOverload(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - serverDE := NewProxyServerForTest(t, "DE") - serverUS := NewProxyServerForTest(t, "US") - mcu := newMcuProxyForTestWithServers(t, []*TestProxyServerHandler{ - serverDE, - serverUS, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubInitiator) - require.NoError(t, err) - - defer pub.Close(context.Background()) - - assert.Equal(t, serverDE.URL, pub.(*mcuProxyPublisher).conn.rawUrl) - - serverDE.UpdateBandwidth(0, 100) - serverUS.UpdateBandwidth(0, 102) - - // Wait until proxy has been updated - for ctx.Err() == nil { - mcu.connectionsMu.RLock() - connections := mcu.connections - mcu.connectionsMu.RUnlock() - missing := false - for _, c := range connections { - if c.Bandwidth() == nil { - missing = true - break - } - } - if !missing { - break - } - time.Sleep(time.Millisecond) - } - - subListener := &MockMcuListener{ - publicId: "subscriber-public", - } - subInitiator := &MockMcuInitiator{ - country: "US", - } - sub, err := mcu.NewSubscriber(ctx, subListener, pubId, StreamTypeVideo, subInitiator) - require.NoError(t, err) - - defer sub.Close(context.Background()) - - assert.Equal(t, serverDE.URL, sub.(*mcuProxySubscriber).conn.rawUrl) -} - -type mockGrpcServerHub struct { - sessionsLock sync.Mutex - sessionByPublicId map[string]Session -} - -func (h *mockGrpcServerHub) addSession(session *ClientSession) { - h.sessionsLock.Lock() - defer h.sessionsLock.Unlock() - if h.sessionByPublicId == nil { - h.sessionByPublicId = make(map[string]Session) - } - h.sessionByPublicId[session.PublicId()] = session -} - -func (h *mockGrpcServerHub) removeSession(session *ClientSession) { - h.sessionsLock.Lock() - defer h.sessionsLock.Unlock() - delete(h.sessionByPublicId, session.PublicId()) -} - -func (h *mockGrpcServerHub) GetSessionByResumeId(resumeId string) Session { - return nil -} - -func (h *mockGrpcServerHub) GetSessionByPublicId(sessionId string) Session { - h.sessionsLock.Lock() - defer h.sessionsLock.Unlock() - return h.sessionByPublicId[sessionId] -} - -func (h *mockGrpcServerHub) GetSessionIdByRoomSessionId(roomSessionId string) (string, error) { - return "", nil -} - -func (h *mockGrpcServerHub) GetBackend(u *url.URL) *Backend { - return nil -} - -func (h *mockGrpcServerHub) GetRoomForBackend(roomId string, backend *Backend) *Room { - return nil -} - -func Test_ProxyRemotePublisher(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - - etcd := NewEtcdForTest(t) - - grpcServer1, addr1 := NewGrpcServerForTest(t) - grpcServer2, addr2 := NewGrpcServerForTest(t) - - hub1 := &mockGrpcServerHub{} - hub2 := &mockGrpcServerHub{} - grpcServer1.hub = hub1 - grpcServer2.hub = hub2 - - SetEtcdValue(etcd, "/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) - SetEtcdValue(etcd, "/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) - - server1 := NewProxyServerForTest(t, "DE") - server2 := NewProxyServerForTest(t, "DE") - - mcu1 := newMcuProxyForTestWithOptions(t, proxyTestOptions{ - etcd: etcd, - servers: []*TestProxyServerHandler{ - server1, - server2, - }, - }) - mcu2 := newMcuProxyForTestWithOptions(t, proxyTestOptions{ - etcd: etcd, - servers: []*TestProxyServerHandler{ - server1, - server2, - }, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - - session1 := &ClientSession{ - publicId: pubId, - publishers: make(map[StreamType]McuPublisher), - } - hub1.addSession(session1) - defer hub1.removeSession(session1) - - pub, err := mcu1.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubInitiator) - require.NoError(t, err) - - defer pub.Close(context.Background()) - - session1.mu.Lock() - session1.publishers[StreamTypeVideo] = pub - session1.publisherWaiters.Wakeup() - session1.mu.Unlock() - - subListener := &MockMcuListener{ - publicId: "subscriber-public", - } - subInitiator := &MockMcuInitiator{ - country: "DE", - } - sub, err := mcu2.NewSubscriber(ctx, subListener, pubId, StreamTypeVideo, subInitiator) - require.NoError(t, err) - - defer sub.Close(context.Background()) -} - -func Test_ProxyMultipleRemotePublisher(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - - etcd := NewEtcdForTest(t) - - grpcServer1, addr1 := NewGrpcServerForTest(t) - grpcServer2, addr2 := NewGrpcServerForTest(t) - grpcServer3, addr3 := NewGrpcServerForTest(t) - - hub1 := &mockGrpcServerHub{} - hub2 := &mockGrpcServerHub{} - hub3 := &mockGrpcServerHub{} - grpcServer1.hub = hub1 - grpcServer2.hub = hub2 - grpcServer3.hub = hub3 - - SetEtcdValue(etcd, "/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) - SetEtcdValue(etcd, "/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) - SetEtcdValue(etcd, "/grpctargets/three", []byte("{\"address\":\""+addr3+"\"}")) - - server1 := NewProxyServerForTest(t, "DE") - server2 := NewProxyServerForTest(t, "US") - server3 := NewProxyServerForTest(t, "US") - - mcu1 := newMcuProxyForTestWithOptions(t, proxyTestOptions{ - etcd: etcd, - servers: []*TestProxyServerHandler{ - server1, - server2, - server3, - }, - }) - mcu2 := newMcuProxyForTestWithOptions(t, proxyTestOptions{ - etcd: etcd, - servers: []*TestProxyServerHandler{ - server1, - server2, - server3, - }, - }) - mcu3 := newMcuProxyForTestWithOptions(t, proxyTestOptions{ - etcd: etcd, - servers: []*TestProxyServerHandler{ - server1, - server2, - server3, - }, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - - session1 := &ClientSession{ - publicId: pubId, - publishers: make(map[StreamType]McuPublisher), - } - hub1.addSession(session1) - defer hub1.removeSession(session1) - - pub, err := mcu1.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubInitiator) - require.NoError(t, err) - - defer pub.Close(context.Background()) - - session1.mu.Lock() - session1.publishers[StreamTypeVideo] = pub - session1.publisherWaiters.Wakeup() - session1.mu.Unlock() - - sub1Listener := &MockMcuListener{ - publicId: "subscriber-public-1", - } - sub1Initiator := &MockMcuInitiator{ - country: "US", - } - sub1, err := mcu2.NewSubscriber(ctx, sub1Listener, pubId, StreamTypeVideo, sub1Initiator) - require.NoError(t, err) - - defer sub1.Close(context.Background()) - - sub2Listener := &MockMcuListener{ - publicId: "subscriber-public-2", - } - sub2Initiator := &MockMcuInitiator{ - country: "US", - } - sub2, err := mcu3.NewSubscriber(ctx, sub2Listener, pubId, StreamTypeVideo, sub2Initiator) - require.NoError(t, err) - - defer sub2.Close(context.Background()) -} - -func Test_ProxyRemotePublisherWait(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - - etcd := NewEtcdForTest(t) - - grpcServer1, addr1 := NewGrpcServerForTest(t) - grpcServer2, addr2 := NewGrpcServerForTest(t) - - hub1 := &mockGrpcServerHub{} - hub2 := &mockGrpcServerHub{} - grpcServer1.hub = hub1 - grpcServer2.hub = hub2 - - SetEtcdValue(etcd, "/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) - SetEtcdValue(etcd, "/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) - - server1 := NewProxyServerForTest(t, "DE") - server2 := NewProxyServerForTest(t, "DE") - - mcu1 := newMcuProxyForTestWithOptions(t, proxyTestOptions{ - etcd: etcd, - servers: []*TestProxyServerHandler{ - server1, - server2, - }, - }) - mcu2 := newMcuProxyForTestWithOptions(t, proxyTestOptions{ - etcd: etcd, - servers: []*TestProxyServerHandler{ - server1, - server2, - }, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - - session1 := &ClientSession{ - publicId: pubId, - publishers: make(map[StreamType]McuPublisher), - } - hub1.addSession(session1) - defer hub1.removeSession(session1) - - subListener := &MockMcuListener{ - publicId: "subscriber-public", - } - subInitiator := &MockMcuInitiator{ - country: "DE", - } - - done := make(chan struct{}) - go func() { - defer close(done) - sub, err := mcu2.NewSubscriber(ctx, subListener, pubId, StreamTypeVideo, subInitiator) - if !assert.NoError(t, err) { - return - } - - defer sub.Close(context.Background()) - }() - - // Give subscriber goroutine some time to start - time.Sleep(100 * time.Millisecond) - - pub, err := mcu1.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubInitiator) - require.NoError(t, err) - - defer pub.Close(context.Background()) - - session1.mu.Lock() - session1.publishers[StreamTypeVideo] = pub - session1.publisherWaiters.Wakeup() - session1.mu.Unlock() - - select { - case <-done: - case <-ctx.Done(): - assert.NoError(t, ctx.Err()) - } -} - -func Test_ProxyRemotePublisherTemporary(t *testing.T) { - CatchLogForTest(t) - t.Parallel() - - etcd := NewEtcdForTest(t) - - grpcServer1, addr1 := NewGrpcServerForTest(t) - grpcServer2, addr2 := NewGrpcServerForTest(t) - - hub1 := &mockGrpcServerHub{} - hub2 := &mockGrpcServerHub{} - grpcServer1.hub = hub1 - grpcServer2.hub = hub2 - - SetEtcdValue(etcd, "/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) - SetEtcdValue(etcd, "/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) - - server1 := NewProxyServerForTest(t, "DE") - server2 := NewProxyServerForTest(t, "DE") - - mcu1 := newMcuProxyForTestWithOptions(t, proxyTestOptions{ - etcd: etcd, - servers: []*TestProxyServerHandler{ - server1, - }, - }) - mcu2 := newMcuProxyForTestWithOptions(t, proxyTestOptions{ - etcd: etcd, - servers: []*TestProxyServerHandler{ - server2, - }, - }) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - pubId := "the-publisher" - pubSid := "1234567890" - pubListener := &MockMcuListener{ - publicId: pubId + "-public", - } - pubInitiator := &MockMcuInitiator{ - country: "DE", - } - - session1 := &ClientSession{ - publicId: pubId, - publishers: make(map[StreamType]McuPublisher), - } - hub1.addSession(session1) - defer hub1.removeSession(session1) - - pub, err := mcu1.NewPublisher(ctx, pubListener, pubId, pubSid, StreamTypeVideo, NewPublisherSettings{ - MediaTypes: MediaTypeVideo | MediaTypeAudio, - }, pubInitiator) - require.NoError(t, err) - - defer pub.Close(context.Background()) - - session1.mu.Lock() - session1.publishers[StreamTypeVideo] = pub - session1.publisherWaiters.Wakeup() - session1.mu.Unlock() - - mcu2.connectionsMu.RLock() - count := len(mcu2.connections) - mcu2.connectionsMu.RUnlock() - assert.Equal(t, 1, count) - - subListener := &MockMcuListener{ - publicId: "subscriber-public", - } - subInitiator := &MockMcuInitiator{ - country: "DE", - } - sub, err := mcu2.NewSubscriber(ctx, subListener, pubId, StreamTypeVideo, subInitiator) - require.NoError(t, err) - - defer sub.Close(context.Background()) - - assert.Equal(t, server1.URL, sub.(*mcuProxySubscriber).conn.rawUrl) - - // The temporary connection has been added - mcu2.connectionsMu.RLock() - count = len(mcu2.connections) - mcu2.connectionsMu.RUnlock() - assert.Equal(t, 2, count) - - sub.Close(context.Background()) - - // Wait for temporary connection to be removed. -loop: - for { - select { - case <-ctx.Done(): - assert.NoError(t, ctx.Err()) - default: - mcu2.connectionsMu.RLock() - count = len(mcu2.connections) - mcu2.connectionsMu.RUnlock() - if count == 1 { - break loop - } - } - } -} diff --git a/mcu_stats_prometheus.go b/mcu_stats_prometheus.go deleted file mode 100644 index 0d0e9ca..0000000 --- a/mcu_stats_prometheus.go +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2021 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "github.com/prometheus/client_golang/prometheus" -) - -var ( - statsPublishersCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "publishers", - Help: "The current number of publishers", - }, []string{"type"}) - statsPublishersTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "publishers_total", - Help: "The total number of created publishers", - }, []string{"type"}) - statsSubscribersCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "subscribers", - Help: "The current number of subscribers", - }, []string{"type"}) - statsSubscribersTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "subscribers_total", - Help: "The total number of created subscribers", - }, []string{"type"}) - statsWaitingForPublisherTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "nopublisher_total", - Help: "The total number of subscribe requests where no publisher exists", - }, []string{"type"}) - statsMcuMessagesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "messages_total", - Help: "The total number of MCU messages", - }, []string{"type"}) - statsMcuSubscriberStreamTypesCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "subscriber_streams", - Help: "The current number of subscribed media streams", - }, []string{"type"}) - statsMcuPublisherStreamTypesCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "publisher_streams", - Help: "The current number of published media streams", - }, []string{"type"}) - - commonMcuStats = []prometheus.Collector{ - statsPublishersCurrent, - statsPublishersTotal, - statsSubscribersCurrent, - statsSubscribersTotal, - statsWaitingForPublisherTotal, - statsMcuMessagesTotal, - statsMcuSubscriberStreamTypesCurrent, - statsMcuPublisherStreamTypesCurrent, - } - - statsConnectedProxyBackendsCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "backend_connections", - Help: "Current number of connections to signaling proxy backends", - }, []string{"country"}) - statsProxyBackendLoadCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "backend_load", - Help: "Current load of signaling proxy backends", - }, []string{"url"}) - statsProxyNobackendAvailableTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "signaling", - Subsystem: "mcu", - Name: "no_backend_available_total", - Help: "Total number of publishing requests where no backend was available", - }, []string{"type"}) - - proxyMcuStats = []prometheus.Collector{ - statsConnectedProxyBackendsCurrent, - statsProxyBackendLoadCurrent, - statsProxyNobackendAvailableTotal, - } -) - -func RegisterJanusMcuStats() { - registerAll(commonMcuStats...) -} - -func UnregisterJanusMcuStats() { - unregisterAll(commonMcuStats...) -} - -func RegisterProxyMcuStats() { - registerAll(commonMcuStats...) - registerAll(proxyMcuStats...) -} - -func UnregisterProxyMcuStats() { - unregisterAll(commonMcuStats...) - unregisterAll(proxyMcuStats...) -} diff --git a/mcu_test.go b/mcu_test.go deleted file mode 100644 index 1fb6841..0000000 --- a/mcu_test.go +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2019 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "errors" - "fmt" - "log" - "sync" - "sync/atomic" - - "github.com/dlintw/goconf" -) - -const ( - TestMaxBitrateScreen = 12345678 - TestMaxBitrateVideo = 23456789 -) - -type TestMCU struct { - mu sync.Mutex - publishers map[string]*TestMCUPublisher - subscribers map[string]*TestMCUSubscriber -} - -func NewTestMCU() (*TestMCU, error) { - return &TestMCU{ - publishers: make(map[string]*TestMCUPublisher), - subscribers: make(map[string]*TestMCUSubscriber), - }, nil -} - -func (m *TestMCU) Start(ctx context.Context) error { - return nil -} - -func (m *TestMCU) Stop() { -} - -func (m *TestMCU) Reload(config *goconf.ConfigFile) { -} - -func (m *TestMCU) SetOnConnected(f func()) { -} - -func (m *TestMCU) SetOnDisconnected(f func()) { -} - -func (m *TestMCU) GetStats() interface{} { - return nil -} - -func (m *TestMCU) NewPublisher(ctx context.Context, listener McuListener, id string, sid string, streamType StreamType, settings NewPublisherSettings, initiator McuInitiator) (McuPublisher, error) { - var maxBitrate int - if streamType == StreamTypeScreen { - maxBitrate = TestMaxBitrateScreen - } else { - maxBitrate = TestMaxBitrateVideo - } - publisherSettings := settings - bitrate := publisherSettings.Bitrate - if bitrate <= 0 || bitrate > maxBitrate { - publisherSettings.Bitrate = maxBitrate - } - pub := &TestMCUPublisher{ - TestMCUClient: TestMCUClient{ - id: id, - sid: sid, - streamType: streamType, - }, - - settings: publisherSettings, - } - - m.mu.Lock() - defer m.mu.Unlock() - - m.publishers[id] = pub - return pub, nil -} - -func (m *TestMCU) GetPublishers() map[string]*TestMCUPublisher { - m.mu.Lock() - defer m.mu.Unlock() - - result := make(map[string]*TestMCUPublisher, len(m.publishers)) - for id, pub := range m.publishers { - result[id] = pub - } - return result -} - -func (m *TestMCU) GetPublisher(id string) *TestMCUPublisher { - m.mu.Lock() - defer m.mu.Unlock() - - return m.publishers[id] -} - -func (m *TestMCU) NewSubscriber(ctx context.Context, listener McuListener, publisher string, streamType StreamType, initiator McuInitiator) (McuSubscriber, error) { - m.mu.Lock() - defer m.mu.Unlock() - - pub := m.publishers[publisher] - if pub == nil { - return nil, fmt.Errorf("Waiting for publisher not implemented yet") - } - - id := newRandomString(8) - sub := &TestMCUSubscriber{ - TestMCUClient: TestMCUClient{ - id: id, - streamType: streamType, - }, - - publisher: pub, - } - return sub, nil -} - -type TestMCUClient struct { - closed atomic.Bool - - id string - sid string - streamType StreamType -} - -func (c *TestMCUClient) Id() string { - return c.id -} - -func (c *TestMCUClient) Sid() string { - return c.sid -} - -func (c *TestMCUClient) StreamType() StreamType { - return c.streamType -} - -func (c *TestMCUClient) MaxBitrate() int { - return 0 -} - -func (c *TestMCUClient) Close(ctx context.Context) { - if c.closed.CompareAndSwap(false, true) { - log.Printf("Close MCU client %s", c.id) - } -} - -func (c *TestMCUClient) isClosed() bool { - return c.closed.Load() -} - -type TestMCUPublisher struct { - TestMCUClient - - settings NewPublisherSettings - - sdp string -} - -func (p *TestMCUPublisher) HasMedia(mt MediaType) bool { - return (p.settings.MediaTypes & mt) == mt -} - -func (p *TestMCUPublisher) SetMedia(mt MediaType) { - p.settings.MediaTypes = mt -} - -func (p *TestMCUPublisher) SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) { - go func() { - if p.isClosed() { - callback(fmt.Errorf("Already closed"), nil) - return - } - - switch data.Type { - case "offer": - sdp := data.Payload["sdp"] - if sdp, ok := sdp.(string); ok { - p.sdp = sdp - if sdp == MockSdpOfferAudioOnly { - callback(nil, map[string]interface{}{ - "type": "answer", - "sdp": MockSdpAnswerAudioOnly, - }) - return - } else if sdp == MockSdpOfferAudioAndVideo { - callback(nil, map[string]interface{}{ - "type": "answer", - "sdp": MockSdpAnswerAudioAndVideo, - }) - return - } - } - callback(fmt.Errorf("Offer payload %+v is not implemented", data.Payload), nil) - default: - callback(fmt.Errorf("Message type %s is not implemented", data.Type), nil) - } - }() -} - -func (p *TestMCUPublisher) GetStreams(ctx context.Context) ([]PublisherStream, error) { - return nil, errors.New("not implemented") -} - -func (p *TestMCUPublisher) PublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error { - return errors.New("remote publishing not supported") -} - -func (p *TestMCUPublisher) UnpublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error { - return errors.New("remote publishing not supported") -} - -type TestMCUSubscriber struct { - TestMCUClient - - publisher *TestMCUPublisher -} - -func (s *TestMCUSubscriber) Publisher() string { - return s.publisher.id -} - -func (s *TestMCUSubscriber) SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) { - go func() { - if s.isClosed() { - callback(fmt.Errorf("Already closed"), nil) - return - } - - switch data.Type { - case "requestoffer": - fallthrough - case "sendoffer": - sdp := s.publisher.sdp - if sdp == "" { - callback(fmt.Errorf("Publisher not sending (no SDP)"), nil) - return - } - - callback(nil, map[string]interface{}{ - "type": "offer", - "sdp": sdp, - }) - case "answer": - callback(nil, nil) - default: - callback(fmt.Errorf("Message type %s is not implemented", data.Type), nil) - } - }() -} diff --git a/metrics/prometheus.go b/metrics/prometheus.go new file mode 100644 index 0000000..93ad0ba --- /dev/null +++ b/metrics/prometheus.go @@ -0,0 +1,42 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +func RegisterAll(cs ...prometheus.Collector) { + for _, c := range cs { + if err := prometheus.DefaultRegisterer.Register(c); err != nil { + if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { + panic(err) + } + } + } +} + +func UnregisterAll(cs ...prometheus.Collector) { + for _, c := range cs { + prometheus.Unregister(c) + } +} diff --git a/metrics/prometheus_test.go b/metrics/prometheus_test.go new file mode 100644 index 0000000..801ae3f --- /dev/null +++ b/metrics/prometheus_test.go @@ -0,0 +1,67 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package metrics + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" +) + +func TestRegistration(t *testing.T) { + t.Parallel() + + collectors := []prometheus.Collector{ + prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "test", + Name: "value_total", + Help: "Total value.", + }), + prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "test", + Name: "value", + Help: "Current value.", + }, []string{"foo", "bar"}), + } + // Can unregister without previous registration + UnregisterAll(collectors...) + RegisterAll(collectors...) + // Can register multiple times + RegisterAll(collectors...) + UnregisterAll(collectors...) +} + +func TestRegistrationError(t *testing.T) { + t.Parallel() + + defer func() { + value := recover() + if err, ok := value.(error); assert.True(t, ok) { + assert.ErrorContains(t, err, "is not a valid metric name") + } + }() + + RegisterAll(prometheus.NewCounter(prometheus.CounterOpts{})) +} diff --git a/stats_prometheus_test.go b/metrics/test/metrics.go similarity index 58% rename from stats_prometheus_test.go rename to metrics/test/metrics.go index 626777a..f7f126c 100644 --- a/stats_prometheus_test.go +++ b/metrics/test/metrics.go @@ -19,12 +19,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package test import ( - "fmt" - "runtime" - "strings" "testing" "github.com/prometheus/client_golang/prometheus" @@ -32,38 +29,44 @@ import ( "github.com/stretchr/testify/assert" ) -func checkStatsValue(t *testing.T, collector prometheus.Collector, value float64) { +func ResetStatsValue[T prometheus.Gauge](t *testing.T, collector T) { + // Make sure test is not executed with "t.Parallel()" + t.Setenv("PARALLEL_CHECK", "1") + + collector.Set(0) + t.Cleanup(func() { + collector.Set(0) + }) +} + +func AssertCollectorChangeBy(t *testing.T, collector prometheus.Collector, delta float64) { + t.Helper() + + ch := make(chan *prometheus.Desc, 1) + collector.Describe(ch) + desc := <-ch + + before := testutil.ToFloat64(collector) + t.Cleanup(func() { + t.Helper() + + after := testutil.ToFloat64(collector) + assert.InEpsilon(t, delta, after-before, 0.0001, "failed for %s", desc) + }) +} + +func CheckStatsValue(t *testing.T, collector prometheus.Collector, value float64) { + // Make sure test is not executed with "t.Parallel()" + t.Setenv("PARALLEL_CHECK", "1") + ch := make(chan *prometheus.Desc, 1) collector.Describe(ch) desc := <-ch v := testutil.ToFloat64(collector) - if v != value { - assert := assert.New(t) - pc := make([]uintptr, 10) - n := runtime.Callers(2, pc) - if n == 0 { - assert.Fail("Expected value %f for %s, got %f", value, desc, v) - return - } - - pc = pc[:n] - frames := runtime.CallersFrames(pc) - stack := "" - for { - frame, more := frames.Next() - if !strings.Contains(frame.File, "nextcloud-spreed-signaling") { - break - } - stack += fmt.Sprintf("%s:%d\n", frame.File, frame.Line) - if !more { - break - } - } - assert.Fail("Expected value %f for %s, got %f at\n%s", value, desc, v, stack) - } + assert.InDelta(t, value, v, 0.0001, "unexpected value for %s", desc) } -func collectAndLint(t *testing.T, collectors ...prometheus.Collector) { +func CollectAndLint(t *testing.T, collectors ...prometheus.Collector) { assert := assert.New(t) for _, collector := range collectors { problems, err := testutil.CollectAndLint(collector) @@ -72,7 +75,7 @@ func collectAndLint(t *testing.T, collectors ...prometheus.Collector) { } for _, problem := range problems { - assert.Fail("Problem with %s: %s", problem.Metric, problem.Text) + assert.Fail("Problem with metric", "%s: %s", problem.Metric, problem.Text) } } } diff --git a/metrics/test/metrics_test.go b/metrics/test/metrics_test.go new file mode 100644 index 0000000..d6c4a65 --- /dev/null +++ b/metrics/test/metrics_test.go @@ -0,0 +1,61 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" +) + +func TestMetrics(t *testing.T) { // nolint:paralleltest + gauge := prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "metrics_tests", + Name: "value", + Help: "Current value for the metrics tests", + }) + + CollectAndLint(t, gauge) + + CheckStatsValue(t, gauge, 0) + + gauge.Inc() + CheckStatsValue(t, gauge, 1) + + ResetStatsValue(t, gauge) + CheckStatsValue(t, gauge, 0) +} + +func TestAssertCollectorChangeBy(t *testing.T) { + t.Parallel() + + gauge := prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "metrics_tests", + Name: "value", + Help: "Current value for the metrics tests", + }) + + AssertCollectorChangeBy(t, gauge, 1) + gauge.Inc() +} diff --git a/mock_data_test.go b/mock/data.go similarity index 77% rename from mock_data_test.go rename to mock/data.go index f90927a..fa4def2 100644 --- a/mock_data_test.go +++ b/mock/data.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package mock const ( // See https://tools.ietf.org/id/draft-ietf-rtcweb-sdp-08.html#rfc.section.5.2.1 @@ -168,5 +168,62 @@ a=rtcp-fb:99 nack a=rtcp-fb:99 nack pli a=rtcp-fb:99 ccm fir a=extmap:2 urn:ietf:params:rtp-hdrext:sdes:mid +` + + MockSdpOfferAudioOnlyNoFilter = `v=0 +o=- 20518 0 IN IP4 0.0.0.0 +s=- +t=0 0 +a=group:BUNDLE audio-D.ietf-mmusic-sdp-bundle-negotiation +a=ice-options:trickle-D.ietf-mmusic-trickle-ice +m=audio 54609 UDP/TLS/RTP/SAVPF 109 0 8 +c=IN IP4 192.168.0.1 +a=mid:audio +a=msid:ma ta +a=sendrecv +a=rtpmap:109 opus/48000/2 +a=rtpmap:0 PCMU/8000 +a=rtpmap:8 PCMA/8000 +a=maxptime:120 +a=ice-ufrag:074c6550 +a=ice-pwd:a28a397a4c3f31747d1ee3474af08a068 +a=fingerprint:sha-256 19:E2:1C:3B:4B:9F:81:E6:B8:5C:F4:A5:A8:D8:73:04:BB:05:2F:70:9F:04:A9:0E:05:E9:26:33:E8:70:88:A2 +a=setup:actpass +a=tls-id:1 +a=rtcp-mux +a=rtcp:60065 IN IP4 192.168.0.1 +a=rtcp-rsize +a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level +a=extmap:2 urn:ietf:params:rtp-hdrext:sdes:mid +a=candidate:0 1 UDP 2122194687 192.0.2.4 61665 typ host +a=candidate:0 2 UDP 2122194687 192.0.2.4 61667 typ host +a=end-of-candidates +` + MockSdpAnswerAudioOnlyNoFilter = `v=0 +o=- 16833 0 IN IP4 0.0.0.0 +s=- +t=0 0 +a=group:BUNDLE audio +a=ice-options:trickle +m=audio 49203 UDP/TLS/RTP/SAVPF 109 0 8 +c=IN IP4 192.168.0.1 +a=mid:audio +a=msid:ma ta +a=sendrecv +a=rtpmap:109 opus/48000/2 +a=rtpmap:0 PCMU/8000 +a=rtpmap:8 PCMA/8000 +a=maxptime:120 +a=ice-ufrag:05067423 +a=ice-pwd:1747d1ee3474a28a397a4c3f3af08a068 +a=fingerprint:sha-256 6B:8B:F0:65:5F:78:E2:51:3B:AC:6F:F3:3F:46:1B:35:DC:B8:5F:64:1A:24:C2:43:F0:A1:58:D0:A1:2C:19:08 +a=setup:active +a=tls-id:1 +a=rtcp-mux +a=rtcp-rsize +a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level +a=extmap:2 urn:ietf:params:rtp-hdrext:sdes:mid +a=candidate:0 1 UDP 2122194687 198.51.100.7 51556 typ host +a=end-of-candidates ` ) diff --git a/natsclient.go b/nats/client.go similarity index 54% rename from natsclient.go rename to nats/client.go index 3c2f6d1..027d691 100644 --- a/natsclient.go +++ b/nats/client.go @@ -1,6 +1,6 @@ /** * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG + * Copyright (C) 2025 struktur AG * * @author Joachim Bauch * @@ -19,40 +19,47 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package nats import ( "context" "encoding/base64" "encoding/json" - "fmt" - "log" + "errors" "os" "os/signal" "strings" "time" "github.com/nats-io/nats.go" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) const ( initialConnectInterval = time.Second maxConnectInterval = 8 * time.Second - NatsLoopbackUrl = "nats://loopback" + LoopbackUrl = "nats://loopback" + + DefaultURL = nats.DefaultURL ) -type NatsSubscription interface { +var ( + ErrConnectionClosed = nats.ErrConnectionClosed +) + +type Msg = nats.Msg + +type Subscription interface { Unsubscribe() error } -type NatsClient interface { - Close() +type Client interface { + Close(ctx context.Context) error - Subscribe(subject string, ch chan *nats.Msg) (NatsSubscription, error) - Publish(subject string, message interface{}) error - - Decode(msg *nats.Msg, v interface{}) error + Subscribe(subject string, ch chan *Msg) (Subscription, error) + Publish(subject string, message any) error } // The NATS client doesn't work if a subject contains spaces. As the room id @@ -62,79 +69,53 @@ func GetEncodedSubject(prefix string, suffix string) string { return prefix + "." + base64.StdEncoding.EncodeToString([]byte(suffix)) } -type natsClient struct { - conn *nats.Conn -} - -func NewNatsClient(url string) (NatsClient, error) { +func NewClient(ctx context.Context, url string, options ...nats.Option) (Client, error) { + logger := log.LoggerFromContext(ctx) if url == ":loopback:" { - log.Printf("WARNING: events url %s is deprecated, please use %s instead", url, NatsLoopbackUrl) - url = NatsLoopbackUrl + logger.Printf("WARNING: events url %s is deprecated, please use %s instead", url, LoopbackUrl) + url = LoopbackUrl } - if url == NatsLoopbackUrl { - log.Println("Using internal NATS loopback client") - return NewLoopbackNatsClient() + if url == LoopbackUrl { + logger.Println("Using internal NATS loopback client") + return NewLoopbackClient(logger) } - backoff, err := NewExponentialBackoff(initialConnectInterval, maxConnectInterval) + backoff, err := async.NewExponentialBackoff(initialConnectInterval, maxConnectInterval) if err != nil { return nil, err } - client := &natsClient{} + client := &NativeClient{ + logger: logger, + closed: make(chan struct{}), + } - client.conn, err = nats.Connect(url, + options = append([]nats.Option{ nats.ClosedHandler(client.onClosed), nats.DisconnectHandler(client.onDisconnected), - nats.ReconnectHandler(client.onReconnected)) + nats.ReconnectHandler(client.onReconnected), + nats.MaxReconnects(-1), + }, options...) + client.conn, err = nats.Connect(url, options...) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + ctx, stop := signal.NotifyContext(ctx, os.Interrupt) defer stop() // The initial connect must succeed, so we retry in the case of an error. for err != nil { - log.Printf("Could not create connection (%s), will retry in %s", err, backoff.NextWait()) + logger.Printf("Could not create connection (%s), will retry in %s", err, backoff.NextWait()) backoff.Wait(ctx) if ctx.Err() != nil { - return nil, fmt.Errorf("interrupted") + return nil, errors.New("interrupted") } client.conn, err = nats.Connect(url) } - log.Printf("Connection established to %s (%s)", client.conn.ConnectedUrl(), client.conn.ConnectedServerId()) + logger.Printf("Connection established to %s (%s)", removeURLCredentials(client.conn.ConnectedUrl()), client.conn.ConnectedServerId()) return client, nil } -func (c *natsClient) Close() { - c.conn.Close() -} - -func (c *natsClient) onClosed(conn *nats.Conn) { - log.Println("NATS client closed", conn.LastError()) -} - -func (c *natsClient) onDisconnected(conn *nats.Conn) { - log.Println("NATS client disconnected") -} - -func (c *natsClient) onReconnected(conn *nats.Conn) { - log.Printf("NATS client reconnected to %s (%s)", conn.ConnectedUrl(), conn.ConnectedServerId()) -} - -func (c *natsClient) Subscribe(subject string, ch chan *nats.Msg) (NatsSubscription, error) { - return c.conn.ChanSubscribe(subject, ch) -} - -func (c *natsClient) Publish(subject string, message interface{}) error { - data, err := json.Marshal(message) - if err != nil { - return err - } - - return c.conn.Publish(subject, data) -} - -func (c *natsClient) Decode(msg *nats.Msg, vPtr interface{}) (err error) { +func Decode(msg *nats.Msg, vPtr any) (err error) { switch arg := vPtr.(type) { case *string: // If they want a string and it is a JSON string, strip quotes diff --git a/nats/client_test.go b/nats/client_test.go new file mode 100644 index 0000000..06894c5 --- /dev/null +++ b/nats/client_test.go @@ -0,0 +1,159 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package nats + +import ( + "testing" + + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" +) + +func TestGetEncodedSubject(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + encoded := GetEncodedSubject("foo", "this is the subject") + assert.NotContains(encoded, " ") + + encoded = GetEncodedSubject("foo", "this-is-the-subject") + assert.NotContains(encoded, "this-is") +} + +func TestDecodeToString(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + testcases := []struct { + data []byte + expected string + }{ + { + []byte(`""`), + "", + }, + { + []byte(`"foo"`), + "foo", + }, + { + []byte(`{"type":"foo"}`), + `{"type":"foo"}`, + }, + { + []byte(`1234`), + "1234", + }, + } + + for idx, tc := range testcases { + var dest string + if assert.NoError(Decode(&nats.Msg{ + Data: tc.data, + }, &dest), "decoding failed for test %d (%s)", idx, string(tc.data)) { + assert.Equal(tc.expected, dest, "failed for test %s (%s)", idx, string(tc.data)) + } + } +} + +func TestDecodeToByteSlice(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + testcases := []struct { + data []byte + expected []byte + }{ + { + []byte(``), + []byte{}, + }, + { + []byte(`""`), + []byte(`""`), + }, + { + []byte(`"foo"`), + []byte(`"foo"`), + }, + { + []byte(`{"type":"foo"}`), + []byte(`{"type":"foo"}`), + }, + { + []byte(`1234`), + []byte(`1234`), + }, + } + + for idx, tc := range testcases { + var dest []byte + if assert.NoError(Decode(&nats.Msg{ + Data: tc.data, + }, &dest), "decoding failed for test %d (%s)", idx, string(tc.data)) { + assert.Equal(tc.expected, dest, "failed for test %s (%s)", idx, string(tc.data)) + } + } +} + +func TestDecodeRegular(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + type testdata struct { + Type string `json:"type"` + Value any `json:"value"` + } + + testcases := []struct { + data []byte + expected *testdata + }{ + { + []byte(`null`), + nil, + }, + { + []byte(`{"value":"bar","type":"foo"}`), + &testdata{ + Type: "foo", + Value: "bar", + }, + }, + { + []byte(`{"value":123,"type":"foo"}`), + &testdata{ + Type: "foo", + Value: float64(123), + }, + }, + } + + for idx, tc := range testcases { + var dest *testdata + if assert.NoError(Decode(&nats.Msg{ + Data: tc.data, + }, &dest), "decoding failed for test %d (%s)", idx, string(tc.data)) { + assert.Equal(tc.expected, dest, "failed for test %s (%s)", idx, string(tc.data)) + } + } +} diff --git a/natsclient_loopback.go b/nats/loopback.go similarity index 62% rename from natsclient_loopback.go rename to nats/loopback.go index 56b6fb6..a0c83ef 100644 --- a/natsclient_loopback.go +++ b/nats/loopback.go @@ -19,36 +19,57 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package nats import ( "container/list" + "context" "encoding/json" - "log" "strings" "sync" "github.com/nats-io/nats.go" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) -type LoopbackNatsClient struct { - mu sync.Mutex - subscriptions map[string]map[*loopbackNatsSubscription]bool +type LoopbackClient struct { + logger log.Logger - wakeup sync.Cond + mu sync.Mutex + closed chan struct{} + + // +checklocks:mu + subscriptions map[string]map[*loopbackSubscription]bool + + // +checklocks:mu + wakeup sync.Cond + // +checklocks:mu incoming list.List } -func NewLoopbackNatsClient() (NatsClient, error) { - client := &LoopbackNatsClient{ - subscriptions: make(map[string]map[*loopbackNatsSubscription]bool), +func NewLoopbackClient(logger log.Logger) (Client, error) { + client := &LoopbackClient{ + logger: logger, + closed: make(chan struct{}), + + subscriptions: make(map[string]map[*loopbackSubscription]bool), } client.wakeup.L = &client.mu go client.processMessages() return client, nil } -func (c *LoopbackNatsClient) processMessages() { +func (c *LoopbackClient) SubscriptionCount() int { + c.mu.Lock() + defer c.mu.Unlock() + + return len(c.subscriptions) +} + +func (c *LoopbackClient) processMessages() { + defer close(c.closed) + c.mu.Lock() defer c.mu.Unlock() for { @@ -60,18 +81,19 @@ func (c *LoopbackNatsClient) processMessages() { break } - msg := c.incoming.Remove(c.incoming.Front()).(*nats.Msg) + msg := c.incoming.Remove(c.incoming.Front()).(*Msg) c.processMessage(msg) } } -func (c *LoopbackNatsClient) processMessage(msg *nats.Msg) { +// +checklocks:c.mu +func (c *LoopbackClient) processMessage(msg *Msg) { subs, found := c.subscriptions[msg.Subject] if !found { return } - channels := make([]chan *nats.Msg, 0, len(subs)) + channels := make([]chan *Msg, 0, len(subs)) for sub := range subs { channels = append(channels, sub.ch) } @@ -81,12 +103,12 @@ func (c *LoopbackNatsClient) processMessage(msg *nats.Msg) { select { case ch <- msg: default: - log.Printf("Slow consumer %s, dropping message", msg.Subject) + c.logger.Printf("Slow consumer %s, dropping message", msg.Subject) } } } -func (c *LoopbackNatsClient) Close() { +func (c *LoopbackClient) doClose() { c.mu.Lock() defer c.mu.Unlock() @@ -95,19 +117,29 @@ func (c *LoopbackNatsClient) Close() { c.wakeup.Signal() } -type loopbackNatsSubscription struct { - subject string - client *LoopbackNatsClient - - ch chan *nats.Msg +func (c *LoopbackClient) Close(ctx context.Context) error { + c.doClose() + select { + case <-c.closed: + return nil + case <-ctx.Done(): + return ctx.Err() + } } -func (s *loopbackNatsSubscription) Unsubscribe() error { +type loopbackSubscription struct { + subject string + client *LoopbackClient + + ch chan *Msg +} + +func (s *loopbackSubscription) Unsubscribe() error { s.client.unsubscribe(s) return nil } -func (c *LoopbackNatsClient) Subscribe(subject string, ch chan *nats.Msg) (NatsSubscription, error) { +func (c *LoopbackClient) Subscribe(subject string, ch chan *Msg) (Subscription, error) { if strings.HasSuffix(subject, ".") || strings.Contains(subject, " ") { return nil, nats.ErrBadSubject } @@ -118,14 +150,14 @@ func (c *LoopbackNatsClient) Subscribe(subject string, ch chan *nats.Msg) (NatsS return nil, nats.ErrConnectionClosed } - s := &loopbackNatsSubscription{ + s := &loopbackSubscription{ subject: subject, client: c, ch: ch, } subs, found := c.subscriptions[subject] if !found { - subs = make(map[*loopbackNatsSubscription]bool) + subs = make(map[*loopbackSubscription]bool) c.subscriptions[subject] = subs } subs[s] = true @@ -133,7 +165,7 @@ func (c *LoopbackNatsClient) Subscribe(subject string, ch chan *nats.Msg) (NatsS return s, nil } -func (c *LoopbackNatsClient) unsubscribe(s *loopbackNatsSubscription) { +func (c *LoopbackClient) unsubscribe(s *loopbackSubscription) { c.mu.Lock() defer c.mu.Unlock() @@ -145,7 +177,7 @@ func (c *LoopbackNatsClient) unsubscribe(s *loopbackNatsSubscription) { } } -func (c *LoopbackNatsClient) Publish(subject string, message interface{}) error { +func (c *LoopbackClient) Publish(subject string, message any) error { if strings.HasSuffix(subject, ".") || strings.Contains(subject, " ") { return nats.ErrBadSubject } @@ -156,7 +188,7 @@ func (c *LoopbackNatsClient) Publish(subject string, message interface{}) error return nats.ErrConnectionClosed } - msg := &nats.Msg{ + msg := &Msg{ Subject: subject, } var err error @@ -167,7 +199,3 @@ func (c *LoopbackNatsClient) Publish(subject string, message interface{}) error c.wakeup.Signal() return nil } - -func (c *LoopbackNatsClient) Decode(msg *nats.Msg, v interface{}) error { - return json.Unmarshal(msg.Data, v) -} diff --git a/natsclient_loopback_test.go b/nats/loopback_test.go similarity index 51% rename from natsclient_loopback_test.go rename to nats/loopback_test.go index d6cf5de..f839e32 100644 --- a/natsclient_loopback_test.go +++ b/nats/loopback_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package nats import ( "context" @@ -28,66 +28,46 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" ) -func (c *LoopbackNatsClient) waitForSubscriptionsEmpty(ctx context.Context, t *testing.T) { - for { - c.mu.Lock() - count := len(c.subscriptions) - c.mu.Unlock() - if count == 0 { - break - } - - select { - case <-ctx.Done(): - c.mu.Lock() - assert.NoError(t, ctx.Err(), "Error waiting for subscriptions %+v to terminate", c.subscriptions) - c.mu.Unlock() - return - default: - time.Sleep(time.Millisecond) - } - } -} - -func CreateLoopbackNatsClientForTest(t *testing.T) NatsClient { - result, err := NewLoopbackNatsClient() +func CreateLoopbackClientForTest(t *testing.T) Client { + logger := logtest.NewLoggerForTest(t) + result, err := NewLoopbackClient(logger) require.NoError(t, err) t.Cleanup(func() { - result.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + assert.NoError(t, result.Close(ctx)) }) return result } -func TestLoopbackNatsClient_Subscribe(t *testing.T) { - ensureNoGoroutinesLeak(t, func(t *testing.T) { - client := CreateLoopbackNatsClientForTest(t) +func TestLoopbackClient_Subscribe(t *testing.T) { + t.Parallel() - testNatsClient_Subscribe(t, client) - }) + client := CreateLoopbackClientForTest(t) + testClient_Subscribe(t, client) } func TestLoopbackClient_PublishAfterClose(t *testing.T) { - ensureNoGoroutinesLeak(t, func(t *testing.T) { - client := CreateLoopbackNatsClientForTest(t) + t.Parallel() - testNatsClient_PublishAfterClose(t, client) - }) + client := CreateLoopbackClientForTest(t) + test_PublishAfterClose(t, client) } func TestLoopbackClient_SubscribeAfterClose(t *testing.T) { - ensureNoGoroutinesLeak(t, func(t *testing.T) { - client := CreateLoopbackNatsClientForTest(t) + t.Parallel() - testNatsClient_SubscribeAfterClose(t, client) - }) + client := CreateLoopbackClientForTest(t) + testClient_SubscribeAfterClose(t, client) } func TestLoopbackClient_BadSubjects(t *testing.T) { - ensureNoGoroutinesLeak(t, func(t *testing.T) { - client := CreateLoopbackNatsClientForTest(t) + t.Parallel() - testNatsClient_BadSubjects(t, client) - }) + client := CreateLoopbackClientForTest(t) + testClient_BadSubjects(t, client) } diff --git a/nats/native.go b/nats/native.go new file mode 100644 index 0000000..49b78f0 --- /dev/null +++ b/nats/native.go @@ -0,0 +1,114 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package nats + +import ( + "context" + "encoding/json" + "net/url" + + "github.com/nats-io/nats.go" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" +) + +type NativeClient struct { + logger log.Logger + conn *nats.Conn + closed chan struct{} +} + +func (c *NativeClient) URLs() []string { + return c.conn.Servers() +} + +func (c *NativeClient) IsConnected() bool { + return c.conn.IsConnected() +} + +func (c *NativeClient) ConnectedUrl() string { + return c.conn.ConnectedUrl() +} + +func (c *NativeClient) ConnectedServerId() string { + return c.conn.ConnectedServerId() +} + +func (c *NativeClient) ConnectedServerVersion() string { + return c.conn.ConnectedServerVersion() +} + +func (c *NativeClient) ConnectedClusterName() string { + return c.conn.ConnectedClusterName() +} + +func (c *NativeClient) Close(ctx context.Context) error { + c.conn.Close() + select { + case <-c.closed: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *NativeClient) FlushWithContext(ctx context.Context) error { + return c.conn.FlushWithContext(ctx) +} + +func (c *NativeClient) onClosed(conn *nats.Conn) { + if err := conn.LastError(); err != nil { + c.logger.Printf("NATS client closed, last error %s", conn.LastError()) + } else { + c.logger.Println("NATS client closed") + } + close(c.closed) +} + +func (c *NativeClient) onDisconnected(conn *nats.Conn) { + c.logger.Println("NATS client disconnected") +} + +func (c *NativeClient) onReconnected(conn *nats.Conn) { + c.logger.Printf("NATS client reconnected to %s (%s)", conn.ConnectedUrl(), conn.ConnectedServerId()) +} + +func (c *NativeClient) Subscribe(subject string, ch chan *Msg) (Subscription, error) { + return c.conn.ChanSubscribe(subject, ch) +} + +func (c *NativeClient) Publish(subject string, message any) error { + data, err := json.Marshal(message) + if err != nil { + return err + } + + return c.conn.Publish(subject, data) +} + +func removeURLCredentials(u string) string { + if u, err := url.Parse(u); err == nil && u.User != nil { + u.User = url.User("***") + return u.String() + } + return u +} diff --git a/nats/native_test.go b/nats/native_test.go new file mode 100644 index 0000000..6a1eeeb --- /dev/null +++ b/nats/native_test.go @@ -0,0 +1,210 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package nats + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nats-io/nats-server/v2/server" + natsservertest "github.com/nats-io/nats-server/v2/test" + "github.com/nats-io/nats.go" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" +) + +func StartLocalServer(t *testing.T) (*server.Server, int) { + t.Helper() + return StartLocalServerPort(t, server.RANDOM_PORT) +} + +func StartLocalServerPort(t *testing.T, port int) (*server.Server, int) { + t.Helper() + opts := natsservertest.DefaultTestOptions + opts.Port = port + opts.Cluster.Name = "testing" + srv := natsservertest.RunServer(&opts) + t.Cleanup(func() { + srv.Shutdown() + srv.WaitForShutdown() + }) + return srv, opts.Port +} + +func CreateLocalClientForTest(t *testing.T, options ...nats.Option) (*server.Server, int, Client) { + t.Helper() + server, port := StartLocalServer(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + result, err := NewClient(ctx, server.ClientURL(), options...) + require.NoError(t, err) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + assert.NoError(t, result.Close(ctx)) + }) + return server, port, result +} + +func testClient_Subscribe(t *testing.T, client Client) { + require := require.New(t) + assert := assert.New(t) + dest := make(chan *Msg) + sub, err := client.Subscribe("foo", dest) + require.NoError(err) + ch := make(chan struct{}) + + var received atomic.Int32 + maxPublish := int32(20) + ready := make(chan struct{}) + quit := make(chan struct{}) + defer close(quit) + go func() { + close(ready) + for { + select { + case <-dest: + total := received.Add(1) + if total == maxPublish { + if err := sub.Unsubscribe(); !assert.NoError(err) { + return + } + close(ch) + } + case <-quit: + return + } + } + }() + <-ready + for range maxPublish { + assert.NoError(client.Publish("foo", []byte("hello"))) + + // Allow NATS goroutines to process messages. + time.Sleep(10 * time.Millisecond) + } + <-ch + + require.Equal(maxPublish, received.Load(), "Received wrong # of messages") +} + +func TestClient_Subscribe(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { + _, _, client := CreateLocalClientForTest(t) + + testClient_Subscribe(t, client) + }) +} + +func test_PublishAfterClose(t *testing.T, client Client) { + assert.NoError(t, client.Close(t.Context())) + + assert.ErrorIs(t, client.Publish("foo", "bar"), nats.ErrConnectionClosed) +} + +func TestClient_PublishAfterClose(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { + _, _, client := CreateLocalClientForTest(t) + + test_PublishAfterClose(t, client) + }) +} + +func testClient_SubscribeAfterClose(t *testing.T, client Client) { + assert.NoError(t, client.Close(t.Context())) + + ch := make(chan *Msg) + _, err := client.Subscribe("foo", ch) + assert.ErrorIs(t, err, nats.ErrConnectionClosed) +} + +func TestClient_SubscribeAfterClose(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { + _, _, client := CreateLocalClientForTest(t) + + testClient_SubscribeAfterClose(t, client) + }) +} + +func testClient_BadSubjects(t *testing.T, client Client) { + assert := assert.New(t) + subjects := []string{ + "foo bar", + "foo.", + } + + ch := make(chan *Msg) + for _, s := range subjects { + _, err := client.Subscribe(s, ch) + assert.ErrorIs(err, nats.ErrBadSubject, "Expected error for subject %s", s) + } +} + +func TestClient_BadSubjects(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { + _, _, client := CreateLocalClientForTest(t) + + testClient_BadSubjects(t, client) + }) +} + +func TestClient_MaxReconnects(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + reconnectWait := time.Millisecond + server, port, client := CreateLocalClientForTest(t, + nats.ReconnectWait(reconnectWait), + nats.ReconnectJitter(0, 0), + ) + c, ok := client.(*NativeClient) + require.True(ok, "wrong class: %T", client) + require.True(c.conn.IsConnected(), "not connected initially") + assert.Equal(server.ID(), c.conn.ConnectedServerId()) + + server.Shutdown() + server.WaitForShutdown() + + // The NATS client tries to reconnect a maximum of 100 times by default. + time.Sleep(100 * reconnectWait) + for i := 0; i < 1000 && c.conn.IsConnected(); i++ { + time.Sleep(time.Millisecond) + } + require.False(c.conn.IsConnected(), "should be disconnected after server shutdown") + + server, _ = StartLocalServerPort(t, port) + + // Wait for automatic reconnection + for i := 0; i < 1000 && !c.conn.IsConnected(); i++ { + time.Sleep(time.Millisecond) + } + require.True(c.conn.IsConnected(), "not connected after restart") + assert.Equal(server.ID(), c.conn.ConnectedServerId()) + }) +} diff --git a/nats/test/server.go b/nats/test/server.go new file mode 100644 index 0000000..6daff42 --- /dev/null +++ b/nats/test/server.go @@ -0,0 +1,72 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "context" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats-server/v2/test" + "github.com/stretchr/testify/assert" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" +) + +func StartLocalServer(t *testing.T) (*server.Server, int) { + t.Helper() + return StartLocalServerPort(t, server.RANDOM_PORT) +} + +func StartLocalServerPort(t *testing.T, port int) (*server.Server, int) { + t.Helper() + opts := test.DefaultTestOptions + opts.Port = port + opts.Cluster.Name = "testing" + srv := test.RunServer(&opts) + t.Cleanup(func() { + srv.Shutdown() + srv.WaitForShutdown() + }) + return srv, opts.Port +} + +func WaitForSubscriptionsEmpty(ctx context.Context, t *testing.T, client nats.Client) { + t.Helper() + if c, ok := client.(*nats.LoopbackClient); assert.True(t, ok, "expected LoopbackNatsClient, got %T", client) { + for { + remaining := c.SubscriptionCount() + if remaining == 0 { + break + } + + select { + case <-ctx.Done(): + assert.NoError(t, ctx.Err(), "Error waiting for %d subscriptions to terminate", remaining) + return + default: + time.Sleep(time.Millisecond) + } + } + } +} diff --git a/nats/test/server_test.go b/nats/test/server_test.go new file mode 100644 index 0000000..190d2b7 --- /dev/null +++ b/nats/test/server_test.go @@ -0,0 +1,88 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" +) + +func TestLocalServer(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + server, port := StartLocalServer(t) + assert.NotEqual(0, port) + + ctx := log.NewLoggerContext(t.Context(), logtest.NewLoggerForTest(t)) + + client, err := nats.NewClient(ctx, server.ClientURL()) + require.NoError(err) + + assert.NoError(client.Close(context.Background())) +} + +func TestWaitForSubscriptionsEmpty(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + ctx := log.NewLoggerContext(t.Context(), logtest.NewLoggerForTest(t)) + + client, err := nats.NewClient(ctx, nats.LoopbackUrl) + require.NoError(err) + defer func() { + assert.NoError(client.Close(context.Background())) + }() + + ch := make(chan *nats.Msg) + sub, err := client.Subscribe("foo", ch) + require.NoError(err) + + ready := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + + close(ready) + WaitForSubscriptionsEmpty(ctx, t, client) + }() + + <-ready + + require.NoError(sub.Unsubscribe()) + <-done +} diff --git a/natsclient_test.go b/natsclient_test.go deleted file mode 100644 index 430ef6d..0000000 --- a/natsclient_test.go +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2021 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "sync/atomic" - "testing" - "time" - - "github.com/nats-io/nats.go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - natsserver "github.com/nats-io/nats-server/v2/test" -) - -func startLocalNatsServer(t *testing.T) string { - opts := natsserver.DefaultTestOptions - opts.Port = -1 - opts.Cluster.Name = "testing" - srv := natsserver.RunServer(&opts) - t.Cleanup(func() { - srv.Shutdown() - srv.WaitForShutdown() - }) - return srv.ClientURL() -} - -func CreateLocalNatsClientForTest(t *testing.T) NatsClient { - url := startLocalNatsServer(t) - result, err := NewNatsClient(url) - require.NoError(t, err) - t.Cleanup(func() { - result.Close() - }) - return result -} - -func testNatsClient_Subscribe(t *testing.T, client NatsClient) { - require := require.New(t) - assert := assert.New(t) - dest := make(chan *nats.Msg) - sub, err := client.Subscribe("foo", dest) - require.NoError(err) - ch := make(chan struct{}) - - var received atomic.Int32 - maxPublish := int32(20) - ready := make(chan struct{}) - quit := make(chan struct{}) - defer close(quit) - go func() { - close(ready) - for { - select { - case <-dest: - total := received.Add(1) - if total == maxPublish { - if err := sub.Unsubscribe(); !assert.NoError(err) { - return - } - close(ch) - } - case <-quit: - return - } - } - }() - <-ready - for i := int32(0); i < maxPublish; i++ { - assert.NoError(client.Publish("foo", []byte("hello"))) - - // Allow NATS goroutines to process messages. - time.Sleep(10 * time.Millisecond) - } - <-ch - - require.EqualValues(maxPublish, received.Load(), "Received wrong # of messages") -} - -func TestNatsClient_Subscribe(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { - client := CreateLocalNatsClientForTest(t) - - testNatsClient_Subscribe(t, client) - }) -} - -func testNatsClient_PublishAfterClose(t *testing.T, client NatsClient) { - client.Close() - - assert.ErrorIs(t, client.Publish("foo", "bar"), nats.ErrConnectionClosed) -} - -func TestNatsClient_PublishAfterClose(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { - client := CreateLocalNatsClientForTest(t) - - testNatsClient_PublishAfterClose(t, client) - }) -} - -func testNatsClient_SubscribeAfterClose(t *testing.T, client NatsClient) { - client.Close() - - ch := make(chan *nats.Msg) - _, err := client.Subscribe("foo", ch) - assert.ErrorIs(t, err, nats.ErrConnectionClosed) -} - -func TestNatsClient_SubscribeAfterClose(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { - client := CreateLocalNatsClientForTest(t) - - testNatsClient_SubscribeAfterClose(t, client) - }) -} - -func testNatsClient_BadSubjects(t *testing.T, client NatsClient) { - assert := assert.New(t) - subjects := []string{ - "foo bar", - "foo.", - } - - ch := make(chan *nats.Msg) - for _, s := range subjects { - _, err := client.Subscribe(s, ch) - assert.ErrorIs(err, nats.ErrBadSubject, "Expected error for subject %s", s) - } -} - -func TestNatsClient_BadSubjects(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { - client := CreateLocalNatsClientForTest(t) - - testNatsClient_BadSubjects(t, client) - }) -} diff --git a/pool/buffer_pool.go b/pool/buffer_pool.go new file mode 100644 index 0000000..8164bc5 --- /dev/null +++ b/pool/buffer_pool.go @@ -0,0 +1,79 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2024 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package pool + +import ( + "bytes" + "encoding/json" + "io" + "sync" +) + +type BufferPool struct { + buffers sync.Pool + copyBuffers sync.Pool +} + +func (p *BufferPool) Get() *bytes.Buffer { + b := p.buffers.Get() + if b == nil { + return bytes.NewBuffer(nil) + } + + return b.(*bytes.Buffer) +} + +func (p *BufferPool) Put(b *bytes.Buffer) { + if b == nil { + return + } + + b.Reset() + p.buffers.Put(b) +} + +func (p *BufferPool) ReadAll(r io.Reader) (*bytes.Buffer, error) { + buf := p.copyBuffers.Get() + if buf == nil { + buf = make([]byte, 1024) + } + defer p.copyBuffers.Put(buf) + + b := p.Get() + if _, err := io.CopyBuffer(b, r, buf.([]byte)); err != nil { + p.Put(b) + return nil, err + } + + return b, nil +} + +func (p *BufferPool) MarshalAsJSON(v any) (*bytes.Buffer, error) { + b := p.Get() + encoder := json.NewEncoder(b) + if err := encoder.Encode(v); err != nil { + p.Put(b) + return nil, err + } + + return b, nil +} diff --git a/pool/buffer_pool_test.go b/pool/buffer_pool_test.go new file mode 100644 index 0000000..f06b969 --- /dev/null +++ b/pool/buffer_pool_test.go @@ -0,0 +1,141 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package pool + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBufferPool(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + + var pool BufferPool + + buf1 := pool.Get() + assert.NotNil(buf1) + buf2 := pool.Get() + assert.NotSame(buf1, buf2) + + buf1.WriteString("empty string") + pool.Put(buf1) + + buf3 := pool.Get() + assert.Equal(0, buf3.Len()) + + pool.Put(nil) +} + +func TestBufferPoolReadAll(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + s := "Hello world!" + data := bytes.NewBufferString(s) + + var pool BufferPool + + buf1 := pool.Get() + assert.NotNil(buf1) + pool.Put(buf1) + + buf2, err := pool.ReadAll(data) + require.NoError(err) + assert.Equal(s, buf2.String()) +} + +var errTest = errors.New("test error") + +type errorReader struct{} + +func (e errorReader) Read(b []byte) (int, error) { + return 0, errTest +} + +func TestBufferPoolReadAllError(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + + var pool BufferPool + + buf1 := pool.Get() + assert.NotNil(buf1) + pool.Put(buf1) + + r := &errorReader{} + buf2, err := pool.ReadAll(r) + assert.ErrorIs(err, errTest) + assert.Nil(buf2) +} + +func TestBufferPoolMarshalAsJSON(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + var pool BufferPool + buf1 := pool.Get() + assert.NotNil(buf1) + pool.Put(buf1) + + s := "Hello world!" + buf2, err := pool.MarshalAsJSON(s) + require.NoError(err) + + assert.Equal(fmt.Sprintf("\"%s\"\n", s), buf2.String()) +} + +type errorMarshaler struct { + json.Marshaler +} + +func (e errorMarshaler) MarshalJSON() ([]byte, error) { + return nil, errTest +} + +func TestBufferPoolMarshalAsJSONError(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + + var pool BufferPool + buf1 := pool.Get() + assert.NotNil(buf1) + pool.Put(buf1) + + var ob errorMarshaler + buf2, err := pool.MarshalAsJSON(ob) + assert.ErrorIs(err, errTest) + assert.Nil(buf2) +} diff --git a/http_client_pool.go b/pool/http_client_pool.go similarity index 91% rename from http_client_pool.go rename to pool/http_client_pool.go index 65c19dc..55004be 100644 --- a/http_client_pool.go +++ b/pool/http_client_pool.go @@ -19,13 +19,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package pool import ( "context" "crypto/tls" "errors" - "fmt" "net/http" "net/url" "sync" @@ -33,6 +32,10 @@ import ( "github.com/prometheus/client_golang/prometheus" ) +var ( + ErrNotRedirecting = errors.New("not redirecting to different host") +) + func init() { RegisterHttpClientPoolStats() } @@ -60,7 +63,7 @@ func (p *Pool) Put(c *http.Client) { func newPool(host string, constructor func() *http.Client, size int) (*Pool, error) { if size <= 0 { - return nil, fmt.Errorf("can't create empty pool") + return nil, errors.New("can't create empty pool") } p := &Pool{ @@ -79,14 +82,15 @@ type HttpClientPool struct { mu sync.Mutex transport *http.Transport - clients map[string]*Pool + // +checklocks:mu + clients map[string]*Pool - maxConcurrentRequestsPerHost int + maxConcurrentRequestsPerHost int // +checklocksignore: Only written to from constructor. } func NewHttpClientPool(maxConcurrentRequestsPerHost int, skipVerify bool) (*HttpClientPool, error) { if maxConcurrentRequestsPerHost <= 0 { - return nil, fmt.Errorf("can't create empty pool") + return nil, errors.New("can't create empty pool") } tlsconfig := &tls.Config{ diff --git a/http_client_pool_stats_prometheus.go b/pool/http_client_pool_stats_prometheus.go similarity index 91% rename from http_client_pool_stats_prometheus.go rename to pool/http_client_pool_stats_prometheus.go index 72d7b2c..45a5fe6 100644 --- a/http_client_pool_stats_prometheus.go +++ b/pool/http_client_pool_stats_prometheus.go @@ -19,10 +19,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package pool import ( "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" ) var ( @@ -39,5 +41,5 @@ var ( ) func RegisterHttpClientPoolStats() { - registerAll(httpClientPoolStats...) + metrics.RegisterAll(httpClientPoolStats...) } diff --git a/http_client_pool_test.go b/pool/http_client_pool_test.go similarity index 99% rename from http_client_pool_test.go rename to pool/http_client_pool_test.go index dff3866..1433b5f 100644 --- a/http_client_pool_test.go +++ b/pool/http_client_pool_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package pool import ( "context" diff --git a/proxy.conf.in b/proxy.conf.in index 5cbb3c4..ac3482b 100644 --- a/proxy.conf.in +++ b/proxy.conf.in @@ -4,7 +4,9 @@ #listen = 127.0.0.1:9090 [app] -# Set to "true" to install pprof debug handlers. +# Set to "true" to install pprof debug handlers. Access will only be possible +# from IPs allowed through the "allowed_ips" option below. +# # See "https://golang.org/pkg/net/http/pprof/" for further information. #debug = false @@ -82,9 +84,17 @@ url = ws://localhost:8188/ # Default is 2 mbit/sec. #maxscreenbitrate = 2097152 +# List of IP addresses / subnets that are allowed to be used by clients in +# candidates. The allowed list has preference over the blocked list below. +#allowedcandidates = 10.0.0.0/8 + +# List of IP addresses / subnets to filter from candidates received by clients. +#blockedcandidates = 1.2.3.0/24 + [stats] -# Comma-separated list of IP addresses that are allowed to access the stats -# endpoint. Leave empty (or commented) to only allow access from "127.0.0.1". +# Comma-separated list of IP addresses that are allowed to access the debug, +# stats and metrics endpoints. +# Leave empty (or commented) to only allow access from localhost. #allowed_ips = [etcd] diff --git a/api_proxy.go b/proxy/api.go similarity index 54% rename from api_proxy.go rename to proxy/api.go index 23acf35..3663460 100644 --- a/api_proxy.go +++ b/proxy/api.go @@ -19,17 +19,21 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package proxy import ( "encoding/json" + "errors" "fmt" "net/url" "github.com/golang-jwt/jwt/v5" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" ) -type ProxyClientMessage struct { +type ClientMessage struct { json.Marshaler json.Unmarshaler @@ -40,16 +44,16 @@ type ProxyClientMessage struct { Type string `json:"type"` // Filled for type "hello" - Hello *HelloProxyClientMessage `json:"hello,omitempty"` + Hello *HelloClientMessage `json:"hello,omitempty"` - Bye *ByeProxyClientMessage `json:"bye,omitempty"` + Bye *ByeClientMessage `json:"bye,omitempty"` - Command *CommandProxyClientMessage `json:"command,omitempty"` + Command *CommandClientMessage `json:"command,omitempty"` - Payload *PayloadProxyClientMessage `json:"payload,omitempty"` + Payload *PayloadClientMessage `json:"payload,omitempty"` } -func (m *ProxyClientMessage) String() string { +func (m *ClientMessage) String() string { data, err := json.Marshal(m) if err != nil { return fmt.Sprintf("Could not serialize %#v: %s", m, err) @@ -57,13 +61,13 @@ func (m *ProxyClientMessage) String() string { return string(data) } -func (m *ProxyClientMessage) CheckValid() error { +func (m *ClientMessage) CheckValid() error { switch m.Type { case "": - return fmt.Errorf("type missing") + return errors.New("type missing") case "hello": if m.Hello == nil { - return fmt.Errorf("hello missing") + return errors.New("hello missing") } else if err := m.Hello.CheckValid(); err != nil { return err } @@ -76,13 +80,13 @@ func (m *ProxyClientMessage) CheckValid() error { } case "command": if m.Command == nil { - return fmt.Errorf("command missing") + return errors.New("command missing") } else if err := m.Command.CheckValid(); err != nil { return err } case "payload": if m.Payload == nil { - return fmt.Errorf("payload missing") + return errors.New("payload missing") } else if err := m.Payload.CheckValid(); err != nil { return err } @@ -90,20 +94,20 @@ func (m *ProxyClientMessage) CheckValid() error { return nil } -func (m *ProxyClientMessage) NewErrorServerMessage(e *Error) *ProxyServerMessage { - return &ProxyServerMessage{ +func (m *ClientMessage) NewErrorServerMessage(e *api.Error) *ServerMessage { + return &ServerMessage{ Id: m.Id, Type: "error", Error: e, } } -func (m *ProxyClientMessage) NewWrappedErrorServerMessage(e error) *ProxyServerMessage { - return m.NewErrorServerMessage(NewError("internal_error", e.Error())) +func (m *ClientMessage) NewWrappedErrorServerMessage(e error) *ServerMessage { + return m.NewErrorServerMessage(api.NewError("internal_error", e.Error())) } -// ProxyServerMessage is a message that is sent from the server to a client. -type ProxyServerMessage struct { +// ServerMessage is a message that is sent from the server to a client. +type ServerMessage struct { json.Marshaler json.Unmarshaler @@ -111,20 +115,20 @@ type ProxyServerMessage struct { Type string `json:"type"` - Error *Error `json:"error,omitempty"` + Error *api.Error `json:"error,omitempty"` - Hello *HelloProxyServerMessage `json:"hello,omitempty"` + Hello *HelloServerMessage `json:"hello,omitempty"` - Bye *ByeProxyServerMessage `json:"bye,omitempty"` + Bye *ByeServerMessage `json:"bye,omitempty"` - Command *CommandProxyServerMessage `json:"command,omitempty"` + Command *CommandServerMessage `json:"command,omitempty"` - Payload *PayloadProxyServerMessage `json:"payload,omitempty"` + Payload *PayloadServerMessage `json:"payload,omitempty"` - Event *EventProxyServerMessage `json:"event,omitempty"` + Event *EventServerMessage `json:"event,omitempty"` } -func (r *ProxyServerMessage) String() string { +func (r *ServerMessage) String() string { data, err := json.Marshal(r) if err != nil { return fmt.Sprintf("Could not serialize %#v: %s", r, err) @@ -132,7 +136,7 @@ func (r *ProxyServerMessage) String() string { return string(data) } -func (r *ProxyServerMessage) CloseAfterSend(session Session) bool { +func (r *ServerMessage) CloseAfterSend(session api.RoomAware) bool { switch r.Type { case "bye": return true @@ -147,10 +151,10 @@ type TokenClaims struct { jwt.RegisteredClaims } -type HelloProxyClientMessage struct { +type HelloClientMessage struct { Version string `json:"version"` - ResumeId string `json:"resumeid"` + ResumeId api.PublicSessionId `json:"resumeid"` Features []string `json:"features,omitempty"` @@ -158,65 +162,55 @@ type HelloProxyClientMessage struct { Token string `json:"token"` } -func (m *HelloProxyClientMessage) CheckValid() error { - if m.Version != HelloVersionV1 { +func (m *HelloClientMessage) CheckValid() error { + if m.Version != api.HelloVersionV1 { return fmt.Errorf("unsupported hello version: %s", m.Version) } if m.ResumeId == "" { if m.Token == "" { - return fmt.Errorf("token missing") + return errors.New("token missing") } } return nil } -type HelloProxyServerMessage struct { +type HelloServerMessage struct { Version string `json:"version"` - SessionId string `json:"sessionid"` - Server *WelcomeServerMessage `json:"server,omitempty"` + SessionId api.PublicSessionId `json:"sessionid"` + Server *api.WelcomeServerMessage `json:"server,omitempty"` } // Type "bye" -type ByeProxyClientMessage struct { +type ByeClientMessage struct { } -func (m *ByeProxyClientMessage) CheckValid() error { +func (m *ByeClientMessage) CheckValid() error { // No additional validation required. return nil } -type ByeProxyServerMessage struct { +type ByeServerMessage struct { Reason string `json:"reason"` } // Type "command" -type NewPublisherSettings struct { - Bitrate int `json:"bitrate,omitempty"` - MediaTypes MediaType `json:"mediatypes,omitempty"` - - AudioCodec string `json:"audiocodec,omitempty"` - VideoCodec string `json:"videocodec,omitempty"` - VP9Profile string `json:"vp9_profile,omitempty"` - H264Profile string `json:"h264_profile,omitempty"` -} - -type CommandProxyClientMessage struct { +type CommandClientMessage struct { Type string `json:"type"` - Sid string `json:"sid,omitempty"` - StreamType StreamType `json:"streamType,omitempty"` - PublisherId string `json:"publisherId,omitempty"` - ClientId string `json:"clientId,omitempty"` + Sid string `json:"sid,omitempty"` + StreamType sfu.StreamType `json:"streamType,omitempty"` + PublisherId api.PublicSessionId `json:"publisherId,omitempty"` + ClientId string `json:"clientId,omitempty"` // Deprecated: use PublisherSettings instead. - Bitrate int `json:"bitrate,omitempty"` + Bitrate api.Bandwidth `json:"bitrate,omitempty"` // Deprecated: use PublisherSettings instead. - MediaTypes MediaType `json:"mediatypes,omitempty"` + MediaTypes sfu.MediaType `json:"mediatypes,omitempty"` - PublisherSettings *NewPublisherSettings `json:"publisherSettings,omitempty"` + PublisherSettings *sfu.NewPublisherSettings `json:"publisherSettings,omitempty"` RemoteUrl string `json:"remoteUrl,omitempty"` remoteUrl *url.URL @@ -227,24 +221,24 @@ type CommandProxyClientMessage struct { RtcpPort int `json:"rtcpPort,omitempty"` } -func (m *CommandProxyClientMessage) CheckValid() error { +func (m *CommandClientMessage) CheckValid() error { switch m.Type { case "": - return fmt.Errorf("type missing") + return errors.New("type missing") case "create-publisher": if m.StreamType == "" { - return fmt.Errorf("stream type missing") + return errors.New("stream type missing") } case "create-subscriber": if m.PublisherId == "" { - return fmt.Errorf("publisher id missing") + return errors.New("publisher id missing") } if m.StreamType == "" { - return fmt.Errorf("stream type missing") + return errors.New("stream type missing") } if m.RemoteUrl != "" { if m.RemoteToken == "" { - return fmt.Errorf("remote token missing") + return errors.New("remote token missing") } remoteUrl, err := url.Parse(m.RemoteUrl) @@ -257,42 +251,42 @@ func (m *CommandProxyClientMessage) CheckValid() error { fallthrough case "delete-subscriber": if m.ClientId == "" { - return fmt.Errorf("client id missing") + return errors.New("client id missing") } } return nil } -type CommandProxyServerMessage struct { +type CommandServerMessage struct { Id string `json:"id,omitempty"` Sid string `json:"sid,omitempty"` - Bitrate int `json:"bitrate,omitempty"` + Bitrate api.Bandwidth `json:"bitrate,omitempty"` - Streams []PublisherStream `json:"streams,omitempty"` + Streams []sfu.PublisherStream `json:"streams,omitempty"` } // Type "payload" -type PayloadProxyClientMessage struct { +type PayloadClientMessage struct { Type string `json:"type"` - ClientId string `json:"clientId"` - Sid string `json:"sid,omitempty"` - Payload map[string]interface{} `json:"payload,omitempty"` + ClientId string `json:"clientId"` + Sid string `json:"sid,omitempty"` + Payload api.StringMap `json:"payload,omitempty"` } -func (m *PayloadProxyClientMessage) CheckValid() error { +func (m *PayloadClientMessage) CheckValid() error { switch m.Type { case "": - return fmt.Errorf("type missing") + return errors.New("type missing") case "offer": fallthrough case "answer": fallthrough case "candidate": if len(m.Payload) == 0 { - return fmt.Errorf("payload missing") + return errors.New("payload missing") } case "endOfCandidates": fallthrough @@ -300,66 +294,72 @@ func (m *PayloadProxyClientMessage) CheckValid() error { // No payload required. } if m.ClientId == "" { - return fmt.Errorf("client id missing") + return errors.New("client id missing") } return nil } -type PayloadProxyServerMessage struct { +type PayloadServerMessage struct { Type string `json:"type"` - ClientId string `json:"clientId"` - Payload map[string]interface{} `json:"payload"` + ClientId string `json:"clientId"` + Payload api.StringMap `json:"payload"` } // Type "event" -type EventProxyServerBandwidth struct { +type EventServerBandwidth struct { // Incoming is the bandwidth utilization for publishers in percent. Incoming *float64 `json:"incoming,omitempty"` // Outgoing is the bandwidth utilization for subscribers in percent. Outgoing *float64 `json:"outgoing,omitempty"` + + // Received is the incoming bandwidth. + Received api.Bandwidth `json:"received,omitempty"` + // Sent is the outgoing bandwidth. + Sent api.Bandwidth `json:"sent,omitempty"` } -func (b *EventProxyServerBandwidth) String() string { - if b.Incoming != nil && b.Outgoing != nil { +func (b *EventServerBandwidth) String() string { + switch { + case b.Incoming != nil && b.Outgoing != nil: return fmt.Sprintf("bandwidth: incoming=%.3f%%, outgoing=%.3f%%", *b.Incoming, *b.Outgoing) - } else if b.Incoming != nil { + case b.Incoming != nil: return fmt.Sprintf("bandwidth: incoming=%.3f%%, outgoing=unlimited", *b.Incoming) - } else if b.Outgoing != nil { + case b.Outgoing != nil: return fmt.Sprintf("bandwidth: incoming=unlimited, outgoing=%.3f%%", *b.Outgoing) - } else { + default: return "bandwidth: incoming=unlimited, outgoing=unlimited" } } -func (b EventProxyServerBandwidth) AllowIncoming() bool { +func (b EventServerBandwidth) AllowIncoming() bool { return b.Incoming == nil || *b.Incoming < 100 } -func (b EventProxyServerBandwidth) AllowOutgoing() bool { +func (b EventServerBandwidth) AllowOutgoing() bool { return b.Outgoing == nil || *b.Outgoing < 100 } -type EventProxyServerMessage struct { +type EventServerMessage struct { Type string `json:"type"` ClientId string `json:"clientId,omitempty"` - Load int64 `json:"load,omitempty"` + Load uint64 `json:"load,omitempty"` Sid string `json:"sid,omitempty"` - Bandwidth *EventProxyServerBandwidth `json:"bandwidth,omitempty"` + Bandwidth *EventServerBandwidth `json:"bandwidth,omitempty"` } // Information on a proxy in the etcd cluster. -type ProxyInformationEtcd struct { +type InformationEtcd struct { Address string `json:"address"` } -func (p *ProxyInformationEtcd) CheckValid() error { +func (p *InformationEtcd) CheckValid() error { if p.Address == "" { - return fmt.Errorf("address missing") + return errors.New("address missing") } if p.Address[len(p.Address)-1] != '/' { p.Address += "/" diff --git a/api_proxy_easyjson.go b/proxy/api_easyjson.go similarity index 58% rename from api_proxy_easyjson.go rename to proxy/api_easyjson.go index e41b81c..163a495 100644 --- a/api_proxy_easyjson.go +++ b/proxy/api_easyjson.go @@ -1,6 +1,6 @@ // Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. -package signaling +package proxy import ( json "encoding/json" @@ -8,6 +8,8 @@ import ( easyjson "github.com/mailru/easyjson" jlexer "github.com/mailru/easyjson/jlexer" jwriter "github.com/mailru/easyjson/jwriter" + api "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + sfu "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" ) // suppress unused package warning @@ -18,7 +20,7 @@ var ( _ easyjson.Marshaler ) -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexer.Lexer, out *TokenClaims) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy(in *jlexer.Lexer, out *TokenClaims) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -31,19 +33,26 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "iss": - out.Issuer = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Issuer = string(in.String()) + } case "sub": - out.Subject = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Subject = string(in.String()) + } case "aud": - if data := in.Raw(); in.Ok() { - in.AddError((out.Audience).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Audience).UnmarshalJSON(data)) + } } case "exp": if in.IsNull() { @@ -53,8 +62,12 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe if out.ExpiresAt == nil { out.ExpiresAt = new(_v5.NumericDate) } - if data := in.Raw(); in.Ok() { - in.AddError((*out.ExpiresAt).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.ExpiresAt).UnmarshalJSON(data)) + } } } case "nbf": @@ -65,8 +78,12 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe if out.NotBefore == nil { out.NotBefore = new(_v5.NumericDate) } - if data := in.Raw(); in.Ok() { - in.AddError((*out.NotBefore).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.NotBefore).UnmarshalJSON(data)) + } } } case "iat": @@ -77,12 +94,20 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe if out.IssuedAt == nil { out.IssuedAt = new(_v5.NumericDate) } - if data := in.Raw(); in.Ok() { - in.AddError((*out.IssuedAt).UnmarshalJSON(data)) + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.IssuedAt).UnmarshalJSON(data)) + } } } case "jti": - out.ID = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ID = string(in.String()) + } default: in.SkipRecursive() } @@ -93,7 +118,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling(in *jlexe in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling(out *jwriter.Writer, in TokenClaims) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy(out *jwriter.Writer, in TokenClaims) { out.RawByte('{') first := true _ = first @@ -169,27 +194,27 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling(out *jwri // MarshalJSON supports json.Marshaler interface func (v TokenClaims) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v TokenClaims) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling(w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *TokenClaims) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *TokenClaims) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling(l, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlexer.Lexer, out *ProxyServerMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy1(in *jlexer.Lexer, out *ServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -202,25 +227,32 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "id": - out.Id = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Id = string(in.String()) + } case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "error": if in.IsNull() { in.Skip() out.Error = nil } else { if out.Error == nil { - out.Error = new(Error) + out.Error = new(api.Error) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Error).UnmarshalEasyJSON(in) } - (*out.Error).UnmarshalEasyJSON(in) } case "hello": if in.IsNull() { @@ -228,9 +260,13 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex out.Hello = nil } else { if out.Hello == nil { - out.Hello = new(HelloProxyServerMessage) + out.Hello = new(HelloServerMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Hello).UnmarshalEasyJSON(in) } - (*out.Hello).UnmarshalEasyJSON(in) } case "bye": if in.IsNull() { @@ -238,9 +274,13 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex out.Bye = nil } else { if out.Bye == nil { - out.Bye = new(ByeProxyServerMessage) + out.Bye = new(ByeServerMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Bye).UnmarshalEasyJSON(in) } - (*out.Bye).UnmarshalEasyJSON(in) } case "command": if in.IsNull() { @@ -248,9 +288,13 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex out.Command = nil } else { if out.Command == nil { - out.Command = new(CommandProxyServerMessage) + out.Command = new(CommandServerMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Command).UnmarshalEasyJSON(in) } - (*out.Command).UnmarshalEasyJSON(in) } case "payload": if in.IsNull() { @@ -258,9 +302,13 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex out.Payload = nil } else { if out.Payload == nil { - out.Payload = new(PayloadProxyServerMessage) + out.Payload = new(PayloadServerMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Payload).UnmarshalEasyJSON(in) } - (*out.Payload).UnmarshalEasyJSON(in) } case "event": if in.IsNull() { @@ -268,9 +316,13 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex out.Event = nil } else { if out.Event == nil { - out.Event = new(EventProxyServerMessage) + out.Event = new(EventServerMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Event).UnmarshalEasyJSON(in) } - (*out.Event).UnmarshalEasyJSON(in) } default: in.SkipRecursive() @@ -282,7 +334,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling1(in *jlex in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling1(out *jwriter.Writer, in ProxyServerMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy1(out *jwriter.Writer, in ServerMessage) { out.RawByte('{') first := true _ = first @@ -336,29 +388,29 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling1(out *jwr } // MarshalJSON supports json.Marshaler interface -func (v ProxyServerMessage) MarshalJSON() ([]byte, error) { +func (v ServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling1(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy1(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v ProxyServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling1(w, v) +func (v ServerMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy1(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *ProxyServerMessage) UnmarshalJSON(data []byte) error { +func (v *ServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling1(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy1(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *ProxyServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling1(l, v) +func (v *ServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy1(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlexer.Lexer, out *ProxyInformationEtcd) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy2(in *jlexer.Lexer, out *PayloadServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -371,227 +423,25 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling2(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "address": - out.Address = string(in.String()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling2(out *jwriter.Writer, in ProxyInformationEtcd) { - out.RawByte('{') - first := true - _ = first - { - const prefix string = ",\"address\":" - out.RawString(prefix[1:]) - out.String(string(in.Address)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v ProxyInformationEtcd) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling2(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v ProxyInformationEtcd) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling2(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *ProxyInformationEtcd) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling2(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *ProxyInformationEtcd) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling2(l, v) -} -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling3(in *jlexer.Lexer, out *ProxyClientMessage) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "id": - out.Id = string(in.String()) - case "type": - out.Type = string(in.String()) - case "hello": - if in.IsNull() { - in.Skip() - out.Hello = nil - } else { - if out.Hello == nil { - out.Hello = new(HelloProxyClientMessage) - } - (*out.Hello).UnmarshalEasyJSON(in) - } - case "bye": - if in.IsNull() { - in.Skip() - out.Bye = nil - } else { - if out.Bye == nil { - out.Bye = new(ByeProxyClientMessage) - } - (*out.Bye).UnmarshalEasyJSON(in) - } - case "command": - if in.IsNull() { - in.Skip() - out.Command = nil - } else { - if out.Command == nil { - out.Command = new(CommandProxyClientMessage) - } - (*out.Command).UnmarshalEasyJSON(in) - } - case "payload": - if in.IsNull() { - in.Skip() - out.Payload = nil - } else { - if out.Payload == nil { - out.Payload = new(PayloadProxyClientMessage) - } - (*out.Payload).UnmarshalEasyJSON(in) - } - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling3(out *jwriter.Writer, in ProxyClientMessage) { - out.RawByte('{') - first := true - _ = first - if in.Id != "" { - const prefix string = ",\"id\":" - first = false - out.RawString(prefix[1:]) - out.String(string(in.Id)) - } - { - const prefix string = ",\"type\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.String(string(in.Type)) - } - if in.Hello != nil { - const prefix string = ",\"hello\":" - out.RawString(prefix) - (*in.Hello).MarshalEasyJSON(out) - } - if in.Bye != nil { - const prefix string = ",\"bye\":" - out.RawString(prefix) - (*in.Bye).MarshalEasyJSON(out) - } - if in.Command != nil { - const prefix string = ",\"command\":" - out.RawString(prefix) - (*in.Command).MarshalEasyJSON(out) - } - if in.Payload != nil { - const prefix string = ",\"payload\":" - out.RawString(prefix) - (*in.Payload).MarshalEasyJSON(out) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v ProxyClientMessage) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling3(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v ProxyClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling3(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *ProxyClientMessage) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling3(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *ProxyClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling3(l, v) -} -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlexer.Lexer, out *PayloadProxyServerMessage) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "clientId": - out.ClientId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ClientId = string(in.String()) + } case "payload": if in.IsNull() { in.Skip() } else { in.Delim('{') - out.Payload = make(map[string]interface{}) + out.Payload = make(api.StringMap) for !in.IsDelim('}') { key := string(in.String()) in.WantColon() @@ -618,7 +468,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling4(in *jlex in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling4(out *jwriter.Writer, in PayloadProxyServerMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy2(out *jwriter.Writer, in PayloadServerMessage) { out.RawByte('{') first := true _ = first @@ -663,29 +513,29 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling4(out *jwr } // MarshalJSON supports json.Marshaler interface -func (v PayloadProxyServerMessage) MarshalJSON() ([]byte, error) { +func (v PayloadServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling4(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy2(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v PayloadProxyServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling4(w, v) +func (v PayloadServerMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy2(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *PayloadProxyServerMessage) UnmarshalJSON(data []byte) error { +func (v *PayloadServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling4(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy2(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *PayloadProxyServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling4(l, v) +func (v *PayloadServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy2(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling5(in *jlexer.Lexer, out *PayloadProxyClientMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy3(in *jlexer.Lexer, out *PayloadClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -698,25 +548,32 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling5(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "clientId": - out.ClientId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ClientId = string(in.String()) + } case "sid": - out.Sid = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Sid = string(in.String()) + } case "payload": if in.IsNull() { in.Skip() } else { in.Delim('{') if !in.IsDelim('}') { - out.Payload = make(map[string]interface{}) + out.Payload = make(api.StringMap) } else { out.Payload = nil } @@ -746,7 +603,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling5(in *jlex in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling5(out *jwriter.Writer, in PayloadProxyClientMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy3(out *jwriter.Writer, in PayloadClientMessage) { out.RawByte('{') first := true _ = first @@ -794,29 +651,29 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling5(out *jwr } // MarshalJSON supports json.Marshaler interface -func (v PayloadProxyClientMessage) MarshalJSON() ([]byte, error) { +func (v PayloadClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling5(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy3(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v PayloadProxyClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling5(w, v) +func (v PayloadClientMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy3(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *PayloadProxyClientMessage) UnmarshalJSON(data []byte) error { +func (v *PayloadClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling5(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy3(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *PayloadProxyClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling5(l, v) +func (v *PayloadClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy3(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling6(in *jlexer.Lexer, out *NewPublisherSettings) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy4(in *jlexer.Lexer, out *InformationEtcd) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -829,152 +686,12 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling6(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { - case "bitrate": - out.Bitrate = int(in.Int()) - case "mediatypes": - out.MediaTypes = MediaType(in.Int()) - case "audiocodec": - out.AudioCodec = string(in.String()) - case "videocodec": - out.VideoCodec = string(in.String()) - case "vp9_profile": - out.VP9Profile = string(in.String()) - case "h264_profile": - out.H264Profile = string(in.String()) - default: - in.SkipRecursive() - } - in.WantComma() - } - in.Delim('}') - if isTopLevel { - in.Consumed() - } -} -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling6(out *jwriter.Writer, in NewPublisherSettings) { - out.RawByte('{') - first := true - _ = first - if in.Bitrate != 0 { - const prefix string = ",\"bitrate\":" - first = false - out.RawString(prefix[1:]) - out.Int(int(in.Bitrate)) - } - if in.MediaTypes != 0 { - const prefix string = ",\"mediatypes\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.Int(int(in.MediaTypes)) - } - if in.AudioCodec != "" { - const prefix string = ",\"audiocodec\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.String(string(in.AudioCodec)) - } - if in.VideoCodec != "" { - const prefix string = ",\"videocodec\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.String(string(in.VideoCodec)) - } - if in.VP9Profile != "" { - const prefix string = ",\"vp9_profile\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.String(string(in.VP9Profile)) - } - if in.H264Profile != "" { - const prefix string = ",\"h264_profile\":" - if first { - first = false - out.RawString(prefix[1:]) - } else { - out.RawString(prefix) - } - out.String(string(in.H264Profile)) - } - out.RawByte('}') -} - -// MarshalJSON supports json.Marshaler interface -func (v NewPublisherSettings) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling6(&w, v) - return w.Buffer.BuildBytes(), w.Error -} - -// MarshalEasyJSON supports easyjson.Marshaler interface -func (v NewPublisherSettings) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling6(w, v) -} - -// UnmarshalJSON supports json.Unmarshaler interface -func (v *NewPublisherSettings) UnmarshalJSON(data []byte) error { - r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling6(&r, v) - return r.Error() -} - -// UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *NewPublisherSettings) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling6(l, v) -} -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling7(in *jlexer.Lexer, out *HelloProxyServerMessage) { - isTopLevel := in.IsStart() - if in.IsNull() { - if isTopLevel { - in.Consumed() - } - in.Skip() - return - } - in.Delim('{') - for !in.IsDelim('}') { - key := in.UnsafeFieldName(false) - in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } - switch key { - case "version": - out.Version = string(in.String()) - case "sessionid": - out.SessionId = string(in.String()) - case "server": + case "address": if in.IsNull() { in.Skip() - out.Server = nil } else { - if out.Server == nil { - out.Server = new(WelcomeServerMessage) - } - (*out.Server).UnmarshalEasyJSON(in) + out.Address = string(in.String()) } default: in.SkipRecursive() @@ -986,7 +703,92 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling7(in *jlex in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling7(out *jwriter.Writer, in HelloProxyServerMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy4(out *jwriter.Writer, in InformationEtcd) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"address\":" + out.RawString(prefix[1:]) + out.String(string(in.Address)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v InformationEtcd) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy4(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v InformationEtcd) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy4(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *InformationEtcd) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy4(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *InformationEtcd) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy4(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy5(in *jlexer.Lexer, out *HelloServerMessage) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "sessionid": + if in.IsNull() { + in.Skip() + } else { + out.SessionId = api.PublicSessionId(in.String()) + } + case "server": + if in.IsNull() { + in.Skip() + out.Server = nil + } else { + if out.Server == nil { + out.Server = new(api.WelcomeServerMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Server).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy5(out *jwriter.Writer, in HelloServerMessage) { out.RawByte('{') first := true _ = first @@ -1009,29 +811,29 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling7(out *jwr } // MarshalJSON supports json.Marshaler interface -func (v HelloProxyServerMessage) MarshalJSON() ([]byte, error) { +func (v HelloServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling7(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy5(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v HelloProxyServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling7(w, v) +func (v HelloServerMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy5(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *HelloProxyServerMessage) UnmarshalJSON(data []byte) error { +func (v *HelloServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling7(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy5(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *HelloProxyServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling7(l, v) +func (v *HelloServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy5(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlexer.Lexer, out *HelloProxyClientMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy6(in *jlexer.Lexer, out *HelloClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1044,16 +846,19 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "version": - out.Version = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } case "resumeid": - out.ResumeId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ResumeId = api.PublicSessionId(in.String()) + } case "features": if in.IsNull() { in.Skip() @@ -1071,14 +876,22 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlex } for !in.IsDelim(']') { var v5 string - v5 = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + v5 = string(in.String()) + } out.Features = append(out.Features, v5) in.WantComma() } in.Delim(']') } case "token": - out.Token = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Token = string(in.String()) + } default: in.SkipRecursive() } @@ -1089,7 +902,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling8(in *jlex in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling8(out *jwriter.Writer, in HelloProxyClientMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy6(out *jwriter.Writer, in HelloClientMessage) { out.RawByte('{') first := true _ = first @@ -1126,29 +939,29 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling8(out *jwr } // MarshalJSON supports json.Marshaler interface -func (v HelloProxyClientMessage) MarshalJSON() ([]byte, error) { +func (v HelloClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling8(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy6(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v HelloProxyClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling8(w, v) +func (v HelloClientMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy6(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *HelloProxyClientMessage) UnmarshalJSON(data []byte) error { +func (v *HelloClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling8(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy6(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *HelloProxyClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling8(l, v) +func (v *HelloClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy6(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling9(in *jlexer.Lexer, out *EventProxyServerMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy7(in *jlexer.Lexer, out *EventServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1161,29 +974,44 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling9(in *jlex for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "clientId": - out.ClientId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ClientId = string(in.String()) + } case "load": - out.Load = int64(in.Int64()) + if in.IsNull() { + in.Skip() + } else { + out.Load = uint64(in.Uint64()) + } case "sid": - out.Sid = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Sid = string(in.String()) + } case "bandwidth": if in.IsNull() { in.Skip() out.Bandwidth = nil } else { if out.Bandwidth == nil { - out.Bandwidth = new(EventProxyServerBandwidth) + out.Bandwidth = new(EventServerBandwidth) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Bandwidth).UnmarshalEasyJSON(in) } - (*out.Bandwidth).UnmarshalEasyJSON(in) } default: in.SkipRecursive() @@ -1195,7 +1023,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling9(in *jlex in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling9(out *jwriter.Writer, in EventProxyServerMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy7(out *jwriter.Writer, in EventServerMessage) { out.RawByte('{') first := true _ = first @@ -1212,7 +1040,7 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling9(out *jwr if in.Load != 0 { const prefix string = ",\"load\":" out.RawString(prefix) - out.Int64(int64(in.Load)) + out.Uint64(uint64(in.Load)) } if in.Sid != "" { const prefix string = ",\"sid\":" @@ -1228,29 +1056,29 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling9(out *jwr } // MarshalJSON supports json.Marshaler interface -func (v EventProxyServerMessage) MarshalJSON() ([]byte, error) { +func (v EventServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling9(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy7(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v EventProxyServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling9(w, v) +func (v EventServerMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy7(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *EventProxyServerMessage) UnmarshalJSON(data []byte) error { +func (v *EventServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling9(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy7(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *EventProxyServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling9(l, v) +func (v *EventServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy7(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling10(in *jlexer.Lexer, out *EventProxyServerBandwidth) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy8(in *jlexer.Lexer, out *EventServerBandwidth) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1263,11 +1091,6 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling10(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "incoming": if in.IsNull() { @@ -1277,7 +1100,11 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling10(in *jle if out.Incoming == nil { out.Incoming = new(float64) } - *out.Incoming = float64(in.Float64()) + if in.IsNull() { + in.Skip() + } else { + *out.Incoming = float64(in.Float64()) + } } case "outgoing": if in.IsNull() { @@ -1287,7 +1114,23 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling10(in *jle if out.Outgoing == nil { out.Outgoing = new(float64) } - *out.Outgoing = float64(in.Float64()) + if in.IsNull() { + in.Skip() + } else { + *out.Outgoing = float64(in.Float64()) + } + } + case "received": + if in.IsNull() { + in.Skip() + } else { + out.Received = api.Bandwidth(in.Uint64()) + } + case "sent": + if in.IsNull() { + in.Skip() + } else { + out.Sent = api.Bandwidth(in.Uint64()) } default: in.SkipRecursive() @@ -1299,7 +1142,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling10(in *jle in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling10(out *jwriter.Writer, in EventProxyServerBandwidth) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy8(out *jwriter.Writer, in EventServerBandwidth) { out.RawByte('{') first := true _ = first @@ -1319,33 +1162,53 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling10(out *jw } out.Float64(float64(*in.Outgoing)) } + if in.Received != 0 { + const prefix string = ",\"received\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Uint64(uint64(in.Received)) + } + if in.Sent != 0 { + const prefix string = ",\"sent\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Uint64(uint64(in.Sent)) + } out.RawByte('}') } // MarshalJSON supports json.Marshaler interface -func (v EventProxyServerBandwidth) MarshalJSON() ([]byte, error) { +func (v EventServerBandwidth) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling10(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy8(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v EventProxyServerBandwidth) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling10(w, v) +func (v EventServerBandwidth) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy8(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *EventProxyServerBandwidth) UnmarshalJSON(data []byte) error { +func (v *EventServerBandwidth) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling10(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy8(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *EventProxyServerBandwidth) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling10(l, v) +func (v *EventServerBandwidth) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy8(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling11(in *jlexer.Lexer, out *CommandProxyServerMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy9(in *jlexer.Lexer, out *CommandServerMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1358,18 +1221,25 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling11(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "id": - out.Id = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Id = string(in.String()) + } case "sid": - out.Sid = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Sid = string(in.String()) + } case "bitrate": - out.Bitrate = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.Bitrate = api.Bandwidth(in.Uint64()) + } case "streams": if in.IsNull() { in.Skip() @@ -1378,16 +1248,16 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling11(in *jle in.Delim('[') if out.Streams == nil { if !in.IsDelim(']') { - out.Streams = make([]PublisherStream, 0, 0) + out.Streams = make([]sfu.PublisherStream, 0, 0) } else { - out.Streams = []PublisherStream{} + out.Streams = []sfu.PublisherStream{} } } else { out.Streams = (out.Streams)[:0] } for !in.IsDelim(']') { - var v8 PublisherStream - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling12(in, &v8) + var v8 sfu.PublisherStream + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Sfu(in, &v8) out.Streams = append(out.Streams, v8) in.WantComma() } @@ -1403,7 +1273,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling11(in *jle in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling11(out *jwriter.Writer, in CommandProxyServerMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy9(out *jwriter.Writer, in CommandServerMessage) { out.RawByte('{') first := true _ = first @@ -1431,7 +1301,7 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling11(out *jw } else { out.RawString(prefix) } - out.Int(int(in.Bitrate)) + out.Uint64(uint64(in.Bitrate)) } if len(in.Streams) != 0 { const prefix string = ",\"streams\":" @@ -1447,7 +1317,7 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling11(out *jw if v9 > 0 { out.RawByte(',') } - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling12(out, v10) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Sfu(out, v10) } out.RawByte(']') } @@ -1456,29 +1326,29 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling11(out *jw } // MarshalJSON supports json.Marshaler interface -func (v CommandProxyServerMessage) MarshalJSON() ([]byte, error) { +func (v CommandServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling11(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy9(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v CommandProxyServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling11(w, v) +func (v CommandServerMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy9(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *CommandProxyServerMessage) UnmarshalJSON(data []byte) error { +func (v *CommandServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling11(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy9(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *CommandProxyServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling11(l, v) +func (v *CommandServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy9(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling12(in *jlexer.Lexer, out *PublisherStream) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Sfu(in *jlexer.Lexer, out *sfu.PublisherStream) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1491,42 +1361,97 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling12(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "mid": - out.Mid = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Mid = string(in.String()) + } case "mindex": - out.Mindex = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.Mindex = int(in.Int()) + } case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "description": - out.Description = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Description = string(in.String()) + } case "disabled": - out.Disabled = bool(in.Bool()) + if in.IsNull() { + in.Skip() + } else { + out.Disabled = bool(in.Bool()) + } case "codec": - out.Codec = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Codec = string(in.String()) + } case "stereo": - out.Stereo = bool(in.Bool()) + if in.IsNull() { + in.Skip() + } else { + out.Stereo = bool(in.Bool()) + } case "fec": - out.Fec = bool(in.Bool()) + if in.IsNull() { + in.Skip() + } else { + out.Fec = bool(in.Bool()) + } case "dtx": - out.Dtx = bool(in.Bool()) + if in.IsNull() { + in.Skip() + } else { + out.Dtx = bool(in.Bool()) + } case "simulcast": - out.Simulcast = bool(in.Bool()) + if in.IsNull() { + in.Skip() + } else { + out.Simulcast = bool(in.Bool()) + } case "svc": - out.Svc = bool(in.Bool()) + if in.IsNull() { + in.Skip() + } else { + out.Svc = bool(in.Bool()) + } case "h264_profile": - out.ProfileH264 = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ProfileH264 = string(in.String()) + } case "vp9_profile": - out.ProfileVP9 = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ProfileVP9 = string(in.String()) + } case "videoorient_ext_id": - out.ExtIdVideoOrientation = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.ExtIdVideoOrientation = int(in.Int()) + } case "playoutdelay_ext_id": - out.ExtIdPlayoutDelay = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.ExtIdPlayoutDelay = int(in.Int()) + } default: in.SkipRecursive() } @@ -1537,7 +1462,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling12(in *jle in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling12(out *jwriter.Writer, in PublisherStream) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Sfu(out *jwriter.Writer, in sfu.PublisherStream) { out.RawByte('{') first := true _ = first @@ -1618,7 +1543,7 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling12(out *jw } out.RawByte('}') } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling13(in *jlexer.Lexer, out *CommandProxyClientMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy10(in *jlexer.Lexer, out *CommandClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1631,46 +1556,89 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling13(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { case "type": - out.Type = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } case "sid": - out.Sid = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Sid = string(in.String()) + } case "streamType": - out.StreamType = StreamType(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.StreamType = sfu.StreamType(in.String()) + } case "publisherId": - out.PublisherId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.PublisherId = api.PublicSessionId(in.String()) + } case "clientId": - out.ClientId = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.ClientId = string(in.String()) + } case "bitrate": - out.Bitrate = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.Bitrate = api.Bandwidth(in.Uint64()) + } case "mediatypes": - out.MediaTypes = MediaType(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.MediaTypes = sfu.MediaType(in.Int()) + } case "publisherSettings": if in.IsNull() { in.Skip() out.PublisherSettings = nil } else { if out.PublisherSettings == nil { - out.PublisherSettings = new(NewPublisherSettings) + out.PublisherSettings = new(sfu.NewPublisherSettings) } - (*out.PublisherSettings).UnmarshalEasyJSON(in) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Sfu1(in, out.PublisherSettings) } case "remoteUrl": - out.RemoteUrl = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RemoteUrl = string(in.String()) + } case "remoteToken": - out.RemoteToken = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.RemoteToken = string(in.String()) + } case "hostname": - out.Hostname = string(in.String()) + if in.IsNull() { + in.Skip() + } else { + out.Hostname = string(in.String()) + } case "port": - out.Port = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.Port = int(in.Int()) + } case "rtcpPort": - out.RtcpPort = int(in.Int()) + if in.IsNull() { + in.Skip() + } else { + out.RtcpPort = int(in.Int()) + } default: in.SkipRecursive() } @@ -1681,7 +1649,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling13(in *jle in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling13(out *jwriter.Writer, in CommandProxyClientMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy10(out *jwriter.Writer, in CommandClientMessage) { out.RawByte('{') first := true _ = first @@ -1713,7 +1681,7 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling13(out *jw if in.Bitrate != 0 { const prefix string = ",\"bitrate\":" out.RawString(prefix) - out.Int(int(in.Bitrate)) + out.Uint64(uint64(in.Bitrate)) } if in.MediaTypes != 0 { const prefix string = ",\"mediatypes\":" @@ -1723,7 +1691,7 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling13(out *jw if in.PublisherSettings != nil { const prefix string = ",\"publisherSettings\":" out.RawString(prefix) - (*in.PublisherSettings).MarshalEasyJSON(out) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Sfu1(out, *in.PublisherSettings) } if in.RemoteUrl != "" { const prefix string = ",\"remoteUrl\":" @@ -1754,29 +1722,29 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling13(out *jw } // MarshalJSON supports json.Marshaler interface -func (v CommandProxyClientMessage) MarshalJSON() ([]byte, error) { +func (v CommandClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling13(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy10(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v CommandProxyClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling13(w, v) +func (v CommandClientMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy10(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *CommandProxyClientMessage) UnmarshalJSON(data []byte) error { +func (v *CommandClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling13(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy10(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *CommandProxyClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling13(l, v) +func (v *CommandClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy10(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling14(in *jlexer.Lexer, out *ByeProxyServerMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Sfu1(in *jlexer.Lexer, out *sfu.NewPublisherSettings) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1789,14 +1757,43 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling14(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { - case "reason": - out.Reason = string(in.String()) + case "bitrate": + if in.IsNull() { + in.Skip() + } else { + out.Bitrate = api.Bandwidth(in.Uint64()) + } + case "mediatypes": + if in.IsNull() { + in.Skip() + } else { + out.MediaTypes = sfu.MediaType(in.Int()) + } + case "audiocodec": + if in.IsNull() { + in.Skip() + } else { + out.AudioCodec = string(in.String()) + } + case "videocodec": + if in.IsNull() { + in.Skip() + } else { + out.VideoCodec = string(in.String()) + } + case "vp9_profile": + if in.IsNull() { + in.Skip() + } else { + out.VP9Profile = string(in.String()) + } + case "h264_profile": + if in.IsNull() { + in.Skip() + } else { + out.H264Profile = string(in.String()) + } default: in.SkipRecursive() } @@ -1807,7 +1804,257 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling14(in *jle in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling14(out *jwriter.Writer, in ByeProxyServerMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Sfu1(out *jwriter.Writer, in sfu.NewPublisherSettings) { + out.RawByte('{') + first := true + _ = first + if in.Bitrate != 0 { + const prefix string = ",\"bitrate\":" + first = false + out.RawString(prefix[1:]) + out.Uint64(uint64(in.Bitrate)) + } + if in.MediaTypes != 0 { + const prefix string = ",\"mediatypes\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Int(int(in.MediaTypes)) + } + if in.AudioCodec != "" { + const prefix string = ",\"audiocodec\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.AudioCodec)) + } + if in.VideoCodec != "" { + const prefix string = ",\"videocodec\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.VideoCodec)) + } + if in.VP9Profile != "" { + const prefix string = ",\"vp9_profile\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.VP9Profile)) + } + if in.H264Profile != "" { + const prefix string = ",\"h264_profile\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.H264Profile)) + } + out.RawByte('}') +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy11(in *jlexer.Lexer, out *ClientMessage) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "id": + if in.IsNull() { + in.Skip() + } else { + out.Id = string(in.String()) + } + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "hello": + if in.IsNull() { + in.Skip() + out.Hello = nil + } else { + if out.Hello == nil { + out.Hello = new(HelloClientMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Hello).UnmarshalEasyJSON(in) + } + } + case "bye": + if in.IsNull() { + in.Skip() + out.Bye = nil + } else { + if out.Bye == nil { + out.Bye = new(ByeClientMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Bye).UnmarshalEasyJSON(in) + } + } + case "command": + if in.IsNull() { + in.Skip() + out.Command = nil + } else { + if out.Command == nil { + out.Command = new(CommandClientMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Command).UnmarshalEasyJSON(in) + } + } + case "payload": + if in.IsNull() { + in.Skip() + out.Payload = nil + } else { + if out.Payload == nil { + out.Payload = new(PayloadClientMessage) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Payload).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy11(out *jwriter.Writer, in ClientMessage) { + out.RawByte('{') + first := true + _ = first + if in.Id != "" { + const prefix string = ",\"id\":" + first = false + out.RawString(prefix[1:]) + out.String(string(in.Id)) + } + { + const prefix string = ",\"type\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.Type)) + } + if in.Hello != nil { + const prefix string = ",\"hello\":" + out.RawString(prefix) + (*in.Hello).MarshalEasyJSON(out) + } + if in.Bye != nil { + const prefix string = ",\"bye\":" + out.RawString(prefix) + (*in.Bye).MarshalEasyJSON(out) + } + if in.Command != nil { + const prefix string = ",\"command\":" + out.RawString(prefix) + (*in.Command).MarshalEasyJSON(out) + } + if in.Payload != nil { + const prefix string = ",\"payload\":" + out.RawString(prefix) + (*in.Payload).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v ClientMessage) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy11(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v ClientMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy11(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *ClientMessage) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy11(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *ClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy11(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy12(in *jlexer.Lexer, out *ByeServerMessage) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "reason": + if in.IsNull() { + in.Skip() + } else { + out.Reason = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy12(out *jwriter.Writer, in ByeServerMessage) { out.RawByte('{') first := true _ = first @@ -1820,29 +2067,29 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling14(out *jw } // MarshalJSON supports json.Marshaler interface -func (v ByeProxyServerMessage) MarshalJSON() ([]byte, error) { +func (v ByeServerMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling14(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy12(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v ByeProxyServerMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling14(w, v) +func (v ByeServerMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy12(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *ByeProxyServerMessage) UnmarshalJSON(data []byte) error { +func (v *ByeServerMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling14(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy12(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *ByeProxyServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling14(l, v) +func (v *ByeServerMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy12(l, v) } -func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling15(in *jlexer.Lexer, out *ByeProxyClientMessage) { +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy13(in *jlexer.Lexer, out *ByeClientMessage) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1855,11 +2102,6 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling15(in *jle for !in.IsDelim('}') { key := in.UnsafeFieldName(false) in.WantColon() - if in.IsNull() { - in.Skip() - in.WantComma() - continue - } switch key { default: in.SkipRecursive() @@ -1871,7 +2113,7 @@ func easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling15(in *jle in.Consumed() } } -func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling15(out *jwriter.Writer, in ByeProxyClientMessage) { +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy13(out *jwriter.Writer, in ByeClientMessage) { out.RawByte('{') first := true _ = first @@ -1879,25 +2121,25 @@ func easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling15(out *jw } // MarshalJSON supports json.Marshaler interface -func (v ByeProxyClientMessage) MarshalJSON() ([]byte, error) { +func (v ByeClientMessage) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling15(&w, v) + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy13(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface -func (v ByeProxyClientMessage) MarshalEasyJSON(w *jwriter.Writer) { - easyjson1c8542dbEncodeGithubComStrukturagNextcloudSpreedSignaling15(w, v) +func (v ByeClientMessage) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy13(w, v) } // UnmarshalJSON supports json.Unmarshaler interface -func (v *ByeProxyClientMessage) UnmarshalJSON(data []byte) error { +func (v *ByeClientMessage) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling15(&r, v) + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy13(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface -func (v *ByeProxyClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjson1c8542dbDecodeGithubComStrukturagNextcloudSpreedSignaling15(l, v) +func (v *ByeClientMessage) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Proxy13(l, v) } diff --git a/proxy/api_test.go b/proxy/api_test.go new file mode 100644 index 0000000..873ce32 --- /dev/null +++ b/proxy/api_test.go @@ -0,0 +1,591 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package proxy + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/mock" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" +) + +func TestValidate(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + testcases := []struct { + message *ClientMessage + reason string + }{ + { + &ClientMessage{}, + "type missing", + }, + { + // Unknown types are ignored. + &ClientMessage{ + Type: "invalid", + }, + "", + }, + { + &ClientMessage{ + Type: "hello", + }, + "hello missing", + }, + { + &ClientMessage{ + Type: "hello", + Hello: &HelloClientMessage{}, + }, + "unsupported hello version", + }, + { + &ClientMessage{ + Type: "hello", + Hello: &HelloClientMessage{ + Version: "abc", + }, + }, + "unsupported hello version", + }, + { + &ClientMessage{ + Type: "hello", + Hello: &HelloClientMessage{ + Version: "1.0", + }, + }, + "token missing", + }, + { + &ClientMessage{ + Type: "hello", + Hello: &HelloClientMessage{ + Version: "1.0", + Token: "token", + }, + }, + "", + }, + { + &ClientMessage{ + Type: "hello", + Hello: &HelloClientMessage{ + Version: "1.0", + ResumeId: "resume-id", + }, + }, + "", + }, + { + &ClientMessage{ + Type: "bye", + }, + "", + }, + { + &ClientMessage{ + Type: "bye", + Bye: &ByeClientMessage{}, + }, + "", + }, + { + &ClientMessage{ + Type: "command", + }, + "command missing", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{}, + }, + "type missing", + }, + { + // Unknown types are ignored. + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "invalid", + }, + }, + "", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "create-publisher", + }, + }, + "stream type missing", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "create-publisher", + StreamType: sfu.StreamTypeVideo, + }, + }, + "", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "create-subscriber", + }, + }, + "publisher id missing", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "create-subscriber", + PublisherId: "foo", + }, + }, + "stream type missing", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "create-subscriber", + PublisherId: "foo", + StreamType: sfu.StreamTypeVideo, + }, + }, + "", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "create-subscriber", + PublisherId: "foo", + StreamType: sfu.StreamTypeVideo, + RemoteUrl: "http://domain.invalid", + }, + }, + "remote token missing", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "create-subscriber", + PublisherId: "foo", + StreamType: sfu.StreamTypeVideo, + RemoteUrl: ":", + RemoteToken: "remote-token", + }, + }, + "invalid remote url", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "create-subscriber", + PublisherId: "foo", + StreamType: sfu.StreamTypeVideo, + RemoteUrl: "http://domain.invalid", + RemoteToken: "remote-token", + }, + }, + "", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "delete-publisher", + }, + }, + "client id missing", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "delete-publisher", + ClientId: "foo", + }, + }, + "", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "delete-subscriber", + }, + }, + "client id missing", + }, + { + &ClientMessage{ + Type: "command", + Command: &CommandClientMessage{ + Type: "delete-subscriber", + ClientId: "foo", + }, + }, + "", + }, + { + &ClientMessage{ + Type: "payload", + }, + "payload missing", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{}, + }, + "type missing", + }, + { + // Unknown types are ignored. + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "invalid", + }, + }, + "client id missing", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "offer", + }, + }, + "payload missing", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "offer", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + }, + }, + "client id missing", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "offer", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + ClientId: "foo", + }, + }, + "", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "answer", + }, + }, + "payload missing", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "answer", + Payload: api.StringMap{ + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + }, + }, + "client id missing", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "answer", + Payload: api.StringMap{ + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + ClientId: "foo", + }, + }, + "", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "candidate", + }, + }, + "payload missing", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "candidate", + Payload: api.StringMap{ + "candidate": "invalid-candidate", + }, + }, + }, + "client id missing", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "candidate", + Payload: api.StringMap{ + "candidate": "invalid-candidate", + }, + ClientId: "foo", + }, + }, + "", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "endOfCandidates", + }, + }, + "client id missing", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "endOfCandidates", + ClientId: "foo", + }, + }, + "", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "requestoffer", + }, + }, + "client id missing", + }, + { + &ClientMessage{ + Type: "payload", + Payload: &PayloadClientMessage{ + Type: "requestoffer", + ClientId: "foo", + }, + }, + "", + }, + } + + for idx, tc := range testcases { + err := tc.message.CheckValid() + if tc.reason == "" { + assert.NoError(err, "failed for testcase %d: %+v", idx, tc.message) + } else { + assert.ErrorContains(err, tc.reason, "failed for testcase %d: %+v", idx, tc.message) + } + } +} + +func TestServerErrorMessage(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + message := &ClientMessage{ + Id: "12346", + } + err := message.NewErrorServerMessage(api.NewError("error_code", "Test error")) + assert.Equal(message.Id, err.Id) + if e := err.Error; assert.NotNil(e) { + assert.Equal("error_code", e.Code) + assert.Equal("Test error", e.Message) + assert.Empty(e.Details) + } +} + +func TestWrapperServerErrorMessage(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + message := &ClientMessage{ + Id: "12346", + } + err := message.NewWrappedErrorServerMessage(errors.New("an internal server error")) + assert.Equal(message.Id, err.Id) + if e := err.Error; assert.NotNil(e) { + assert.Equal("internal_error", e.Code) + assert.Equal("an internal server error", e.Message) + assert.Empty(e.Details) + } +} + +func TestCloseAfterSend(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + message := &ServerMessage{ + Type: "bye", + } + assert.True(message.CloseAfterSend(nil)) + + for _, msgType := range []string{ + "error", + "hello", + "command", + "payload", + "event", + } { + message = &ServerMessage{ + Type: msgType, + } + assert.False(message.CloseAfterSend(nil), "failed for %s", msgType) + } +} + +func TestAllowIncoming(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + testcases := []struct { + bw float64 + allow bool + }{ + { + 0, true, + }, + { + 99, true, + }, + { + 99.9, true, + }, + { + 100, false, + }, + { + 200, false, + }, + } + + bw := EventServerBandwidth{ + Incoming: nil, + } + assert.True(bw.AllowIncoming()) + for idx, tc := range testcases { + bw := EventServerBandwidth{ + Incoming: internal.MakePtr(tc.bw), + } + assert.Equal(tc.allow, bw.AllowIncoming(), "failed for testcase %d: %+v", idx, tc) + } +} + +func TestAllowOutgoing(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + testcases := []struct { + bw float64 + allow bool + }{ + { + 0, true, + }, + { + 99, true, + }, + { + 99.9, true, + }, + { + 100, false, + }, + { + 200, false, + }, + } + + bw := EventServerBandwidth{ + Outgoing: nil, + } + assert.True(bw.AllowOutgoing()) + for idx, tc := range testcases { + bw := EventServerBandwidth{ + Outgoing: internal.MakePtr(tc.bw), + } + assert.Equal(tc.allow, bw.AllowOutgoing(), "failed for testcase %d: %+v", idx, tc) + } +} + +func TestInformationEtcd(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + + info1 := &InformationEtcd{} + assert.ErrorContains(info1.CheckValid(), "address missing") + + info2 := &InformationEtcd{ + Address: "http://domain.invalid", + } + if assert.NoError(info2.CheckValid()) { + assert.Equal("http://domain.invalid/", info2.Address) + } + + info3 := &InformationEtcd{ + Address: "http://domain.invalid/", + } + if assert.NoError(info3.CheckValid()) { + assert.Equal("http://domain.invalid/", info3.Address) + } +} diff --git a/publisher_stats_counter.go b/publisher_stats_counter.go deleted file mode 100644 index ba8b293..0000000 --- a/publisher_stats_counter.go +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2021 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "sync" -) - -type publisherStatsCounter struct { - mu sync.Mutex - - streamTypes map[StreamType]bool - subscribers map[string]bool -} - -func (c *publisherStatsCounter) Reset() { - c.mu.Lock() - defer c.mu.Unlock() - - count := len(c.subscribers) - for streamType := range c.streamTypes { - statsMcuPublisherStreamTypesCurrent.WithLabelValues(string(streamType)).Dec() - statsMcuSubscriberStreamTypesCurrent.WithLabelValues(string(streamType)).Sub(float64(count)) - } - c.streamTypes = nil - c.subscribers = nil -} - -func (c *publisherStatsCounter) EnableStream(streamType StreamType, enable bool) { - c.mu.Lock() - defer c.mu.Unlock() - - if enable == c.streamTypes[streamType] { - return - } - - if enable { - if c.streamTypes == nil { - c.streamTypes = make(map[StreamType]bool) - } - c.streamTypes[streamType] = true - statsMcuPublisherStreamTypesCurrent.WithLabelValues(string(streamType)).Inc() - statsMcuSubscriberStreamTypesCurrent.WithLabelValues(string(streamType)).Add(float64(len(c.subscribers))) - } else { - delete(c.streamTypes, streamType) - statsMcuPublisherStreamTypesCurrent.WithLabelValues(string(streamType)).Dec() - statsMcuSubscriberStreamTypesCurrent.WithLabelValues(string(streamType)).Sub(float64(len(c.subscribers))) - } -} - -func (c *publisherStatsCounter) AddSubscriber(id string) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.subscribers[id] { - return - } - - if c.subscribers == nil { - c.subscribers = make(map[string]bool) - } - c.subscribers[id] = true - for streamType := range c.streamTypes { - statsMcuSubscriberStreamTypesCurrent.WithLabelValues(string(streamType)).Inc() - } -} - -func (c *publisherStatsCounter) RemoveSubscriber(id string) { - c.mu.Lock() - defer c.mu.Unlock() - - if !c.subscribers[id] { - return - } - - delete(c.subscribers, id) - for streamType := range c.streamTypes { - statsMcuSubscriberStreamTypesCurrent.WithLabelValues(string(streamType)).Dec() - } -} diff --git a/publisher_stats_counter_test.go b/publisher_stats_counter_test.go deleted file mode 100644 index 975089b..0000000 --- a/publisher_stats_counter_test.go +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2021 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "testing" -) - -func TestPublisherStatsCounter(t *testing.T) { - RegisterJanusMcuStats() - - var c publisherStatsCounter - - c.Reset() - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 0) - c.EnableStream("audio", false) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 0) - c.EnableStream("audio", true) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 1) - c.EnableStream("audio", true) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 1) - c.EnableStream("video", true) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - c.EnableStream("audio", false) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 0) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - c.EnableStream("audio", false) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 0) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - - c.AddSubscriber("1") - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 0) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("audio"), 0) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("video"), 1) - c.EnableStream("audio", true) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("video"), 1) - c.AddSubscriber("1") - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("video"), 1) - - c.AddSubscriber("2") - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("audio"), 2) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("video"), 2) - - c.RemoveSubscriber("3") - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("audio"), 2) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("video"), 2) - - c.RemoveSubscriber("1") - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("video"), 1) - - c.AddSubscriber("1") - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("audio"), 2) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("video"), 2) - - c.EnableStream("audio", false) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 0) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("audio"), 0) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("video"), 2) - - c.EnableStream("audio", true) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 1) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 1) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("audio"), 2) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("video"), 2) - - c.EnableStream("audio", false) - c.EnableStream("video", false) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("audio"), 0) - checkStatsValue(t, statsMcuPublisherStreamTypesCurrent.WithLabelValues("video"), 0) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("audio"), 0) - checkStatsValue(t, statsMcuSubscriberStreamTypesCurrent.WithLabelValues("video"), 0) - - collectAndLint(t, commonMcuStats...) -} diff --git a/room_test.go b/room_test.go deleted file mode 100644 index 2d170ea..0000000 --- a/room_test.go +++ /dev/null @@ -1,483 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRoom_InCall(t *testing.T) { - type Testcase struct { - Value interface{} - InCall bool - Valid bool - } - tests := []Testcase{ - {nil, false, false}, - {"a", false, false}, - {true, true, true}, - {false, false, true}, - {0, false, true}, - {FlagDisconnected, false, true}, - {1, true, true}, - {FlagInCall, true, true}, - {2, false, true}, - {FlagWithAudio, false, true}, - {3, true, true}, - {FlagInCall | FlagWithAudio, true, true}, - {4, false, true}, - {FlagWithVideo, false, true}, - {5, true, true}, - {FlagInCall | FlagWithVideo, true, true}, - {1.1, true, true}, - {json.Number("1"), true, true}, - {json.Number("1.1"), false, false}, - } - for _, test := range tests { - inCall, ok := IsInCall(test.Value) - if test.Valid { - assert.True(t, ok, "%+v should be valid", test.Value) - } else { - assert.False(t, ok, "%+v should not be valid", test.Value) - } - assert.EqualValues(t, test.InCall, inCall, "conversion failed for %+v", test.Value) - } -} - -func TestRoom_Update(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - hub, _, router, server := CreateHubForTest(t) - - config, err := getTestConfig(server) - require.NoError(err) - b, err := NewBackendServer(config, hub, "no-version") - require.NoError(err) - require.NoError(b.Start(router)) - - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - hello, err := client.RunUntilHello(ctx) - require.NoError(err) - - // Join room by id. - roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - // We will receive a "joined" event. - assert.NoError(client.RunUntilJoined(ctx, hello.Hello)) - - // Simulate backend request from Nextcloud to update the room. - roomProperties := json.RawMessage("{\"foo\":\"bar\"}") - msg := &BackendServerRoomRequest{ - Type: "update", - Update: &BackendRoomUpdateRequest{ - UserIds: []string{ - testDefaultUserId, - }, - Properties: roomProperties, - }, - } - - data, err := json.Marshal(msg) - require.NoError(err) - res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) - require.NoError(err) - defer res.Body.Close() - body, err := io.ReadAll(res.Body) - assert.NoError(err) - assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - - // The client receives a roomlist update and a changed room event. The - // ordering is not defined because messages are sent by asynchronous event - // handlers. - message1, err := client.RunUntilMessage(ctx) - assert.NoError(err) - message2, err := client.RunUntilMessage(ctx) - assert.NoError(err) - - if msg, err := checkMessageRoomlistUpdate(message1); err != nil { - assert.NoError(checkMessageRoomId(message1, roomId)) - if msg, err := checkMessageRoomlistUpdate(message2); assert.NoError(err) { - assert.Equal(roomId, msg.RoomId) - assert.Equal(string(roomProperties), string(msg.Properties)) - } - } else { - assert.Equal(roomId, msg.RoomId) - assert.Equal(string(roomProperties), string(msg.Properties)) - assert.NoError(checkMessageRoomId(message2, roomId)) - } - - // Allow up to 100 milliseconds for asynchronous event processing. - ctx2, cancel2 := context.WithTimeout(ctx, 100*time.Millisecond) - defer cancel2() - -loop: - for { - select { - case <-ctx2.Done(): - break loop - default: - // The internal room has been updated with the new properties. - if room := hub.getRoom(roomId); room == nil { - err = fmt.Errorf("Room %s not found in hub", roomId) - } else if len(room.Properties()) == 0 || !bytes.Equal(room.Properties(), roomProperties) { - err = fmt.Errorf("Expected room properties %s, got %+v", string(roomProperties), room.Properties()) - } else { - err = nil - } - } - if err == nil { - break - } - - time.Sleep(time.Millisecond) - } - - assert.NoError(err) -} - -func TestRoom_Delete(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - hub, _, router, server := CreateHubForTest(t) - - config, err := getTestConfig(server) - require.NoError(err) - b, err := NewBackendServer(config, hub, "no-version") - require.NoError(err) - require.NoError(b.Start(router)) - - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - hello, err := client.RunUntilHello(ctx) - require.NoError(err) - - // Join room by id. - roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - // We will receive a "joined" event. - assert.NoError(client.RunUntilJoined(ctx, hello.Hello)) - - // Simulate backend request from Nextcloud to update the room. - msg := &BackendServerRoomRequest{ - Type: "delete", - Delete: &BackendRoomDeleteRequest{ - UserIds: []string{ - testDefaultUserId, - }, - }, - } - - data, err := json.Marshal(msg) - require.NoError(err) - res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) - require.NoError(err) - defer res.Body.Close() - body, err := io.ReadAll(res.Body) - assert.NoError(err) - assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - - // The client is no longer invited to the room and leaves it. The ordering - // of messages is not defined as they get published through events and handled - // by asynchronous channels. - message1, err := client.RunUntilMessage(ctx) - assert.NoError(err) - - if err := checkMessageType(message1, "event"); err != nil { - // Ordering should be "leave room", "disinvited". - assert.NoError(checkMessageRoomId(message1, "")) - if message2, err := client.RunUntilMessage(ctx); assert.NoError(err) { - _, err := checkMessageRoomlistDisinvite(message2) - assert.NoError(err) - } - } else { - // Ordering should be "disinvited", "leave room". - _, err := checkMessageRoomlistDisinvite(message1) - assert.NoError(err) - message2, err := client.RunUntilMessage(ctx) - if err != nil { - // The connection should get closed after the "disinvited". - if websocket.IsUnexpectedCloseError(err, - websocket.CloseNormalClosure, - websocket.CloseGoingAway, - websocket.CloseNoStatusReceived) { - assert.NoError(err) - } - } else { - assert.NoError(checkMessageRoomId(message2, "")) - } - } - - // Allow up to 100 milliseconds for asynchronous event processing. - ctx2, cancel2 := context.WithTimeout(ctx, 100*time.Millisecond) - defer cancel2() - -loop: - for { - select { - case <-ctx2.Done(): - break loop - default: - // The internal room has been updated with the new properties. - hub.ru.Lock() - _, found := hub.rooms[roomId] - hub.ru.Unlock() - - if found { - err = fmt.Errorf("Room %s still found in hub", roomId) - } else { - err = nil - } - } - if err == nil { - break - } - - time.Sleep(time.Millisecond) - } - - assert.NoError(err) -} - -func TestRoom_RoomJoinFeatures(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - hub, _, router, server := CreateHubForTest(t) - - config, err := getTestConfig(server) - require.NoError(err) - b, err := NewBackendServer(config, hub, "no-version") - require.NoError(err) - require.NoError(b.Start(router)) - - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - features := []string{"one", "two", "three"} - require.NoError(client.SendHelloClientWithFeatures(testDefaultUserId, features)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - hello, err := client.RunUntilHello(ctx) - require.NoError(err) - - // Join room by id. - roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { - if assert.NoError(client.checkMessageJoinedSession(message, hello.Hello.SessionId, testDefaultUserId)) { - assert.Equal(roomId+"-"+hello.Hello.SessionId, message.Event.Join[0].RoomSessionId) - assert.Equal(features, message.Event.Join[0].Features) - } - } -} - -func TestRoom_RoomSessionData(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - hub, _, router, server := CreateHubForTest(t) - - config, err := getTestConfig(server) - require.NoError(err) - b, err := NewBackendServer(config, hub, "no-version") - require.NoError(err) - require.NoError(b.Start(router)) - - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(authAnonymousUserId)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - hello, err := client.RunUntilHello(ctx) - require.NoError(err) - - // Join room by id. - roomId := "test-room-with-sessiondata" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - // We will receive a "joined" event with the userid from the room session data. - expected := "userid-from-sessiondata" - if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { - if assert.NoError(client.checkMessageJoinedSession(message, hello.Hello.SessionId, expected)) { - assert.Equal(roomId+"-"+hello.Hello.SessionId, message.Event.Join[0].RoomSessionId) - } - } - - session := hub.GetSessionByPublicId(hello.Hello.SessionId) - require.NotNil(session, "Could not find session %s", hello.Hello.SessionId) - assert.Equal(expected, session.UserId()) - - room := hub.getRoom(roomId) - assert.NotNil(room, "Room not found") - - entries, wg := room.publishActiveSessions() - assert.Equal(1, entries) - wg.Wait() -} - -func TestRoom_InCallAll(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - hub, _, router, server := CreateHubForTest(t) - - config, err := getTestConfig(server) - require.NoError(err) - b, err := NewBackendServer(config, hub, "no-version") - require.NoError(err) - require.NoError(b.Start(router)) - - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId + "1")) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - - require.NoError(client2.SendHello(testDefaultUserId + "2")) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - - // Join room by id. - roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) - - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) - - assert.NoError(client1.RunUntilJoined(ctx, hello2.Hello)) - - // Simulate backend request from Nextcloud to update the "inCall" flag of all participants. - msg1 := &BackendServerRoomRequest{ - Type: "incall", - InCall: &BackendRoomInCallRequest{ - All: true, - InCall: json.RawMessage(strconv.FormatInt(FlagInCall, 10)), - }, - } - - data1, err := json.Marshal(msg1) - require.NoError(err) - res1, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data1) - require.NoError(err) - defer res1.Body.Close() - body1, err := io.ReadAll(res1.Body) - assert.NoError(err) - assert.Equal(http.StatusOK, res1.StatusCode, "Expected successful request, got %s", string(body1)) - - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(checkMessageInCallAll(msg, roomId, FlagInCall)) - } - - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(checkMessageInCallAll(msg, roomId, FlagInCall)) - } - - // Simulate backend request from Nextcloud to update the "inCall" flag of all participants. - msg2 := &BackendServerRoomRequest{ - Type: "incall", - InCall: &BackendRoomInCallRequest{ - All: true, - InCall: json.RawMessage(strconv.FormatInt(0, 10)), - }, - } - - data2, err := json.Marshal(msg2) - require.NoError(err) - res2, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data2) - require.NoError(err) - defer res2.Body.Close() - body2, err := io.ReadAll(res2.Body) - assert.NoError(err) - assert.Equal(http.StatusOK, res2.StatusCode, "Expected successful request, got %s", string(body2)) - - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(checkMessageInCallAll(msg, roomId, 0)) - } - - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(checkMessageInCallAll(msg, roomId, 0)) - } -} diff --git a/scripts/get-version.sh b/scripts/get-version.sh index e1fc562..28074ab 100755 --- a/scripts/get-version.sh +++ b/scripts/get-version.sh @@ -26,4 +26,4 @@ if [ -z "$VERSION" ]; then VERSION=unknown fi -echo $VERSION +echo "$VERSION" diff --git a/scripts/get_continent_map.py b/scripts/get_continent_map.py index 61ef9c4..d86cf3d 100755 --- a/scripts/get_continent_map.py +++ b/scripts/get_continent_map.py @@ -87,13 +87,13 @@ def generate_map(filename): continents.setdefault(country, []).append(continent) out = StringIO() - out.write('package signaling\n') + out.write('package geoip\n') out.write('\n') out.write('// This file has been automatically generated, do not modify.\n') out.write('// Source: %s\n' % (URL)) out.write('\n') out.write('var (\n') - out.write('\tContinentMap = map[string][]string{\n') + out.write('\tContinentMap = map[Country][]Continent{\n') for country, continents in sorted(continents.items()): value = [] for continent in continents: diff --git a/scripts/prepare-changelog.py b/scripts/prepare-changelog.py new file mode 100755 index 0000000..7fa3011 --- /dev/null +++ b/scripts/prepare-changelog.py @@ -0,0 +1,77 @@ +#!/usr/bin/python3 + +import git +import os.path +import re + +ROOT = os.path.join(os.path.dirname(__file__), '..') + +FIND_PR = re.compile(r'Merge pull request #(\d+) from').findall + +ENTRY = """- %(title)s + [#%(pr)s](https://github.com/strukturag/nextcloud-spreed-signaling/pull/%(pr)s)""" + +SIMPLE_ENTRY = """- %(title)s""" + +def main(): + repo = git.Repo(ROOT) + latest = repo.tags[-1] + print('Generating changelog since %s (commit %s)' % (latest, latest.commit)) + + entries = [] + dependencies = [] + ignore = set() + for commit in repo.iter_commits('%s..HEAD' % (latest.commit, )): + if len(commit.parents) > 1: + # Merge commit. + ignore.add(commit.parents[-1]) + entries.append(commit) + else: + try: + ignore.remove(commit) + except KeyError: + # Direct commit. + entries.append(commit) + else: + # Commit is part of a merge. + ignore.add(commit.parents[0]) + + # Sort commits from old to new. + for commit in reversed(entries): + lines = [x.strip() for x in commit.message.strip().split('\n')] + title = None + for line in lines: + if not line: + title = '' + elif title == '': + title = line + break + + if not title and len(lines) == 1: + title = lines[0] + assert line, (commit.message, ) + pr = FIND_PR(commit.summary) + assert len(pr) <= 1, (commit.summary, ) + if len(pr) == 1: + entry = ENTRY % { + 'title': title, + 'pr': pr[0], + } + elif len(pr) == 0: + entry = SIMPLE_ENTRY % { + 'title': title, + } + + if title.startswith('Bump '): + dependencies.append(entry) + else: + print(entry) + + # Dependencies should be in a separate section at the end. + if dependencies: + print() + print('### Dependencies') + print('\n'.join(dependencies)) + +if __name__ == '__main__': + main() diff --git a/certificate_reloader.go b/security/certificate_reloader.go similarity index 70% rename from certificate_reloader.go rename to security/certificate_reloader.go index 3e23c96..fa14cdf 100644 --- a/certificate_reloader.go +++ b/security/certificate_reloader.go @@ -19,45 +19,56 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package security import ( "crypto/tls" "crypto/x509" "fmt" - "log" "os" "sync/atomic" + "testing" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/security/internal" ) type CertificateReloader struct { + logger log.Logger + certFile string - certWatcher *FileWatcher + certWatcher *internal.FileWatcher keyFile string - keyWatcher *FileWatcher + keyWatcher *internal.FileWatcher certificate atomic.Pointer[tls.Certificate] reloadCounter atomic.Uint64 } -func NewCertificateReloader(certFile string, keyFile string) (*CertificateReloader, error) { +func NewCertificateReloader(logger log.Logger, certFile string, keyFile string) (*CertificateReloader, error) { pair, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { return nil, fmt.Errorf("could not load certificate / key: %w", err) } + deduplicate := internal.DefaultDeduplicateWatchEvents + if testing.Testing() { + deduplicate = 0 + } + reloader := &CertificateReloader{ + logger: logger, certFile: certFile, keyFile: keyFile, } reloader.certificate.Store(&pair) - reloader.certWatcher, err = NewFileWatcher(certFile, reloader.reload) + reloader.certWatcher, err = internal.NewFileWatcher(reloader.logger, certFile, reloader.reload, deduplicate) if err != nil { return nil, err } - reloader.keyWatcher, err = NewFileWatcher(keyFile, reloader.reload) + reloader.keyWatcher, err = internal.NewFileWatcher(reloader.logger, keyFile, reloader.reload, deduplicate) if err != nil { reloader.certWatcher.Close() // nolint return nil, err @@ -72,10 +83,10 @@ func (r *CertificateReloader) Close() { } func (r *CertificateReloader) reload(filename string) { - log.Printf("reloading certificate from %s with %s", r.certFile, r.keyFile) + r.logger.Printf("reloading certificate from %s with %s", r.certFile, r.keyFile) pair, err := tls.LoadX509KeyPair(r.certFile, r.keyFile) if err != nil { - log.Printf("could not load certificate / key: %s", err) + r.logger.Printf("could not load certificate / key: %s", err) return } @@ -100,8 +111,10 @@ func (r *CertificateReloader) GetReloadCounter() uint64 { } type CertPoolReloader struct { + logger log.Logger + certFile string - certWatcher *FileWatcher + certWatcher *internal.FileWatcher pool atomic.Pointer[x509.CertPool] @@ -122,17 +135,23 @@ func loadCertPool(filename string) (*x509.CertPool, error) { return pool, nil } -func NewCertPoolReloader(certFile string) (*CertPoolReloader, error) { +func NewCertPoolReloader(logger log.Logger, certFile string) (*CertPoolReloader, error) { pool, err := loadCertPool(certFile) if err != nil { return nil, err } + deduplicate := internal.DefaultDeduplicateWatchEvents + if testing.Testing() { + deduplicate = 0 + } + reloader := &CertPoolReloader{ + logger: logger, certFile: certFile, } reloader.pool.Store(pool) - reloader.certWatcher, err = NewFileWatcher(certFile, reloader.reload) + reloader.certWatcher, err = internal.NewFileWatcher(reloader.logger, certFile, reloader.reload, deduplicate) if err != nil { return nil, err } @@ -145,10 +164,10 @@ func (r *CertPoolReloader) Close() { } func (r *CertPoolReloader) reload(filename string) { - log.Printf("reloading certificate pool from %s", r.certFile) + r.logger.Printf("reloading certificate pool from %s", r.certFile) pool, err := loadCertPool(r.certFile) if err != nil { - log.Printf("could not load certificate pool: %s", err) + r.logger.Printf("could not load certificate pool: %s", err) return } diff --git a/security/certificate_reloader_test.go b/security/certificate_reloader_test.go new file mode 100644 index 0000000..b74d780 --- /dev/null +++ b/security/certificate_reloader_test.go @@ -0,0 +1,154 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package security + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "path" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" +) + +type withReloadCounter interface { + GetReloadCounter() uint64 +} + +func waitForReload(ctx context.Context, t *testing.T, r withReloadCounter, expected uint64) bool { + t.Helper() + + for r.GetReloadCounter() < expected { + if !assert.NoError(t, ctx.Err()) { + return false + } + + time.Sleep(time.Millisecond) + } + return true +} + +func TestCertificateReloader(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + + org1 := "Testing certificate" + cert1 := internal.GenerateSelfSignedCertificateForTesting(t, org1, key) + + tmpdir := t.TempDir() + certFile := path.Join(tmpdir, "cert.pem") + privkeyFile := path.Join(tmpdir, "privkey.pem") + pubkeyFile := path.Join(tmpdir, "pubkey.pem") + + require.NoError(internal.WritePrivateKey(key, privkeyFile)) + require.NoError(internal.WritePublicKey(&key.PublicKey, pubkeyFile)) + require.NoError(internal.WriteCertificate(cert1, certFile)) + + logger := logtest.NewLoggerForTest(t) + reloader, err := NewCertificateReloader(logger, certFile, privkeyFile) + require.NoError(err) + + defer reloader.Close() + + if cert, err := reloader.GetCertificate(nil); assert.NoError(err) { + assert.True(cert1.Equal(cert.Leaf)) + assert.True(key.Equal(cert.PrivateKey)) + } + if cert, err := reloader.GetClientCertificate(nil); assert.NoError(err) { + assert.True(cert1.Equal(cert.Leaf)) + assert.True(key.Equal(cert.PrivateKey)) + } + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + + org2 := "Updated certificate" + cert2 := internal.GenerateSelfSignedCertificateForTesting(t, org2, key) + internal.ReplaceCertificate(t, certFile, cert2) + + waitForReload(ctx, t, reloader, 1) + + if cert, err := reloader.GetCertificate(nil); assert.NoError(err) { + assert.True(cert2.Equal(cert.Leaf)) + assert.True(key.Equal(cert.PrivateKey)) + } + if cert, err := reloader.GetClientCertificate(nil); assert.NoError(err) { + assert.True(cert2.Equal(cert.Leaf)) + assert.True(key.Equal(cert.PrivateKey)) + } +} + +func TestCertPoolReloader(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + + org1 := "Testing certificate" + cert1 := internal.GenerateSelfSignedCertificateForTesting(t, org1, key) + + tmpdir := t.TempDir() + certFile := path.Join(tmpdir, "cert.pem") + privkeyFile := path.Join(tmpdir, "privkey.pem") + pubkeyFile := path.Join(tmpdir, "pubkey.pem") + + require.NoError(internal.WritePrivateKey(key, privkeyFile)) + require.NoError(internal.WritePublicKey(&key.PublicKey, pubkeyFile)) + require.NoError(internal.WriteCertificate(cert1, certFile)) + + logger := logtest.NewLoggerForTest(t) + reloader, err := NewCertPoolReloader(logger, certFile) + require.NoError(err) + + defer reloader.Close() + + pool1 := reloader.GetCertPool() + assert.NotNil(pool1) + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + + org2 := "Updated certificate" + cert2 := internal.GenerateSelfSignedCertificateForTesting(t, org2, key) + internal.ReplaceCertificate(t, certFile, cert2) + + waitForReload(ctx, t, reloader, 1) + + pool2 := reloader.GetCertPool() + assert.NotNil(pool2) + + assert.False(pool1.Equal(pool2)) +} diff --git a/file_watcher.go b/security/internal/file_watcher.go similarity index 64% rename from file_watcher.go rename to security/internal/file_watcher.go index 6d3a923..e1d48db 100644 --- a/file_watcher.go +++ b/security/internal/file_watcher.go @@ -19,63 +19,47 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package internal import ( "context" "errors" - "log" "os" "path" "path/filepath" "strings" "sync" - "sync/atomic" "time" "github.com/fsnotify/fsnotify" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) const ( - defaultDeduplicateWatchEvents = 100 * time.Millisecond + DefaultDeduplicateWatchEvents = 100 * time.Millisecond ) -var ( - deduplicateWatchEvents atomic.Int64 -) - -func init() { - deduplicateWatchEvents.Store(int64(defaultDeduplicateWatchEvents)) -} - type FileWatcherCallback func(filename string) type FileWatcher struct { - filename string - target string - callback FileWatcherCallback + logger log.Logger + filename string + target string + callback FileWatcherCallback + deduplicate time.Duration watcher *fsnotify.Watcher closeCtx context.Context closeFunc context.CancelFunc } -func NewFileWatcher(filename string, callback FileWatcherCallback) (*FileWatcher, error) { - realFilename, err := filepath.EvalSymlinks(filename) - if err != nil { - return nil, err - } - +func NewFileWatcher(logger log.Logger, filename string, callback FileWatcherCallback, deduplicate time.Duration) (*FileWatcher, error) { watcher, err := fsnotify.NewWatcher() if err != nil { return nil, err } - if err := watcher.Add(realFilename); err != nil { - watcher.Close() // nolint - return nil, err - } - if err := watcher.Add(path.Dir(filename)); err != nil { watcher.Close() // nolint return nil, err @@ -84,18 +68,38 @@ func NewFileWatcher(filename string, callback FileWatcherCallback) (*FileWatcher closeCtx, closeFunc := context.WithCancel(context.Background()) w := &FileWatcher{ - filename: filename, - target: realFilename, - callback: callback, - watcher: watcher, + logger: logger, + filename: filename, + callback: callback, + deduplicate: deduplicate, + watcher: watcher, closeCtx: closeCtx, closeFunc: closeFunc, } + if err := w.updateWatcher(); err != nil { + watcher.Close() // nolint + return nil, err + } + go w.run() return w, nil } +func (f *FileWatcher) updateWatcher() error { + realFilename, err := filepath.EvalSymlinks(f.filename) + if err != nil { + return err + } + + if err := f.watcher.Add(realFilename); err != nil { + return err + } + + f.target = realFilename + return nil +} + func (f *FileWatcher) Close() error { f.closeFunc() return f.watcher.Close() @@ -106,42 +110,56 @@ func (f *FileWatcher) run() { timers := make(map[string]*time.Timer) triggerEvent := func(event fsnotify.Event) { - deduplicate := time.Duration(deduplicateWatchEvents.Load()) - if deduplicate <= 0 { + if f.deduplicate <= 0 { f.callback(f.filename) return } + filename := path.Clean(event.Name) + // Use timer to deduplicate multiple events for the same file. mu.Lock() - t, found := timers[event.Name] + t, found := timers[filename] mu.Unlock() if !found { - t = time.AfterFunc(deduplicate, func() { + t = time.AfterFunc(f.deduplicate, func() { f.callback(f.filename) mu.Lock() - delete(timers, event.Name) + delete(timers, filename) mu.Unlock() }) mu.Lock() - timers[event.Name] = t + timers[filename] = t mu.Unlock() } else { - t.Reset(deduplicate) + t.Reset(f.deduplicate) } } for { select { case event := <-f.watcher.Events: - if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) && !event.Has(fsnotify.Rename) { + if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) && !event.Has(fsnotify.Rename) && !event.Has(fsnotify.Remove) { + continue + } + + if event.Has(fsnotify.Remove) { + // Watched target has been deleted, assume it was symlinked and try to watch new target. + if event.Name != f.target { + continue + } + + triggerEvent(event) + if err := f.updateWatcher(); err != nil { + f.logger.Printf("Error updating watcher after %s is deleted: %s", event.Name, err) + } continue } if stat, err := os.Lstat(event.Name); err != nil { if !errors.Is(err, os.ErrNotExist) { - log.Printf("Could not lstat %s: %s", event.Name, err) + f.logger.Printf("Could not lstat %s: %s", event.Name, err) } } else if stat.Mode()&os.ModeSymlink != 0 { target, err := filepath.EvalSymlinks(event.Name) @@ -160,7 +178,7 @@ func (f *FileWatcher) run() { return } - log.Printf("Error watching %s: %s", f.filename, err) + f.logger.Printf("Error watching %s: %s", f.filename, err) case <-f.closeCtx.Done(): return } diff --git a/file_watcher_test.go b/security/internal/file_watcher_test.go similarity index 54% rename from file_watcher_test.go rename to security/internal/file_watcher_test.go index 14dec15..7e515c7 100644 --- a/file_watcher_test.go +++ b/security/internal/file_watcher_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package internal import ( "context" @@ -29,34 +29,83 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" ) var ( - testWatcherNoEventTimeout = 2 * defaultDeduplicateWatchEvents + testWatcherNoEventTimeout = 2 * DefaultDeduplicateWatchEvents ) func TestFileWatcher_NotExist(t *testing.T) { + t.Parallel() assert := assert.New(t) tmpdir := t.TempDir() - if w, err := NewFileWatcher(path.Join(tmpdir, "test.txt"), func(filename string) {}); !assert.ErrorIs(err, os.ErrNotExist) { + logger := logtest.NewLoggerForTest(t) + if w, err := NewFileWatcher(logger, path.Join(tmpdir, "test.txt"), func(filename string) {}, DefaultDeduplicateWatchEvents); !assert.ErrorIs(err, os.ErrNotExist) { if w != nil { assert.NoError(w.Close()) } } } -func TestFileWatcher_File(t *testing.T) { - ensureNoGoroutinesLeak(t, func(t *testing.T) { +func TestFileWatcher_File(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { require := require.New(t) assert := assert.New(t) tmpdir := t.TempDir() filename := path.Join(tmpdir, "test.txt") require.NoError(os.WriteFile(filename, []byte("Hello world!"), 0644)) + logger := logtest.NewLoggerForTest(t) modified := make(chan struct{}) - w, err := NewFileWatcher(filename, func(filename string) { + w, err := NewFileWatcher(logger, filename, func(filename string) { modified <- struct{}{} - }) + }, DefaultDeduplicateWatchEvents) + require.NoError(err) + defer w.Close() + + require.NoError(os.WriteFile(filename, []byte("Updated"), 0644)) + <-modified + + ctxTimeout, cancel := context.WithTimeout(context.Background(), testWatcherNoEventTimeout) + defer cancel() + + select { + case <-modified: + assert.Fail("should not have received another event") + case <-ctxTimeout.Done(): + } + + require.NoError(os.WriteFile(filename, []byte("Updated"), 0644)) + <-modified + + ctxTimeout, cancel = context.WithTimeout(context.Background(), testWatcherNoEventTimeout) + defer cancel() + + select { + case <-modified: + assert.Fail("should not have received another event") + case <-ctxTimeout.Done(): + } + }) +} + +func TestFileWatcher_CurrentDir(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + tmpdir := t.TempDir() + t.Chdir(tmpdir) + filename := path.Join(tmpdir, "test.txt") + require.NoError(os.WriteFile(filename, []byte("Hello world!"), 0644)) + + logger := logtest.NewLoggerForTest(t) + modified := make(chan struct{}) + w, err := NewFileWatcher(logger, "./"+path.Base(filename), func(filename string) { + modified <- struct{}{} + }, DefaultDeduplicateWatchEvents) require.NoError(err) defer w.Close() @@ -87,16 +136,18 @@ func TestFileWatcher_File(t *testing.T) { } func TestFileWatcher_Rename(t *testing.T) { + t.Parallel() require := require.New(t) assert := assert.New(t) tmpdir := t.TempDir() filename := path.Join(tmpdir, "test.txt") require.NoError(os.WriteFile(filename, []byte("Hello world!"), 0644)) + logger := logtest.NewLoggerForTest(t) modified := make(chan struct{}) - w, err := NewFileWatcher(filename, func(filename string) { + w, err := NewFileWatcher(logger, filename, func(filename string) { modified <- struct{}{} - }) + }, DefaultDeduplicateWatchEvents) require.NoError(err) defer w.Close() @@ -126,6 +177,7 @@ func TestFileWatcher_Rename(t *testing.T) { } func TestFileWatcher_Symlink(t *testing.T) { + t.Parallel() require := require.New(t) assert := assert.New(t) tmpdir := t.TempDir() @@ -135,10 +187,11 @@ func TestFileWatcher_Symlink(t *testing.T) { filename := path.Join(tmpdir, "symlink.txt") require.NoError(os.Symlink(sourceFilename, filename)) + logger := logtest.NewLoggerForTest(t) modified := make(chan struct{}) - w, err := NewFileWatcher(filename, func(filename string) { + w, err := NewFileWatcher(logger, filename, func(filename string) { modified <- struct{}{} - }) + }, DefaultDeduplicateWatchEvents) require.NoError(err) defer w.Close() @@ -156,6 +209,7 @@ func TestFileWatcher_Symlink(t *testing.T) { } func TestFileWatcher_ChangeSymlinkTarget(t *testing.T) { + t.Parallel() require := require.New(t) assert := assert.New(t) tmpdir := t.TempDir() @@ -168,10 +222,11 @@ func TestFileWatcher_ChangeSymlinkTarget(t *testing.T) { filename := path.Join(tmpdir, "symlink.txt") require.NoError(os.Symlink(sourceFilename1, filename)) + logger := logtest.NewLoggerForTest(t) modified := make(chan struct{}) - w, err := NewFileWatcher(filename, func(filename string) { + w, err := NewFileWatcher(logger, filename, func(filename string) { modified <- struct{}{} - }) + }, DefaultDeduplicateWatchEvents) require.NoError(err) defer w.Close() @@ -191,6 +246,7 @@ func TestFileWatcher_ChangeSymlinkTarget(t *testing.T) { } func TestFileWatcher_OtherSymlink(t *testing.T) { + t.Parallel() require := require.New(t) assert := assert.New(t) tmpdir := t.TempDir() @@ -203,10 +259,11 @@ func TestFileWatcher_OtherSymlink(t *testing.T) { filename := path.Join(tmpdir, "symlink.txt") require.NoError(os.Symlink(sourceFilename1, filename)) + logger := logtest.NewLoggerForTest(t) modified := make(chan struct{}) - w, err := NewFileWatcher(filename, func(filename string) { + w, err := NewFileWatcher(logger, filename, func(filename string) { modified <- struct{}{} - }) + }, DefaultDeduplicateWatchEvents) require.NoError(err) defer w.Close() @@ -223,6 +280,7 @@ func TestFileWatcher_OtherSymlink(t *testing.T) { } func TestFileWatcher_RenameSymlinkTarget(t *testing.T) { + t.Parallel() require := require.New(t) assert := assert.New(t) tmpdir := t.TempDir() @@ -232,10 +290,11 @@ func TestFileWatcher_RenameSymlinkTarget(t *testing.T) { filename := path.Join(tmpdir, "test.txt") require.NoError(os.Symlink(sourceFilename1, filename)) + logger := logtest.NewLoggerForTest(t) modified := make(chan struct{}) - w, err := NewFileWatcher(filename, func(filename string) { + w, err := NewFileWatcher(logger, filename, func(filename string) { modified <- struct{}{} - }) + }, DefaultDeduplicateWatchEvents) require.NoError(err) defer w.Close() @@ -263,3 +322,92 @@ func TestFileWatcher_RenameSymlinkTarget(t *testing.T) { case <-ctxTimeout.Done(): } } + +func TestFileWatcher_UpdateSymlinkFolder(t *testing.T) { + t.Parallel() + // This mimics what k8s is doing with configmaps / secrets. + require := require.New(t) + assert := assert.New(t) + tmpdir := t.TempDir() + + // File is in a versioned folder. + version1Path := path.Join(tmpdir, "version1") + require.NoError(os.Mkdir(version1Path, 0755)) + sourceFilename1 := path.Join(version1Path, "test.txt") + require.NoError(os.WriteFile(sourceFilename1, []byte("Hello world!"), 0644)) + + // Versioned folder is symlinked to a generic "data" folder. + dataPath := path.Join(tmpdir, "data") + require.NoError(os.Symlink("version1", dataPath)) + + // File in root is symlinked from generic "data" folder. + filename := path.Join(tmpdir, "test.txt") + require.NoError(os.Symlink("data/test.txt", filename)) + + logger := logtest.NewLoggerForTest(t) + modified := make(chan struct{}) + w, err := NewFileWatcher(logger, filename, func(filename string) { + modified <- struct{}{} + }, DefaultDeduplicateWatchEvents) + require.NoError(err) + defer w.Close() + + // New file is created in a new versioned subfolder. + version2Path := path.Join(tmpdir, "version2") + require.NoError(os.Mkdir(version2Path, 0755)) + + sourceFilename2 := path.Join(version2Path, "test.txt") + require.NoError(os.WriteFile(sourceFilename2, []byte("Updated"), 0644)) + + ctxTimeout, cancel := context.WithTimeout(context.Background(), testWatcherNoEventTimeout) + defer cancel() + + select { + case <-modified: + assert.Fail("should not have received another event") + case <-ctxTimeout.Done(): + } + + // Create temporary symlink to new versioned subfolder... + require.NoError(os.Symlink("version2", dataPath+".tmp")) + // ...atomically update generic "data" symlink... + require.NoError(os.Rename(dataPath+".tmp", dataPath)) + // ...and old versioned subfolder is removed (this will trigger the event). + require.NoError(os.RemoveAll(version1Path)) + + <-modified + + // Another new file is created in a new versioned subfolder. + version3Path := path.Join(tmpdir, "version3") + require.NoError(os.Mkdir(version3Path, 0755)) + + sourceFilename3 := path.Join(version3Path, "test.txt") + require.NoError(os.WriteFile(sourceFilename3, []byte("Updated again"), 0644)) + + ctxTimeout, cancel = context.WithTimeout(context.Background(), testWatcherNoEventTimeout) + defer cancel() + + select { + case <-modified: + assert.Fail("should not have received another event") + case <-ctxTimeout.Done(): + } + + // Create temporary symlink to new versioned subfolder... + require.NoError(os.Symlink("version3", dataPath+".tmp")) + // ...atomically update generic "data" symlink... + require.NoError(os.Rename(dataPath+".tmp", dataPath)) + // ...and old versioned subfolder is removed (this will trigger the event). + require.NoError(os.RemoveAll(version2Path)) + + <-modified + + ctxTimeout, cancel = context.WithTimeout(context.Background(), testWatcherNoEventTimeout) + defer cancel() + + select { + case <-modified: + assert.Fail("should not have received another event") + case <-ctxTimeout.Done(): + } +} diff --git a/server.conf.in b/server.conf.in index 85630d5..2e0a7cf 100644 --- a/server.conf.in +++ b/server.conf.in @@ -25,7 +25,9 @@ certificate = /etc/nginx/ssl/server.crt key = /etc/nginx/ssl/server.key [app] -# Set to "true" to install pprof debug handlers. +# Set to "true" to install pprof debug handlers. Access will only be possible +# from IPs allowed through the "allowed_ips" option below. +# # See "https://golang.org/pkg/net/http/pprof/" for further information. debug = false @@ -84,7 +86,8 @@ internalsecret = the-shared-secret-for-internal-clients # For backend type "etcd": # Key prefix of backend entries. All keys below will be watched and assumed to # contain a JSON document with the following entries: -# - "url": Url of the Nextcloud instance. +# - "urls": List of urls of the Nextcloud instance. +# - "url": Url of the Nextcloud instance (deprecated). # - "secret": Shared secret for requests from and to the backend servers. # # Additional optional entries: @@ -93,8 +96,8 @@ internalsecret = the-shared-secret-for-internal-clients # - "sessionlimit": Number of sessions that are allowed to connect. # # Example: -# "/signaling/backend/one" -> {"url": "https://nextcloud.domain1.invalid", ...} -# "/signaling/backend/two" -> {"url": "https://domain2.invalid/nextcloud", ...} +# "/signaling/backend/one" -> {"urls": ["https://nextcloud.domain1.invalid"], ...} +# "/signaling/backend/two" -> {"urls": ["https://domain2.invalid/nextcloud"], ...} #backendprefix = /signaling/backend # Allow any hostname as backend endpoint. This is extremely insecure and should @@ -122,8 +125,8 @@ connectionsperhost = 8 # Backend configurations as defined in the "[backend]" section above. The # section names must match the ids used in "backends" above. #[backend-id] -# URL of the Nextcloud instance -#url = https://cloud.domain.invalid +# Comma-separated list of urls of the Nextcloud instance +#urls = https://cloud.domain.invalid # Shared secret for requests from and to the backend servers. Leave empty to use # the common shared secret from above. @@ -143,8 +146,8 @@ connectionsperhost = 8 #maxscreenbitrate = 2097152 #[another-backend] -# URL of the Nextcloud instance -#url = https://cloud.otherdomain.invalid +# Comma-separated list of urls of the Nextcloud instance +#urls = https://cloud.otherdomain.invalid # Shared secret for requests from and to the backend servers. Leave empty to use # the common shared secret from above. @@ -179,6 +182,13 @@ connectionsperhost = 8 # proxy server that is used. #maxscreenbitrate = 2097152 +# List of IP addresses / subnets that are allowed to be used by clients in +# candidates. The allowed list has preference over the blocked list below. +#allowedcandidates = 10.0.0.0/8 + +# List of IP addresses / subnets to filter from candidates received by clients. +#blockedcandidates = 1.2.3.0/24 + # For type "proxy": timeout in seconds for requests to the proxy server. #proxytimeout = 2 @@ -262,8 +272,9 @@ connectionsperhost = 8 #SA = NA [stats] -# Comma-separated list of IP addresses that are allowed to access the stats -# endpoint. Leave empty (or commented) to only allow access from "127.0.0.1". +# Comma-separated list of IP addresses that are allowed to access the debug, +# stats and metrics endpoints. +# Leave empty (or commented) to only allow access from localhost. #allowed_ips = [etcd] diff --git a/backend_server.go b/server/backend_server.go similarity index 53% rename from backend_server.go rename to server/backend_server.go index 016f0eb..67c549a 100644 --- a/backend_server.go +++ b/server/backend_server.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" @@ -31,12 +31,14 @@ import ( "errors" "fmt" "io" - "log" "net" "net/http" + "net/http/pprof" "net/url" "reflect" "regexp" + runtimepprof "runtime/pprof" + "slices" "strings" "sync" "sync/atomic" @@ -45,6 +47,16 @@ import ( "github.com/dlintw/goconf" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/container" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/pool" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) const ( @@ -52,15 +64,19 @@ const ( randomUsernameLength = 32 - sessionIdNotInMeeting = "0" + sessionIdNotInMeeting = api.RoomSessionId("0") + + startDialoutTimeout = 45 * time.Second ) type BackendServer struct { + logger log.Logger hub *Hub - events AsyncEvents + events events.AsyncEvents roomSessions RoomSessions version string + debug bool welcomeMessage string turnapikey string @@ -68,51 +84,47 @@ type BackendServer struct { turnvalid time.Duration turnservers []string - statsAllowedIps atomic.Pointer[AllowedIps] + statsAllowedIps atomic.Pointer[container.IPList] invalidSecret []byte + + buffers pool.BufferPool } -func NewBackendServer(config *goconf.ConfigFile, hub *Hub, version string) (*BackendServer, error) { - turnapikey, _ := config.GetString("turn", "apikey") - turnsecret, _ := config.GetString("turn", "secret") - turnservers, _ := config.GetString("turn", "servers") +func NewBackendServer(ctx context.Context, cfg *goconf.ConfigFile, hub *Hub, version string) (*BackendServer, error) { + logger := log.LoggerFromContext(ctx) + turnapikey, _ := config.GetStringOptionWithEnv(cfg, "turn", "apikey") + turnsecret, _ := config.GetStringOptionWithEnv(cfg, "turn", "secret") + turnservers, _ := cfg.GetString("turn", "servers") // TODO(jojo): Make the validity for TURN credentials configurable. turnvalid := 24 * time.Hour - var turnserverslist []string - for _, s := range strings.Split(turnservers, ",") { - s = strings.TrimSpace(s) - if s != "" { - turnserverslist = append(turnserverslist, s) - } - } - + turnserverslist := slices.Collect(internal.SplitEntries(turnservers, ",")) if len(turnserverslist) != 0 { if turnapikey == "" { - return nil, fmt.Errorf("need a TURN API key if TURN servers are configured") + return nil, errors.New("need a TURN API key if TURN servers are configured") } if turnsecret == "" { - return nil, fmt.Errorf("need a shared TURN secret if TURN servers are configured") + return nil, errors.New("need a shared TURN secret if TURN servers are configured") } - log.Printf("Using configured TURN API key") - log.Printf("Using configured shared TURN secret") + logger.Printf("Using configured TURN API key") + logger.Printf("Using configured shared TURN secret") for _, s := range turnserverslist { - log.Printf("Adding \"%s\" as TURN server", s) + logger.Printf("Adding \"%s\" as TURN server", s) } } - statsAllowed, _ := config.GetString("stats", "allowed_ips") - statsAllowedIps, err := ParseAllowedIps(statsAllowed) + statsAllowed, _ := cfg.GetString("stats", "allowed_ips") + statsAllowedIps, err := container.ParseIPList(statsAllowed) if err != nil { return nil, err } if !statsAllowedIps.Empty() { - log.Printf("Only allowing access to the stats endpoint from %s", statsAllowed) + logger.Printf("Only allowing access to the stats endpoint from %s", statsAllowed) } else { - log.Printf("No IPs configured for the stats endpoint, only allowing access from 127.0.0.1") - statsAllowedIps = DefaultAllowedIps() + statsAllowedIps = container.DefaultAllowedIPs() + logger.Printf("No IPs configured for the stats endpoint, only allowing access from %s", statsAllowedIps) } invalidSecret := make([]byte, 32) @@ -120,11 +132,15 @@ func NewBackendServer(config *goconf.ConfigFile, hub *Hub, version string) (*Bac return nil, err } + debug, _ := cfg.GetBool("app", "debug") + result := &BackendServer{ + logger: logger, hub: hub, events: hub.events, roomSessions: hub.roomSessions, version: version, + debug: debug, turnapikey: turnapikey, turnsecret: []byte(turnsecret), @@ -141,16 +157,16 @@ func NewBackendServer(config *goconf.ConfigFile, hub *Hub, version string) (*Bac func (b *BackendServer) Reload(config *goconf.ConfigFile) { statsAllowed, _ := config.GetString("stats", "allowed_ips") - if statsAllowedIps, err := ParseAllowedIps(statsAllowed); err == nil { + if statsAllowedIps, err := container.ParseIPList(statsAllowed); err == nil { if !statsAllowedIps.Empty() { - log.Printf("Only allowing access to the stats endpoint from %s", statsAllowed) + b.logger.Printf("Only allowing access to the stats endpoint from %s", statsAllowed) } else { - log.Printf("No IPs configured for the stats endpoint, only allowing access from 127.0.0.1") - statsAllowedIps = DefaultAllowedIps() + statsAllowedIps = container.DefaultAllowedIPs() + b.logger.Printf("No IPs configured for the stats endpoint, only allowing access from %s", statsAllowedIps) } b.statsAllowedIps.Store(statsAllowedIps) } else { - log.Printf("Error parsing allowed stats ips from \"%s\": %s", statsAllowedIps, err) + b.logger.Printf("Error parsing allowed stats ips from \"%s\": %s", statsAllowedIps, err) } } @@ -159,29 +175,45 @@ func (b *BackendServer) Start(r *mux.Router) error { "nextcloud-spreed-signaling": "Welcome", "version": b.version, } - welcomeMessage, err := json.Marshal(welcome) - if err != nil { - // Should never happen. - return err - } - + welcomeMessage, _ := json.Marshal(welcome) b.welcomeMessage = string(welcomeMessage) + "\n" + if b.debug { + b.logger.Println("Installing debug handlers in \"/debug/pprof\"") + s := r.PathPrefix("/debug/pprof").Subrouter() + s.HandleFunc("", b.setCommonHeaders(b.validateStatsRequest(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/debug/pprof/", http.StatusTemporaryRedirect) + }))) + s.HandleFunc("/", b.setCommonHeaders(b.validateStatsRequest(pprof.Index))) + s.HandleFunc("/cmdline", b.setCommonHeaders(b.validateStatsRequest(pprof.Cmdline))) + s.HandleFunc("/profile", b.setCommonHeaders(b.validateStatsRequest(pprof.Profile))) + s.HandleFunc("/symbol", b.setCommonHeaders(b.validateStatsRequest(pprof.Symbol))) + s.HandleFunc("/trace", b.setCommonHeaders(b.validateStatsRequest(pprof.Trace))) + for _, profile := range runtimepprof.Profiles() { + name := profile.Name() + handler := pprof.Handler(name) + s.HandleFunc("/"+name, b.setCommonHeaders(b.validateStatsRequest(func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(w, r) + }))) + } + } + s := r.PathPrefix("/api/v1").Subrouter() - s.HandleFunc("/welcome", b.setComonHeaders(b.welcomeFunc)).Methods("GET") - s.HandleFunc("/room/{roomid}", b.setComonHeaders(b.parseRequestBody(b.roomHandler))).Methods("POST") - s.HandleFunc("/stats", b.setComonHeaders(b.validateStatsRequest(b.statsHandler))).Methods("GET") + s.HandleFunc("/welcome", b.setCommonHeaders(b.welcomeFunc)).Methods("GET") + s.HandleFunc("/room/{roomid}", b.setCommonHeaders(b.parseRequestBody(b.roomHandler))).Methods("POST") + s.HandleFunc("/stats", b.setCommonHeaders(b.validateStatsRequest(b.statsHandler))).Methods("GET") + s.HandleFunc("/serverinfo", b.setCommonHeaders(b.validateStatsRequest(b.serverinfoHandler))).Methods("GET") // Expose prometheus metrics at "/metrics". - r.HandleFunc("/metrics", b.setComonHeaders(b.validateStatsRequest(b.metricsHandler))).Methods("GET") + r.HandleFunc("/metrics", b.setCommonHeaders(b.validateStatsRequest(b.metricsHandler))).Methods("GET") // Provide a REST service to get TURN credentials. // See https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 - r.HandleFunc("/turn/credentials", b.setComonHeaders(b.getTurnCredentials)).Methods("GET") + r.HandleFunc("/turn/credentials", b.setCommonHeaders(b.getTurnCredentials)).Methods("GET") return nil } -func (b *BackendServer) setComonHeaders(f func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { +func (b *BackendServer) setCommonHeaders(f func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Server", "nextcloud-spreed-signaling/"+b.version) w.Header().Set("X-Spreed-Signaling-Features", strings.Join(b.hub.info.Features, ", ")) @@ -233,11 +265,11 @@ func (b *BackendServer) getTurnCredentials(w http.ResponseWriter, r *http.Reques if username == "" { // Make sure to include an actual username in the credentials. - username = newRandomString(randomUsernameLength) + username = internal.RandomString(randomUsernameLength) } username, password := calculateTurnSecret(username, b.turnsecret, b.turnvalid) - result := TurnCredentials{ + result := talk.TurnCredentials{ Username: username, Password: password, TTL: int64(b.turnvalid.Seconds()), @@ -246,7 +278,7 @@ func (b *BackendServer) getTurnCredentials(w http.ResponseWriter, r *http.Reques data, err := json.Marshal(result) if err != nil { - log.Printf("Could not serialize TURN credentials: %s", err) + b.logger.Printf("Could not serialize TURN credentials: %s", err) w.WriteHeader(http.StatusInternalServerError) io.WriteString(w, "Could not serialize credentials.") // nolint return @@ -261,7 +293,7 @@ func (b *BackendServer) getTurnCredentials(w http.ResponseWriter, r *http.Reques w.Write(data) // nolint } -func (b *BackendServer) parseRequestBody(f func(http.ResponseWriter, *http.Request, []byte)) func(http.ResponseWriter, *http.Request) { +func (b *BackendServer) parseRequestBody(f func(context.Context, http.ResponseWriter, *http.Request, []byte)) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { // Sanity checks if r.ContentLength == -1 { @@ -273,37 +305,39 @@ func (b *BackendServer) parseRequestBody(f func(http.ResponseWriter, *http.Reque } ct := r.Header.Get("Content-Type") if !strings.HasPrefix(ct, "application/json") { - log.Printf("Received unsupported content-type: %s", ct) + b.logger.Printf("Received unsupported content-type: %s", ct) http.Error(w, "Unsupported Content-Type", http.StatusBadRequest) return } - if r.Header.Get(HeaderBackendSignalingRandom) == "" || - r.Header.Get(HeaderBackendSignalingChecksum) == "" { + if r.Header.Get(talk.HeaderBackendSignalingRandom) == "" || + r.Header.Get(talk.HeaderBackendSignalingChecksum) == "" { http.Error(w, "Authentication check failed", http.StatusForbidden) return } - body, err := io.ReadAll(r.Body) + body, err := b.buffers.ReadAll(r.Body) if err != nil { - log.Println("Error reading body: ", err) + b.logger.Println("Error reading body: ", err) http.Error(w, "Could not read body", http.StatusBadRequest) return } + defer b.buffers.Put(body) - f(w, r, body) + ctx := log.NewLoggerContext(r.Context(), b.logger) + f(ctx, w, r, body.Bytes()) } } -func (b *BackendServer) sendRoomInvite(roomid string, backend *Backend, userids []string, properties json.RawMessage) { - msg := &AsyncMessage{ +func (b *BackendServer) sendRoomInvite(roomid string, backend *talk.Backend, userids []string, properties json.RawMessage) { + msg := &events.AsyncMessage{ Type: "message", - Message: &ServerMessage{ + Message: &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "roomlist", Type: "invite", - Invite: &RoomEventServerMessage{ + Invite: &api.RoomEventServerMessage{ RoomId: roomid, Properties: properties, }, @@ -312,21 +346,21 @@ func (b *BackendServer) sendRoomInvite(roomid string, backend *Backend, userids } for _, userid := range userids { if err := b.events.PublishUserMessage(userid, backend, msg); err != nil { - log.Printf("Could not publish room invite for user %s in backend %s: %s", userid, backend.Id(), err) + b.logger.Printf("Could not publish room invite for user %s in backend %s: %s", userid, backend.Id(), err) } } } -func (b *BackendServer) sendRoomDisinvite(roomid string, backend *Backend, reason string, userids []string, sessionids []string) { - msg := &AsyncMessage{ +func (b *BackendServer) sendRoomDisinvite(roomid string, backend *talk.Backend, reason string, userids []string, sessionids []api.RoomSessionId) { + msg := &events.AsyncMessage{ Type: "message", - Message: &ServerMessage{ + Message: &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "roomlist", Type: "disinvite", - Disinvite: &RoomDisinviteEventServerMessage{ - RoomEventServerMessage: RoomEventServerMessage{ + Disinvite: &api.RoomDisinviteEventServerMessage{ + RoomEventServerMessage: api.RoomEventServerMessage{ RoomId: roomid, }, Reason: reason, @@ -336,12 +370,13 @@ func (b *BackendServer) sendRoomDisinvite(roomid string, backend *Backend, reaso } for _, userid := range userids { if err := b.events.PublishUserMessage(userid, backend, msg); err != nil { - log.Printf("Could not publish room disinvite for user %s in backend %s: %s", userid, backend.Id(), err) + b.logger.Printf("Could not publish room disinvite for user %s in backend %s: %s", userid, backend.Id(), err) } } timeout := time.Second - ctx, cancel := context.WithTimeout(context.Background(), timeout) + ctx := log.NewLoggerContext(context.Background(), b.logger) + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() var wg sync.WaitGroup for _, sessionid := range sessionids { @@ -350,30 +385,28 @@ func (b *BackendServer) sendRoomDisinvite(roomid string, backend *Backend, reaso continue } - wg.Add(1) - go func(sessionid string) { - defer wg.Done() + wg.Go(func() { if sid, err := b.lookupByRoomSessionId(ctx, sessionid, nil); err != nil { - log.Printf("Could not lookup by room session %s: %s", sessionid, err) + b.logger.Printf("Could not lookup by room session %s: %s", sessionid, err) } else if sid != "" { if err := b.events.PublishSessionMessage(sid, backend, msg); err != nil { - log.Printf("Could not publish room disinvite for session %s: %s", sid, err) + b.logger.Printf("Could not publish room disinvite for session %s: %s", sid, err) } } - }(sessionid) + }) } wg.Wait() } -func (b *BackendServer) sendRoomUpdate(roomid string, backend *Backend, notified_userids []string, all_userids []string, properties json.RawMessage) { - msg := &AsyncMessage{ +func (b *BackendServer) sendRoomUpdate(roomid string, backend *talk.Backend, notified_userids []string, all_userids []string, properties json.RawMessage) { + msg := &events.AsyncMessage{ Type: "message", - Message: &ServerMessage{ + Message: &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "roomlist", Type: "update", - Update: &RoomEventServerMessage{ + Update: &api.RoomEventServerMessage{ RoomId: roomid, Properties: properties, }, @@ -391,14 +424,14 @@ func (b *BackendServer) sendRoomUpdate(roomid string, backend *Backend, notified } if err := b.events.PublishUserMessage(userid, backend, msg); err != nil { - log.Printf("Could not publish room update for user %s in backend %s: %s", userid, backend.Id(), err) + b.logger.Printf("Could not publish room update for user %s in backend %s: %s", userid, backend.Id(), err) } } } -func (b *BackendServer) lookupByRoomSessionId(ctx context.Context, roomSessionId string, cache *ConcurrentStringStringMap) (string, error) { +func (b *BackendServer) lookupByRoomSessionId(ctx context.Context, roomSessionId api.RoomSessionId, cache *container.ConcurrentMap[api.RoomSessionId, api.PublicSessionId]) (api.PublicSessionId, error) { if roomSessionId == sessionIdNotInMeeting { - log.Printf("Trying to lookup empty room session id: %s", roomSessionId) + b.logger.Printf("Trying to lookup empty room session id: %s", roomSessionId) return "", nil } @@ -421,48 +454,41 @@ func (b *BackendServer) lookupByRoomSessionId(ctx context.Context, roomSessionId return sid, nil } -func (b *BackendServer) fixupUserSessions(ctx context.Context, cache *ConcurrentStringStringMap, users []map[string]interface{}) []map[string]interface{} { +func (b *BackendServer) fixupUserSessions(ctx context.Context, cache *container.ConcurrentMap[api.RoomSessionId, api.PublicSessionId], users []api.StringMap) []api.StringMap { if len(users) == 0 { return users } var wg sync.WaitGroup for _, user := range users { - roomSessionIdOb, found := user["sessionId"] + roomSessionId, found := api.GetStringMapString[api.RoomSessionId](user, "sessionId") if !found { - continue - } - - roomSessionId, ok := roomSessionIdOb.(string) - if !ok { - log.Printf("User %+v has invalid room session id, ignoring", user) + b.logger.Printf("User %+v has invalid room session id, ignoring", user) delete(user, "sessionId") continue } if roomSessionId == sessionIdNotInMeeting { - log.Printf("User %+v is not in the meeting, ignoring", user) + b.logger.Printf("User %+v is not in the meeting, ignoring", user) delete(user, "sessionId") continue } - wg.Add(1) - go func(roomSessionId string, u map[string]interface{}) { - defer wg.Done() + wg.Go(func() { if sessionId, err := b.lookupByRoomSessionId(ctx, roomSessionId, cache); err != nil { - log.Printf("Could not lookup by room session %s: %s", roomSessionId, err) - delete(u, "sessionId") + b.logger.Printf("Could not lookup by room session %s: %s", roomSessionId, err) + delete(user, "sessionId") } else if sessionId != "" { - u["sessionId"] = sessionId + user["sessionId"] = sessionId } else { // sessionId == "" - delete(u, "sessionId") + delete(user, "sessionId") } - }(roomSessionId, user) + }) } wg.Wait() - result := make([]map[string]interface{}, 0, len(users)) + result := make([]api.StringMap, 0, len(users)) for _, user := range users { if _, found := user["sessionId"]; found { result = append(result, user) @@ -471,13 +497,14 @@ func (b *BackendServer) fixupUserSessions(ctx context.Context, cache *Concurrent return result } -func (b *BackendServer) sendRoomIncall(roomid string, backend *Backend, request *BackendServerRoomRequest) error { +func (b *BackendServer) sendRoomIncall(roomid string, backend *talk.Backend, request *talk.BackendServerRoomRequest) error { if !request.InCall.All { timeout := time.Second - ctx, cancel := context.WithTimeout(context.Background(), timeout) + ctx := log.NewLoggerContext(context.Background(), b.logger) + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - var cache ConcurrentStringStringMap + var cache container.ConcurrentMap[api.RoomSessionId, api.PublicSessionId] // Convert (Nextcloud) session ids to signaling session ids. request.InCall.Users = b.fixupUserSessions(ctx, &cache, request.InCall.Users) // Entries in "Changed" are most likely already fetched through the "Users" list. @@ -488,20 +515,20 @@ func (b *BackendServer) sendRoomIncall(roomid string, backend *Backend, request } } - message := &AsyncMessage{ + message := &events.AsyncMessage{ Type: "room", Room: request, } return b.events.PublishBackendRoomMessage(roomid, backend, message) } -func (b *BackendServer) sendRoomParticipantsUpdate(roomid string, backend *Backend, request *BackendServerRoomRequest) error { +func (b *BackendServer) sendRoomParticipantsUpdate(ctx context.Context, roomid string, backend *talk.Backend, request *talk.BackendServerRoomRequest) error { timeout := time.Second // Convert (Nextcloud) session ids to signaling session ids. - ctx, cancel := context.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - var cache ConcurrentStringStringMap + var cache container.ConcurrentMap[api.RoomSessionId, api.PublicSessionId] request.Participants.Users = b.fixupUserSessions(ctx, &cache, request.Participants.Users) request.Participants.Changed = b.fixupUserSessions(ctx, &cache, request.Participants.Changed) @@ -517,56 +544,59 @@ loop: continue } - sessionId := user["sessionId"].(string) - permissionsList, ok := permissionsInterface.([]interface{}) - if !ok { - log.Printf("Received invalid permissions %+v (%s) for session %s", permissionsInterface, reflect.TypeOf(permissionsInterface), sessionId) + sessionId, found := api.GetStringMapString[api.PublicSessionId](user, "sessionId") + if !found { + b.logger.Printf("User entry has no session id: %+v", user) continue } - var permissions []Permission + + permissionsList, ok := permissionsInterface.([]any) + if !ok { + b.logger.Printf("Received invalid permissions %+v (%s) for session %s", permissionsInterface, reflect.TypeOf(permissionsInterface), sessionId) + continue + } + var permissions []api.Permission for idx, ob := range permissionsList { permission, ok := ob.(string) if !ok { - log.Printf("Received invalid permission at position %d %+v (%s) for session %s", idx, ob, reflect.TypeOf(ob), sessionId) + b.logger.Printf("Received invalid permission at position %d %+v (%s) for session %s", idx, ob, reflect.TypeOf(ob), sessionId) continue loop } - permissions = append(permissions, Permission(permission)) + permissions = append(permissions, api.Permission(permission)) } - wg.Add(1) - go func(sessionId string, permissions []Permission) { - defer wg.Done() - message := &AsyncMessage{ + wg.Go(func() { + message := &events.AsyncMessage{ Type: "permissions", Permissions: permissions, } if err := b.events.PublishSessionMessage(sessionId, backend, message); err != nil { - log.Printf("Could not send permissions update (%+v) to session %s: %s", permissions, sessionId, err) + b.logger.Printf("Could not send permissions update (%+v) to session %s: %s", permissions, sessionId, err) } - }(sessionId, permissions) + }) } wg.Wait() - message := &AsyncMessage{ + message := &events.AsyncMessage{ Type: "room", Room: request, } return b.events.PublishBackendRoomMessage(roomid, backend, message) } -func (b *BackendServer) sendRoomMessage(roomid string, backend *Backend, request *BackendServerRoomRequest) error { - message := &AsyncMessage{ +func (b *BackendServer) sendRoomMessage(roomid string, backend *talk.Backend, request *talk.BackendServerRoomRequest) error { + message := &events.AsyncMessage{ Type: "room", Room: request, } return b.events.PublishBackendRoomMessage(roomid, backend, message) } -func (b *BackendServer) sendRoomSwitchTo(roomid string, backend *Backend, request *BackendServerRoomRequest) error { +func (b *BackendServer) sendRoomSwitchTo(ctx context.Context, roomid string, backend *talk.Backend, request *talk.BackendServerRoomRequest) error { timeout := time.Second // Convert (Nextcloud) session ids to signaling session ids. - ctx, cancel := context.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() var wg sync.WaitGroup @@ -574,7 +604,7 @@ func (b *BackendServer) sendRoomSwitchTo(roomid string, backend *Backend, reques if len(request.SwitchTo.Sessions) > 0 { // We support both a list of sessions or a map with additional details per session. if request.SwitchTo.Sessions[0] == '[' { - var sessionsList BackendRoomSwitchToSessionsList + var sessionsList talk.BackendRoomSwitchToSessionsList if err := json.Unmarshal(request.SwitchTo.Sessions, &sessionsList); err != nil { return err } @@ -583,23 +613,21 @@ func (b *BackendServer) sendRoomSwitchTo(roomid string, backend *Backend, reques return nil } - var internalSessionsList BackendRoomSwitchToSessionsList + var internalSessionsList talk.BackendRoomSwitchToPublicSessionsList for _, roomSessionId := range sessionsList { if roomSessionId == sessionIdNotInMeeting { continue } - wg.Add(1) - go func(roomSessionId string) { - defer wg.Done() + wg.Go(func() { if sessionId, err := b.lookupByRoomSessionId(ctx, roomSessionId, nil); err != nil { - log.Printf("Could not lookup by room session %s: %s", roomSessionId, err) + b.logger.Printf("Could not lookup by room session %s: %s", roomSessionId, err) } else if sessionId != "" { mu.Lock() defer mu.Unlock() internalSessionsList = append(internalSessionsList, sessionId) } - }(roomSessionId) + }) } wg.Wait() @@ -612,7 +640,7 @@ func (b *BackendServer) sendRoomSwitchTo(roomid string, backend *Backend, reques request.SwitchTo.SessionsList = internalSessionsList request.SwitchTo.SessionsMap = nil } else { - var sessionsMap BackendRoomSwitchToSessionsMap + var sessionsMap talk.BackendRoomSwitchToSessionsMap if err := json.Unmarshal(request.SwitchTo.Sessions, &sessionsMap); err != nil { return err } @@ -621,23 +649,21 @@ func (b *BackendServer) sendRoomSwitchTo(roomid string, backend *Backend, reques return nil } - internalSessionsMap := make(BackendRoomSwitchToSessionsMap) + internalSessionsMap := make(talk.BackendRoomSwitchToPublicSessionsMap) for roomSessionId, details := range sessionsMap { if roomSessionId == sessionIdNotInMeeting { continue } - wg.Add(1) - go func(roomSessionId string, details json.RawMessage) { - defer wg.Done() + wg.Go(func() { if sessionId, err := b.lookupByRoomSessionId(ctx, roomSessionId, nil); err != nil { - log.Printf("Could not lookup by room session %s: %s", roomSessionId, err) + b.logger.Printf("Could not lookup by room session %s: %s", roomSessionId, err) } else if sessionId != "" { mu.Lock() defer mu.Unlock() internalSessionsMap[sessionId] = details } - }(roomSessionId, details) + }) } wg.Wait() @@ -653,7 +679,7 @@ func (b *BackendServer) sendRoomSwitchTo(roomid string, backend *Backend, reques } request.SwitchTo.Sessions = nil - message := &AsyncMessage{ + message := &events.AsyncMessage{ Type: "room", Room: request, } @@ -665,7 +691,7 @@ type BackendResponseWithStatus interface { } type DialoutErrorResponse struct { - BackendServerRoomResponse + talk.BackendServerRoomResponse status int } @@ -674,11 +700,11 @@ func (r *DialoutErrorResponse) Status() int { return r.status } -func returnDialoutError(status int, err *Error) (any, error) { +func returnDialoutError(status int, err *api.Error) (any, error) { response := &DialoutErrorResponse{ - BackendServerRoomResponse: BackendServerRoomResponse{ + BackendServerRoomResponse: talk.BackendServerRoomResponse{ Type: "dialout", - Dialout: &BackendRoomDialoutResponse{ + Dialout: &talk.BackendRoomDialoutResponse{ Error: err, }, }, @@ -694,48 +720,42 @@ func isNumeric(s string) bool { return checkNumeric.MatchString(s) } -func (b *BackendServer) startDialout(roomid string, backend *Backend, backendUrl string, request *BackendServerRoomRequest) (any, error) { - if err := request.Dialout.ValidateNumber(); err != nil { - return returnDialoutError(http.StatusBadRequest, err) +func (b *BackendServer) startDialoutInSession(ctx context.Context, session *ClientSession, roomid string, backend *talk.Backend, backendUrl string, request *talk.BackendServerRoomRequest) (any, error) { + url := backendUrl + if url != "" && url[len(url)-1] != '/' { + url += "/" } - - if !isNumeric(roomid) { - return returnDialoutError(http.StatusBadRequest, NewError("invalid_roomid", "The room id must be numeric.")) - } - - session := b.hub.GetDialoutSession(roomid, backend) - if session == nil { - return returnDialoutError(http.StatusNotFound, NewError("no_client_available", "No available client found to trigger dialout.")) - } - - url := backend.Url() - if url == "" { - // Old-style compat backend, use client-provided URL. - url = backendUrl - if url != "" && url[len(url)-1] != '/' { - url += "/" + if urls := backend.Urls(); len(urls) > 0 { + // Check if client-provided URL is registered for backend and use that. + if !slices.ContainsFunc(urls, func(u string) bool { + return strings.HasPrefix(url, u) + }) { + url = urls[0] } } - id := newRandomString(32) - msg := &ServerMessage{ + id := internal.RandomString(32) + msg := &api.ServerMessage{ Id: id, Type: "internal", - Internal: &InternalServerMessage{ + Internal: &api.InternalServerMessage{ Type: "dialout", - Dialout: &InternalServerDialoutRequest{ + Dialout: &api.InternalServerDialoutRequest{ RoomId: roomid, Backend: url, - Request: request.Dialout, + Request: &api.InternalServerDialoutRequestContents{ + Number: request.Dialout.Number, + Options: request.Dialout.Options, + }, }, }, } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + subCtx, cancel := context.WithTimeout(ctx, startDialoutTimeout) defer cancel() - var response atomic.Pointer[DialoutInternalClientMessage] + var response atomic.Pointer[api.DialoutInternalClientMessage] - session.HandleResponse(id, func(message *ClientMessage) bool { + session.HandleResponse(id, func(message *api.ClientMessage) bool { response.Store(message.Internal.Dialout) cancel() // Don't send error to other sessions in the room. @@ -744,47 +764,88 @@ func (b *BackendServer) startDialout(roomid string, backend *Backend, backendUrl defer session.ClearResponseHandler(id) if !session.SendMessage(msg) { - return returnDialoutError(http.StatusBadGateway, NewError("error_notify", "Could not notify about new dialout.")) + return nil, api.NewError("error_notify", "Could not notify about new dialout.") } - <-ctx.Done() - if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { - return returnDialoutError(http.StatusGatewayTimeout, NewError("timeout", "Timeout while waiting for dialout to start.")) + <-subCtx.Done() + if err := subCtx.Err(); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return nil, api.NewError("timeout", "Timeout while waiting for dialout to start.") + } else if errors.Is(err, context.Canceled) && errors.Is(ctx.Err(), context.Canceled) { + // Upstream request was cancelled. + return nil, err + } } dialout := response.Load() if dialout == nil { - return returnDialoutError(http.StatusBadGateway, NewError("error_notify", "No dialout response received.")) + return nil, api.NewError("error_notify", "No dialout response received.") } switch dialout.Type { case "error": - return returnDialoutError(http.StatusBadGateway, dialout.Error) + return nil, dialout.Error case "status": - if dialout.Status.Status != DialoutStatusAccepted { - log.Printf("Received unsupported dialout status when triggering dialout: %+v", dialout) - return returnDialoutError(http.StatusBadGateway, NewError("unsupported_status", "Unsupported dialout status received.")) + if dialout.Status.Status != api.DialoutStatusAccepted { + return nil, api.NewError("unsupported_status", fmt.Sprintf("Unsupported dialout status received: %+v", dialout)) } - return &BackendServerRoomResponse{ + return &talk.BackendServerRoomResponse{ Type: "dialout", - Dialout: &BackendRoomDialoutResponse{ + Dialout: &talk.BackendRoomDialoutResponse{ CallId: dialout.Status.CallId, }, }, nil + default: + return nil, api.NewError("unsupported_type", fmt.Sprintf("Unsupported dialout type received: %+v", dialout)) } - - log.Printf("Received unsupported dialout type when triggering dialout: %+v", dialout) - return returnDialoutError(http.StatusBadGateway, NewError("unsupported_type", "Unsupported dialout type received.")) } -func (b *BackendServer) roomHandler(w http.ResponseWriter, r *http.Request, body []byte) { - throttle, err := b.hub.throttler.CheckBruteforce(r.Context(), b.hub.getRealUserIP(r), "BackendRoomAuth") - if err == ErrBruteforceDetected { +func (b *BackendServer) startDialout(ctx context.Context, roomid string, backend *talk.Backend, backendUrl string, request *talk.BackendServerRoomRequest) (any, error) { + if err := request.Dialout.ValidateNumber(); err != nil { + return returnDialoutError(http.StatusBadRequest, err) + } + + if !isNumeric(roomid) { + return returnDialoutError(http.StatusBadRequest, api.NewError("invalid_roomid", "The room id must be numeric.")) + } + + var sessionError *api.Error + sessions := b.hub.GetDialoutSessions(roomid, backend) + for _, session := range sessions { + if ctx.Err() != nil { + // Upstream request was cancelled. + break + } + + response, err := b.startDialoutInSession(ctx, session, roomid, backend, backendUrl, request) + if err != nil { + b.logger.Printf("Error starting dialout request %+v in session %s: %+v", request.Dialout, session.PublicId(), err) + if sessionError == nil { + if e, ok := internal.AsErrorType[*api.Error](err); ok { + sessionError = e + } + } + continue + } + + return response, nil + } + + if sessionError != nil { + return returnDialoutError(http.StatusBadGateway, sessionError) + } + + return returnDialoutError(http.StatusNotFound, api.NewError("no_client_available", "No available client found to trigger dialout.")) +} + +func (b *BackendServer) roomHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, body []byte) { + throttle, err := b.hub.throttler.CheckBruteforce(ctx, b.hub.getRealUserIP(r), "BackendRoomAuth") + if err == async.ErrBruteforceDetected { http.Error(w, "Too many requests", http.StatusTooManyRequests) return } else if err != nil { - log.Printf("Error checking for bruteforce: %s", err) + b.logger.Printf("Error checking for bruteforce: %s", err) http.Error(w, "Could not check for bruteforce", http.StatusInternalServerError) return } @@ -792,8 +853,8 @@ func (b *BackendServer) roomHandler(w http.ResponseWriter, r *http.Request, body v := mux.Vars(r) roomid := v["roomid"] - var backend *Backend - backendUrl := r.Header.Get(HeaderBackendServer) + var backend *talk.Backend + backendUrl := r.Header.Get(talk.HeaderBackendServer) if backendUrl != "" { if u, err := url.Parse(backendUrl); err == nil { backend = b.hub.backend.GetBackend(u) @@ -801,7 +862,7 @@ func (b *BackendServer) roomHandler(w http.ResponseWriter, r *http.Request, body if backend == nil { // Unknown backend URL passed, return immediately. - throttle(r.Context()) + throttle(ctx) http.Error(w, "Authentication check failed", http.StatusForbidden) return } @@ -815,7 +876,7 @@ func (b *BackendServer) roomHandler(w http.ResponseWriter, r *http.Request, body // Old-style Talk, find backend that created the checksum. // TODO(fancycode): Remove once all supported Talk versions send the backend header. for _, b := range b.hub.backend.GetBackends() { - if ValidateBackendChecksum(r, body, b.Secret()) { + if talk.ValidateBackendChecksum(r, body, b.Secret()) { backend = b break } @@ -823,21 +884,21 @@ func (b *BackendServer) roomHandler(w http.ResponseWriter, r *http.Request, body } if backend == nil { - throttle(r.Context()) + throttle(ctx) http.Error(w, "Authentication check failed", http.StatusForbidden) return } } - if !ValidateBackendChecksum(r, body, backend.Secret()) { - throttle(r.Context()) + if !talk.ValidateBackendChecksum(r, body, backend.Secret()) { + throttle(ctx) http.Error(w, "Authentication check failed", http.StatusForbidden) return } - var request BackendServerRoomRequest + var request talk.BackendServerRoomRequest if err := json.Unmarshal(body, &request); err != nil { - log.Printf("Error decoding body %s: %s", string(body), err) + b.logger.Printf("Error decoding body %s: %s", string(body), err) http.Error(w, "Could not read body", http.StatusBadRequest) return } @@ -850,39 +911,39 @@ func (b *BackendServer) roomHandler(w http.ResponseWriter, r *http.Request, body b.sendRoomInvite(roomid, backend, request.Invite.UserIds, request.Invite.Properties) b.sendRoomUpdate(roomid, backend, request.Invite.UserIds, request.Invite.AllUserIds, request.Invite.Properties) case "disinvite": - b.sendRoomDisinvite(roomid, backend, DisinviteReasonDisinvited, request.Disinvite.UserIds, request.Disinvite.SessionIds) + b.sendRoomDisinvite(roomid, backend, api.DisinviteReasonDisinvited, request.Disinvite.UserIds, request.Disinvite.SessionIds) b.sendRoomUpdate(roomid, backend, request.Disinvite.UserIds, request.Disinvite.AllUserIds, request.Disinvite.Properties) case "update": - message := &AsyncMessage{ + message := &events.AsyncMessage{ Type: "room", Room: &request, } err = b.events.PublishBackendRoomMessage(roomid, backend, message) b.sendRoomUpdate(roomid, backend, nil, request.Update.UserIds, request.Update.Properties) case "delete": - message := &AsyncMessage{ + message := &events.AsyncMessage{ Type: "room", Room: &request, } err = b.events.PublishBackendRoomMessage(roomid, backend, message) - b.sendRoomDisinvite(roomid, backend, DisinviteReasonDeleted, request.Delete.UserIds, nil) + b.sendRoomDisinvite(roomid, backend, api.DisinviteReasonDeleted, request.Delete.UserIds, nil) case "incall": err = b.sendRoomIncall(roomid, backend, &request) case "participants": - err = b.sendRoomParticipantsUpdate(roomid, backend, &request) + err = b.sendRoomParticipantsUpdate(ctx, roomid, backend, &request) case "message": err = b.sendRoomMessage(roomid, backend, &request) case "switchto": - err = b.sendRoomSwitchTo(roomid, backend, &request) + err = b.sendRoomSwitchTo(ctx, roomid, backend, &request) case "dialout": - response, err = b.startDialout(roomid, backend, backendUrl, &request) + response, err = b.startDialout(ctx, roomid, backend, backendUrl, &request) default: http.Error(w, "Unsupported request type: "+request.Type, http.StatusBadRequest) return } if err != nil { - log.Printf("Error processing %s for room %s: %s", string(body), roomid, err) + b.logger.Printf("Error processing %s for room %s: %s", string(body), roomid, err) http.Error(w, "Error while processing", http.StatusInternalServerError) return } @@ -898,7 +959,7 @@ func (b *BackendServer) roomHandler(w http.ResponseWriter, r *http.Request, body } responseData, err = json.Marshal(response) if err != nil { - log.Printf("Could not serialize backend response %+v: %s", response, err) + b.logger.Printf("Could not serialize backend response %+v: %s", response, err) responseStatus = http.StatusInternalServerError responseData = []byte("{\"error\":\"could_not_serialize\"}") } @@ -918,7 +979,7 @@ func (b *BackendServer) allowStatsAccess(r *http.Request) bool { } allowed := b.statsAllowedIps.Load() - return allowed != nil && allowed.Allowed(ip) + return allowed != nil && allowed.Contains(ip) } func (b *BackendServer) validateStatsRequest(f func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { @@ -936,7 +997,7 @@ func (b *BackendServer) statsHandler(w http.ResponseWriter, r *http.Request) { stats := b.hub.GetStats() statsData, err := json.MarshalIndent(stats, "", " ") if err != nil { - log.Printf("Could not serialize stats %+v: %s", stats, err) + b.logger.Printf("Could not serialize stats %+v: %s", stats, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -947,6 +1008,43 @@ func (b *BackendServer) statsHandler(w http.ResponseWriter, r *http.Request) { w.Write(statsData) // nolint } +type withServerInfoNats interface { + GetServerInfoNats() *talk.BackendServerInfoNats +} + +func (b *BackendServer) serverinfoHandler(w http.ResponseWriter, r *http.Request) { + info := talk.BackendServerInfo{ + Version: b.version, + Features: b.hub.info.Features, + + Dialout: b.hub.GetServerInfoDialout(), + } + if mcu := b.hub.mcu; mcu != nil { + info.Sfu = mcu.GetServerInfoSfu() + } + if e, ok := b.events.(withServerInfoNats); ok { + info.Nats = e.GetServerInfoNats() + } + if rpcClients := b.hub.rpcClients; rpcClients != nil { + info.Grpc = rpcClients.GetServerInfoGrpc() + } + if etcdClient := b.hub.etcdClient; etcdClient != nil { + info.Etcd = etcdClient.GetServerInfoEtcd() + } + + infoData, err := json.MarshalIndent(info, "", " ") + if err != nil { + b.logger.Printf("Could not serialize server info %+v: %s", info, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + w.Write(infoData) // nolint +} + func (b *BackendServer) metricsHandler(w http.ResponseWriter, r *http.Request) { promhttp.Handler().ServeHTTP(w, r) } diff --git a/backend_server_test.go b/server/backend_server_test.go similarity index 63% rename from backend_server_test.go rename to server/backend_server_test.go index 858b7f3..80338bd 100644 --- a/backend_server_test.go +++ b/server/backend_server_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "bytes" @@ -37,14 +37,25 @@ import ( "net/url" "strings" "sync" + "sync/atomic" "testing" "time" "github.com/dlintw/goconf" "github.com/gorilla/mux" - "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + eventstest "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events/test" + grpctest "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" + natstest "github.com/strukturag/nextcloud-spreed-signaling/v2/nats/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) var ( @@ -54,11 +65,11 @@ var ( turnServers = strings.Split(turnServersString, ",") ) -func CreateBackendServerForTest(t *testing.T) (*goconf.ConfigFile, *BackendServer, AsyncEvents, *Hub, *mux.Router, *httptest.Server) { +func CreateBackendServerForTest(t *testing.T) (*goconf.ConfigFile, *BackendServer, events.AsyncEvents, *Hub, *mux.Router, *httptest.Server) { return CreateBackendServerForTestFromConfig(t, nil) } -func CreateBackendServerForTestWithTurn(t *testing.T) (*goconf.ConfigFile, *BackendServer, AsyncEvents, *Hub, *mux.Router, *httptest.Server) { +func CreateBackendServerForTestWithTurn(t *testing.T) (*goconf.ConfigFile, *BackendServer, events.AsyncEvents, *Hub, *mux.Router, *httptest.Server) { config := goconf.NewConfigFile() config.AddOption("turn", "apikey", turnApiKey) config.AddOption("turn", "secret", turnSecret) @@ -66,7 +77,7 @@ func CreateBackendServerForTestWithTurn(t *testing.T) (*goconf.ConfigFile, *Back return CreateBackendServerForTestFromConfig(t, config) } -func CreateBackendServerForTestFromConfig(t *testing.T, config *goconf.ConfigFile) (*goconf.ConfigFile, *BackendServer, AsyncEvents, *Hub, *mux.Router, *httptest.Server) { +func CreateBackendServerForTestFromConfig(t *testing.T, config *goconf.ConfigFile) (*goconf.ConfigFile, *BackendServer, events.AsyncEvents, *Hub, *mux.Router, *httptest.Server) { require := require.New(t) r := mux.NewRouter() registerBackendHandler(t, r) @@ -96,10 +107,12 @@ func CreateBackendServerForTestFromConfig(t *testing.T, config *goconf.ConfigFil config.AddOption("sessions", "blockkey", "09876543210987654321098765432109") config.AddOption("clients", "internalsecret", string(testInternalSecret)) config.AddOption("geoip", "url", "none") - events := getAsyncEventsForTest(t) - hub, err := NewHub(config, events, nil, nil, nil, r, "no-version") + events := eventstest.GetAsyncEventsForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + hub, err := NewHub(ctx, config, events, nil, nil, nil, r, "no-version") require.NoError(err) - b, err := NewBackendServer(config, hub, "no-version") + b, err := NewBackendServer(ctx, config, hub, "no-version") require.NoError(err) require.NoError(b.Start(r)) @@ -121,6 +134,7 @@ func CreateBackendServerWithClusteringForTest(t *testing.T) (*BackendServer, *Ba func CreateBackendServerWithClusteringForTestFromConfig(t *testing.T, config1 *goconf.ConfigFile, config2 *goconf.ConfigFile) (*BackendServer, *BackendServer, *Hub, *Hub, *httptest.Server, *httptest.Server) { require := require.New(t) + assert := assert.New(t) r1 := mux.NewRouter() registerBackendHandler(t, r1) @@ -137,9 +151,9 @@ func CreateBackendServerWithClusteringForTestFromConfig(t *testing.T, config1 *g server2.Close() }) - nats := startLocalNatsServer(t) - grpcServer1, addr1 := NewGrpcServerForTest(t) - grpcServer2, addr2 := NewGrpcServerForTest(t) + nats, _ := natstest.StartLocalServer(t) + grpcServer1, addr1 := grpctest.NewServerForTest(t) + grpcServer2, addr2 := grpctest.NewServerForTest(t) if config1 == nil { config1 = goconf.NewConfigFile() @@ -156,13 +170,18 @@ func CreateBackendServerWithClusteringForTestFromConfig(t *testing.T, config1 *g config1.AddOption("clients", "internalsecret", string(testInternalSecret)) config1.AddOption("geoip", "url", "none") - events1, err := NewAsyncEvents(nats) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + + events1, err := events.NewAsyncEvents(ctx, nats.ClientURL()) require.NoError(err) t.Cleanup(func() { - events1.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + assert.NoError(events1.Close(ctx)) }) - client1, _ := NewGrpcClientsForTest(t, addr2) - hub1, err := NewHub(config1, events1, grpcServer1, client1, nil, r1, "no-version") + client1, _ := grpctest.NewClientsForTest(t, addr2, nil) + hub1, err := NewHub(ctx, config1, events1, grpcServer1, client1, nil, r1, "no-version") require.NoError(err) if config2 == nil { @@ -179,19 +198,21 @@ func CreateBackendServerWithClusteringForTestFromConfig(t *testing.T, config1 *g config2.AddOption("sessions", "blockkey", "09876543210987654321098765432109") config2.AddOption("clients", "internalsecret", string(testInternalSecret)) config2.AddOption("geoip", "url", "none") - events2, err := NewAsyncEvents(nats) + events2, err := events.NewAsyncEvents(ctx, nats.ClientURL()) require.NoError(err) t.Cleanup(func() { - events2.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + assert.NoError(events2.Close(ctx)) }) - client2, _ := NewGrpcClientsForTest(t, addr1) - hub2, err := NewHub(config2, events2, grpcServer2, client2, nil, r2, "no-version") + client2, _ := grpctest.NewClientsForTest(t, addr1, nil) + hub2, err := NewHub(ctx, config2, events2, grpcServer2, client2, nil, r2, "no-version") require.NoError(err) - b1, err := NewBackendServer(config1, hub1, "no-version") + b1, err := NewBackendServer(ctx, config1, hub1, "no-version") require.NoError(err) require.NoError(b1.Start(r1)) - b2, err := NewBackendServer(config2, hub2, "no-version") + b2, err := NewBackendServer(ctx, config2, hub2, "no-version") require.NoError(err) require.NoError(b2.Start(r2)) @@ -215,8 +236,8 @@ func performBackendRequest(requestUrl string, body []byte) (*http.Response, erro return nil, err } request.Header.Set("Content-Type", "application/json") - rnd := newRandomString(32) - check := CalculateBackendChecksum(rnd, body, testBackendSecret) + rnd := internal.RandomString(32) + check := talk.CalculateBackendChecksum(rnd, body, testBackendSecret) request.Header.Set("Spreed-Signaling-Random", rnd) request.Header.Set("Spreed-Signaling-Checksum", check) u, err := url.Parse(requestUrl) @@ -228,31 +249,36 @@ func performBackendRequest(requestUrl string, body []byte) (*http.Response, erro return client.Do(request) } -func expectRoomlistEvent(ch chan *AsyncMessage, msgType string) (*EventServerMessage, error) { +func expectRoomlistEvent(t *testing.T, ch events.AsyncChannel, msgType string) (*api.EventServerMessage, bool) { + assert := assert.New(t) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() select { - case message := <-ch: - if message.Type != "message" || message.Message == nil { - return nil, fmt.Errorf("Expected message type message, got %+v", message) + case natsMsg := <-ch: + var message events.AsyncMessage + if !assert.NoError(nats.Decode(natsMsg, &message)) || + !assert.Equal("message", message.Type, "invalid message type, got %+v", message) || + !assert.NotNil(message.Message, "message missing, got %+v", message) { + return nil, false } msg := message.Message - if msg.Type != "event" || msg.Event == nil { - return nil, fmt.Errorf("Expected message type event, got %+v", msg) + if !assert.Equal("event", msg.Type, "invalid message type, got %+v", msg) || + !assert.NotNil(msg.Event, "event missing, got %+v", msg) || + !assert.Equal("roomlist", msg.Event.Target, "invalid event target, got %+v", msg.Event) || + !assert.Equal(msgType, msg.Event.Type, "invalid event type, got %+v", msg.Event) { + return nil, false } - if msg.Event.Target != "roomlist" || msg.Event.Type != msgType { - return nil, fmt.Errorf("Expected roomlist %s event, got %+v", msgType, msg.Event) - } - return msg.Event, nil + + return msg.Event, true case <-ctx.Done(): - return nil, ctx.Err() + assert.NoError(ctx.Err()) + return nil, false } } func TestBackendServer_NoAuth(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) _, _, _, _, _, server := CreateBackendServerForTest(t) @@ -274,7 +300,6 @@ func TestBackendServer_NoAuth(t *testing.T) { func TestBackendServer_InvalidAuth(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) _, _, _, _, _, server := CreateBackendServerForTest(t) @@ -298,7 +323,6 @@ func TestBackendServer_InvalidAuth(t *testing.T) { func TestBackendServer_OldCompatAuth(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) _, _, _, _, _, server := CreateBackendServerForTest(t) @@ -306,9 +330,9 @@ func TestBackendServer_OldCompatAuth(t *testing.T) { roomId := "the-room-id" userid := "the-user-id" roomProperties := json.RawMessage("{\"foo\":\"bar\"}") - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "invite", - Invite: &BackendRoomInviteRequest{ + Invite: &talk.BackendRoomInviteRequest{ UserIds: []string{ userid, }, @@ -325,8 +349,8 @@ func TestBackendServer_OldCompatAuth(t *testing.T) { request, err := http.NewRequest("POST", server.URL+"/api/v1/room/"+roomId, bytes.NewReader(data)) require.NoError(err) request.Header.Set("Content-Type", "application/json") - rnd := newRandomString(32) - check := CalculateBackendChecksum(rnd, data, testBackendSecret) + rnd := internal.RandomString(32) + check := talk.CalculateBackendChecksum(rnd, data, testBackendSecret) request.Header.Set("Spreed-Signaling-Random", rnd) request.Header.Set("Spreed-Signaling-Checksum", check) client := &http.Client{} @@ -341,7 +365,6 @@ func TestBackendServer_OldCompatAuth(t *testing.T) { func TestBackendServer_InvalidBody(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) _, _, _, _, _, server := CreateBackendServerForTest(t) @@ -358,12 +381,11 @@ func TestBackendServer_InvalidBody(t *testing.T) { func TestBackendServer_UnsupportedRequest(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) _, _, _, _, _, server := CreateBackendServerForTest(t) - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "lala", } @@ -379,27 +401,29 @@ func TestBackendServer_UnsupportedRequest(t *testing.T) { } func TestBackendServer_RoomInvite(t *testing.T) { - CatchLogForTest(t) - for _, backend := range eventBackendsForTest { + t.Parallel() + for _, backend := range eventstest.EventBackendsForTest { t.Run(backend, func(t *testing.T) { t.Parallel() - RunTestBackendServer_RoomInvite(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + RunTestBackendServer_RoomInvite(ctx, t) }) } } type channelEventListener struct { - ch chan *AsyncMessage + ch events.AsyncChannel } -func (l *channelEventListener) ProcessAsyncUserMessage(message *AsyncMessage) { - l.ch <- message +func (l *channelEventListener) AsyncChannel() events.AsyncChannel { + return l.ch } -func RunTestBackendServer_RoomInvite(t *testing.T) { +func RunTestBackendServer_RoomInvite(ctx context.Context, t *testing.T) { require := require.New(t) assert := assert.New(t) - _, _, events, hub, _, server := CreateBackendServerForTest(t) + _, _, asyncEvents, hub, _, server := CreateBackendServerForTest(t) u, err := url.Parse(server.URL) require.NoError(err) @@ -408,16 +432,18 @@ func RunTestBackendServer_RoomInvite(t *testing.T) { roomProperties := json.RawMessage("{\"foo\":\"bar\"}") backend := hub.backend.GetBackend(u) - eventsChan := make(chan *AsyncMessage, 1) + eventsChan := make(events.AsyncChannel, 1) listener := &channelEventListener{ ch: eventsChan, } - require.NoError(events.RegisterUserListener(userid, backend, listener)) - defer events.UnregisterUserListener(userid, backend, listener) + require.NoError(asyncEvents.RegisterUserListener(userid, backend, listener)) + defer func() { + assert.NoError(asyncEvents.UnregisterUserListener(userid, backend, listener)) + }() - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "invite", - Invite: &BackendRoomInviteRequest{ + Invite: &talk.BackendRoomInviteRequest{ UserIds: []string{ userid, }, @@ -438,70 +464,68 @@ func RunTestBackendServer_RoomInvite(t *testing.T) { assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s: %s", res.Status, string(body)) - if event, err := expectRoomlistEvent(eventsChan, "invite"); assert.NoError(err) { - if assert.NotNil(event.Invite) { - assert.Equal(roomId, event.Invite.RoomId) - assert.Equal(string(roomProperties), string(event.Invite.Properties)) - } + if event, ok := expectRoomlistEvent(t, eventsChan, "invite"); ok && assert.NotNil(event.Invite) { + assert.Equal(roomId, event.Invite.RoomId) + assert.Equal(string(roomProperties), string(event.Invite.Properties)) } } func TestBackendServer_RoomDisinvite(t *testing.T) { - CatchLogForTest(t) - for _, backend := range eventBackendsForTest { + t.Parallel() + for _, backend := range eventstest.EventBackendsForTest { t.Run(backend, func(t *testing.T) { t.Parallel() - RunTestBackendServer_RoomDisinvite(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + RunTestBackendServer_RoomDisinvite(ctx, t) }) } } -func RunTestBackendServer_RoomDisinvite(t *testing.T) { +func RunTestBackendServer_RoomDisinvite(ctx context.Context, t *testing.T) { require := require.New(t) assert := assert.New(t) - _, _, events, hub, _, server := CreateBackendServerForTest(t) + _, _, asyncEvents, hub, _, server := CreateBackendServerForTest(t) u, err := url.Parse(server.URL) require.NoError(err) backend := hub.backend.GetBackend(u) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - require.NoError(client.SendHello(testDefaultUserId)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + defer client.CloseWithBye() // Join room by id. roomId := "test-room" - if room, err := client.JoinRoom(ctx, roomId); assert.NoError(err) { + if room, ok := client.JoinRoom(ctx, roomId); assert.True(ok) { assert.Equal(roomId, room.Room.RoomId) } // Ignore "join" events. - assert.NoError(client.DrainMessages(ctx)) + client.RunUntilJoined(ctx, hello.Hello) roomProperties := json.RawMessage("{\"foo\":\"bar\"}") - eventsChan := make(chan *AsyncMessage, 1) + eventsChan := make(events.AsyncChannel, 1) listener := &channelEventListener{ ch: eventsChan, } - require.NoError(events.RegisterUserListener(testDefaultUserId, backend, listener)) - defer events.UnregisterUserListener(testDefaultUserId, backend, listener) + require.NoError(asyncEvents.RegisterUserListener(testDefaultUserId, backend, listener)) + defer func() { + assert.NoError(asyncEvents.UnregisterUserListener(testDefaultUserId, backend, listener)) + }() - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "disinvite", - Disinvite: &BackendRoomDisinviteRequest{ + Disinvite: &talk.BackendRoomDisinviteRequest{ UserIds: []string{ testDefaultUserId, }, - SessionIds: []string{ - roomId + "-" + hello.Hello.SessionId, + SessionIds: []api.RoomSessionId{ + api.RoomSessionId(fmt.Sprintf("%s-%s"+roomId, hello.Hello.SessionId)), }, AllUserIds: []string{}, Properties: roomProperties, @@ -517,65 +541,51 @@ func RunTestBackendServer_RoomDisinvite(t *testing.T) { require.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s: %s", res.Status, string(body)) - if event, err := expectRoomlistEvent(eventsChan, "disinvite"); assert.NoError(err) { - if assert.NotNil(event.Disinvite) { - assert.Equal(roomId, event.Disinvite.RoomId) - assert.Equal("disinvited", event.Disinvite.Reason) - } + if event, ok := expectRoomlistEvent(t, eventsChan, "disinvite"); ok && assert.NotNil(event.Disinvite) { + assert.Equal(roomId, event.Disinvite.RoomId) + assert.Equal("disinvited", event.Disinvite.Reason) assert.Empty(string(event.Disinvite.Properties)) } - if message, err := client.RunUntilRoomlistDisinvite(ctx); assert.NoError(err) { + if message, ok := client.RunUntilRoomlistDisinvite(ctx); ok { assert.Equal(roomId, message.RoomId) } - if message, err := client.RunUntilMessage(ctx); err != nil && !websocket.IsCloseError(err, websocket.CloseNoStatusReceived) { - assert.Fail("Received unexpected error %s", err) - } else if err == nil { - assert.Fail("Server should have closed the connection, received %+v", *message) - } + client.RunUntilClosed(ctx) } func TestBackendServer_RoomDisinviteDifferentRooms(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) _, _, _, hub, _, server := CreateBackendServerForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId)) - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + defer client1.CloseWithBye() + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + defer client2.CloseWithBye() // Join room by id. roomId1 := "test-room1" - _, err = client1.JoinRoom(ctx, roomId1) - require.NoError(err) - require.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + MustSucceed2(t, client1.JoinRoom, ctx, roomId1) + require.True(client1.RunUntilJoined(ctx, hello1.Hello)) roomId2 := "test-room2" - _, err = client2.JoinRoom(ctx, roomId2) - require.NoError(err) - require.NoError(client2.RunUntilJoined(ctx, hello2.Hello)) + MustSucceed2(t, client2.JoinRoom, ctx, roomId2) + require.True(client2.RunUntilJoined(ctx, hello2.Hello)) - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "disinvite", - Disinvite: &BackendRoomDisinviteRequest{ + Disinvite: &talk.BackendRoomDisinviteRequest{ UserIds: []string{ testDefaultUserId, }, - SessionIds: []string{ - roomId1 + "-" + hello1.Hello.SessionId, + SessionIds: []api.RoomSessionId{ + api.RoomSessionId(fmt.Sprintf("%s-%s"+roomId1, hello1.Hello.SessionId)), }, AllUserIds: []string{}, }, @@ -590,23 +600,19 @@ func TestBackendServer_RoomDisinviteDifferentRooms(t *testing.T) { assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - if message, err := client1.RunUntilRoomlistDisinvite(ctx); assert.NoError(err) { + if message, ok := client1.RunUntilRoomlistDisinvite(ctx); ok { assert.Equal(roomId1, message.RoomId) } - if message, err := client1.RunUntilMessage(ctx); err != nil && !websocket.IsCloseError(err, websocket.CloseNoStatusReceived) { - assert.NoError(err) - } else if err == nil { - assert.Fail("Server should have closed the connection, received %+v", *message) - } + client1.RunUntilClosed(ctx) - if message, err := client2.RunUntilRoomlistDisinvite(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilRoomlistDisinvite(ctx); ok { assert.Equal(roomId1, message.RoomId) } - msg = &BackendServerRoomRequest{ + msg = &talk.BackendServerRoomRequest{ Type: "update", - Update: &BackendRoomUpdateRequest{ + Update: &talk.BackendRoomUpdateRequest{ UserIds: []string{ testDefaultUserId, }, @@ -623,25 +629,27 @@ func TestBackendServer_RoomDisinviteDifferentRooms(t *testing.T) { assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - if message, err := client2.RunUntilRoomlistUpdate(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilRoomlistUpdate(ctx); ok { assert.Equal(roomId2, message.RoomId) } } func TestBackendServer_RoomUpdate(t *testing.T) { - CatchLogForTest(t) - for _, backend := range eventBackendsForTest { + t.Parallel() + for _, backend := range eventstest.EventBackendsForTest { t.Run(backend, func(t *testing.T) { t.Parallel() - RunTestBackendServer_RoomUpdate(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + RunTestBackendServer_RoomUpdate(ctx, t) }) } } -func RunTestBackendServer_RoomUpdate(t *testing.T) { +func RunTestBackendServer_RoomUpdate(ctx context.Context, t *testing.T) { require := require.New(t) assert := assert.New(t) - _, _, events, hub, _, server := CreateBackendServerForTest(t) + _, _, asyncEvents, hub, _, server := CreateBackendServerForTest(t) u, err := url.Parse(server.URL) require.NoError(err) @@ -650,23 +658,25 @@ func RunTestBackendServer_RoomUpdate(t *testing.T) { emptyProperties := json.RawMessage("{}") backend := hub.backend.GetBackend(u) require.NotNil(backend, "Did not find backend") - room, err := hub.createRoom(roomId, emptyProperties, backend) + room, err := hub.CreateRoom(roomId, emptyProperties, backend) require.NoError(err, "Could not create room") defer room.Close() userid := "test-userid" roomProperties := json.RawMessage("{\"foo\":\"bar\"}") - eventsChan := make(chan *AsyncMessage, 1) + eventsChan := make(events.AsyncChannel, 1) listener := &channelEventListener{ ch: eventsChan, } - require.NoError(events.RegisterUserListener(userid, backend, listener)) - defer events.UnregisterUserListener(userid, backend, listener) + require.NoError(asyncEvents.RegisterUserListener(userid, backend, listener)) + defer func() { + assert.NoError(asyncEvents.UnregisterUserListener(userid, backend, listener)) + }() - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "update", - Update: &BackendRoomUpdateRequest{ + Update: &talk.BackendRoomUpdateRequest{ UserIds: []string{ userid, }, @@ -683,11 +693,9 @@ func RunTestBackendServer_RoomUpdate(t *testing.T) { assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - if event, err := expectRoomlistEvent(eventsChan, "update"); assert.NoError(err) { - if assert.NotNil(event.Update) { - assert.Equal(roomId, event.Update.RoomId) - assert.Equal(string(roomProperties), string(event.Update.Properties)) - } + if event, ok := expectRoomlistEvent(t, eventsChan, "update"); ok && assert.NotNil(event.Update) { + assert.Equal(roomId, event.Update.RoomId) + assert.Equal(string(roomProperties), string(event.Update.Properties)) } // TODO: Use event to wait for asynchronous messages. @@ -699,19 +707,21 @@ func RunTestBackendServer_RoomUpdate(t *testing.T) { } func TestBackendServer_RoomDelete(t *testing.T) { - CatchLogForTest(t) - for _, backend := range eventBackendsForTest { + t.Parallel() + for _, backend := range eventstest.EventBackendsForTest { t.Run(backend, func(t *testing.T) { t.Parallel() - RunTestBackendServer_RoomDelete(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + RunTestBackendServer_RoomDelete(ctx, t) }) } } -func RunTestBackendServer_RoomDelete(t *testing.T) { +func RunTestBackendServer_RoomDelete(ctx context.Context, t *testing.T) { require := require.New(t) assert := assert.New(t) - _, _, events, hub, _, server := CreateBackendServerForTest(t) + _, _, asyncEvents, hub, _, server := CreateBackendServerForTest(t) u, err := url.Parse(server.URL) require.NoError(err) @@ -720,20 +730,22 @@ func RunTestBackendServer_RoomDelete(t *testing.T) { emptyProperties := json.RawMessage("{}") backend := hub.backend.GetBackend(u) require.NotNil(backend, "Did not find backend") - _, err = hub.createRoom(roomId, emptyProperties, backend) + _, err = hub.CreateRoom(roomId, emptyProperties, backend) require.NoError(err) userid := "test-userid" - eventsChan := make(chan *AsyncMessage, 1) + eventsChan := make(events.AsyncChannel, 1) listener := &channelEventListener{ ch: eventsChan, } - require.NoError(events.RegisterUserListener(userid, backend, listener)) - defer events.UnregisterUserListener(userid, backend, listener) + require.NoError(asyncEvents.RegisterUserListener(userid, backend, listener)) + defer func() { + assert.NoError(asyncEvents.UnregisterUserListener(userid, backend, listener)) + }() - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "delete", - Delete: &BackendRoomDeleteRequest{ + Delete: &talk.BackendRoomDeleteRequest{ UserIds: []string{ userid, }, @@ -750,12 +762,10 @@ func RunTestBackendServer_RoomDelete(t *testing.T) { assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) // A deleted room is signalled as a "disinvite" event. - if event, err := expectRoomlistEvent(eventsChan, "disinvite"); assert.NoError(err) { - if assert.NotNil(event.Disinvite) { - assert.Equal(roomId, event.Disinvite.RoomId) - assert.Empty(event.Disinvite.Properties) - assert.Equal("deleted", event.Disinvite.Reason) - } + if event, ok := expectRoomlistEvent(t, eventsChan, "disinvite"); ok && assert.NotNil(event.Disinvite) { + assert.Equal(roomId, event.Disinvite.RoomId) + assert.Empty(event.Disinvite.Properties) + assert.Equal("deleted", event.Disinvite.Reason) } // TODO: Use event to wait for asynchronous messages. @@ -766,10 +776,12 @@ func RunTestBackendServer_RoomDelete(t *testing.T) { } func TestBackendServer_ParticipantsUpdatePermissions(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) var hub1 *Hub @@ -786,20 +798,13 @@ func TestBackendServer_ParticipantsUpdatePermissions(t *testing.T) { _, _, hub1, hub2, server1, server2 = CreateBackendServerWithClusteringForTest(t) } - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + defer client1.CloseWithBye() + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") + defer client2.CloseWithBye() session1 := hub1.GetSessionByPublicId(hello1.Hello.SessionId) require.NotNil(session1, "Session %s does not exist", hello1.Hello.SessionId) @@ -807,45 +812,44 @@ func TestBackendServer_ParticipantsUpdatePermissions(t *testing.T) { require.NotNil(session2, "Session %s does not exist", hello2.Hello.SessionId) // Sessions have all permissions initially (fallback for old-style sessions). - assertSessionHasPermission(t, session1, PERMISSION_MAY_PUBLISH_MEDIA) - assertSessionHasPermission(t, session1, PERMISSION_MAY_PUBLISH_SCREEN) - assertSessionHasPermission(t, session2, PERMISSION_MAY_PUBLISH_MEDIA) - assertSessionHasPermission(t, session2, PERMISSION_MAY_PUBLISH_SCREEN) + assertSessionHasPermission(t, session1, api.PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasPermission(t, session1, api.PERMISSION_MAY_PUBLISH_SCREEN) + assertSessionHasPermission(t, session2, api.PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasPermission(t, session2, api.PERMISSION_MAY_PUBLISH_SCREEN) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + client1.RunUntilJoined(ctx, hello1.Hello) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Ignore "join" events. - assert.NoError(client1.DrainMessages(ctx)) - assert.NoError(client2.DrainMessages(ctx)) + client1.RunUntilJoined(ctx, hello2.Hello) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "participants", - Participants: &BackendRoomParticipantsRequest{ - Changed: []map[string]interface{}{ + Participants: &talk.BackendRoomParticipantsRequest{ + Changed: []api.StringMap{ { - "sessionId": roomId + "-" + hello1.Hello.SessionId, - "permissions": []Permission{PERMISSION_MAY_PUBLISH_MEDIA}, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), + "permissions": []api.Permission{api.PERMISSION_MAY_PUBLISH_MEDIA}, }, { - "sessionId": roomId + "-" + hello2.Hello.SessionId, - "permissions": []Permission{PERMISSION_MAY_PUBLISH_SCREEN}, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello2.Hello.SessionId), + "permissions": []api.Permission{api.PERMISSION_MAY_PUBLISH_SCREEN}, }, }, - Users: []map[string]interface{}{ + Users: []api.StringMap{ { - "sessionId": roomId + "-" + hello1.Hello.SessionId, - "permissions": []Permission{PERMISSION_MAY_PUBLISH_MEDIA}, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), + "permissions": []api.Permission{api.PERMISSION_MAY_PUBLISH_MEDIA}, }, { - "sessionId": roomId + "-" + hello2.Hello.SessionId, - "permissions": []Permission{PERMISSION_MAY_PUBLISH_SCREEN}, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello2.Hello.SessionId), + "permissions": []api.Permission{api.PERMISSION_MAY_PUBLISH_SCREEN}, }, }, }, @@ -864,62 +868,58 @@ func TestBackendServer_ParticipantsUpdatePermissions(t *testing.T) { // TODO: Use event to wait for asynchronous messages. time.Sleep(10 * time.Millisecond) - assertSessionHasPermission(t, session1, PERMISSION_MAY_PUBLISH_MEDIA) - assertSessionHasNotPermission(t, session1, PERMISSION_MAY_PUBLISH_SCREEN) - assertSessionHasNotPermission(t, session2, PERMISSION_MAY_PUBLISH_MEDIA) - assertSessionHasPermission(t, session2, PERMISSION_MAY_PUBLISH_SCREEN) + assertSessionHasPermission(t, session1, api.PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasNotPermission(t, session1, api.PERMISSION_MAY_PUBLISH_SCREEN) + assertSessionHasNotPermission(t, session2, api.PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasPermission(t, session2, api.PERMISSION_MAY_PUBLISH_SCREEN) }) } } func TestBackendServer_ParticipantsUpdateEmptyPermissions(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) _, _, _, hub, _, server := CreateBackendServerForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - require.NoError(client.SendHello(testDefaultUserId)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + defer client.CloseWithBye() session := hub.GetSessionByPublicId(hello.Hello.SessionId) assert.NotNil(session, "Session %s does not exist", hello.Hello.SessionId) // Sessions have all permissions initially (fallback for old-style sessions). - assertSessionHasPermission(t, session, PERMISSION_MAY_PUBLISH_MEDIA) - assertSessionHasPermission(t, session, PERMISSION_MAY_PUBLISH_SCREEN) + assertSessionHasPermission(t, session, api.PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasPermission(t, session, api.PERMISSION_MAY_PUBLISH_SCREEN) // Join room by id. roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Ignore "join" events. - assert.NoError(client.DrainMessages(ctx)) + client.RunUntilJoined(ctx, hello.Hello) // Updating with empty permissions upgrades to non-old-style and removes // all previously available permissions. - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "participants", - Participants: &BackendRoomParticipantsRequest{ - Changed: []map[string]interface{}{ + Participants: &talk.BackendRoomParticipantsRequest{ + Changed: []api.StringMap{ { - "sessionId": roomId + "-" + hello.Hello.SessionId, - "permissions": []Permission{}, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello.Hello.SessionId), + "permissions": []api.Permission{}, }, }, - Users: []map[string]interface{}{ + Users: []api.StringMap{ { - "sessionId": roomId + "-" + hello.Hello.SessionId, - "permissions": []Permission{}, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello.Hello.SessionId), + "permissions": []api.Permission{}, }, }, }, @@ -937,59 +937,49 @@ func TestBackendServer_ParticipantsUpdateEmptyPermissions(t *testing.T) { // TODO: Use event to wait for asynchronous messages. time.Sleep(10 * time.Millisecond) - assertSessionHasNotPermission(t, session, PERMISSION_MAY_PUBLISH_MEDIA) - assertSessionHasNotPermission(t, session, PERMISSION_MAY_PUBLISH_SCREEN) + assertSessionHasNotPermission(t, session, api.PERMISSION_MAY_PUBLISH_MEDIA) + assertSessionHasNotPermission(t, session, api.PERMISSION_MAY_PUBLISH_SCREEN) } func TestBackendServer_ParticipantsUpdateTimeout(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) _, _, _, hub, _, server := CreateBackendServerForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + defer client1.CloseWithBye() + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") + defer client2.CloseWithBye() // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Give message processing some time. time.Sleep(10 * time.Millisecond) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - msg := &BackendServerRoomRequest{ + wg.Go(func() { + msg := &talk.BackendServerRoomRequest{ Type: "incall", - InCall: &BackendRoomInCallRequest{ + InCall: &talk.BackendRoomInCallRequest{ InCall: json.RawMessage("7"), - Changed: []map[string]interface{}{ + Changed: []api.StringMap{ { - "sessionId": roomId + "-" + hello1.Hello.SessionId, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), "inCall": 7, }, { @@ -997,9 +987,9 @@ func TestBackendServer_ParticipantsUpdateTimeout(t *testing.T) { "inCall": 3, }, }, - Users: []map[string]interface{}{ + Users: []api.StringMap{ { - "sessionId": roomId + "-" + hello1.Hello.SessionId, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), "inCall": 7, }, { @@ -1022,35 +1012,33 @@ func TestBackendServer_ParticipantsUpdateTimeout(t *testing.T) { body, err := io.ReadAll(res.Body) assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - }() + }) // Ensure the first request is being processed. time.Sleep(100 * time.Millisecond) - wg.Add(1) - go func() { - defer wg.Done() - msg := &BackendServerRoomRequest{ + wg.Go(func() { + msg := &talk.BackendServerRoomRequest{ Type: "incall", - InCall: &BackendRoomInCallRequest{ + InCall: &talk.BackendRoomInCallRequest{ InCall: json.RawMessage("7"), - Changed: []map[string]interface{}{ + Changed: []api.StringMap{ { - "sessionId": roomId + "-" + hello1.Hello.SessionId, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), "inCall": 7, }, { - "sessionId": roomId + "-" + hello2.Hello.SessionId, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello2.Hello.SessionId), "inCall": 3, }, }, - Users: []map[string]interface{}{ + Users: []api.StringMap{ { - "sessionId": roomId + "-" + hello1.Hello.SessionId, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), "inCall": 7, }, { - "sessionId": roomId + "-" + hello2.Hello.SessionId, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello2.Hello.SessionId), "inCall": 3, }, }, @@ -1069,60 +1057,53 @@ func TestBackendServer_ParticipantsUpdateTimeout(t *testing.T) { body, err := io.ReadAll(res.Body) assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - }() + }) wg.Wait() if t.Failed() { return } - msg1_a, err := client1.RunUntilMessage(ctx) - assert.NoError(err) - if in_call_1, err := checkMessageParticipantsInCall(msg1_a); assert.NoError(err) { - if len(in_call_1.Users) != 2 { - msg1_b, err := client1.RunUntilMessage(ctx) - assert.NoError(err) - if in_call_2, err := checkMessageParticipantsInCall(msg1_b); assert.NoError(err) { - assert.Len(in_call_2.Users, 2) + if msg1_a, ok := client1.RunUntilMessage(ctx); ok { + if in_call_1, ok := checkMessageParticipantsInCall(t, msg1_a); ok { + if len(in_call_1.Users) != 2 { + msg1_b, _ := client1.RunUntilMessage(ctx) + if in_call_2, ok := checkMessageParticipantsInCall(t, msg1_b); ok { + assert.Len(in_call_2.Users, 2) + } } } } - msg2_a, err := client2.RunUntilMessage(ctx) - assert.NoError(err) - if in_call_1, err := checkMessageParticipantsInCall(msg2_a); assert.NoError(err) { - if len(in_call_1.Users) != 2 { - msg2_b, err := client2.RunUntilMessage(ctx) - assert.NoError(err) - if in_call_2, err := checkMessageParticipantsInCall(msg2_b); assert.NoError(err) { - assert.Len(in_call_2.Users, 2) + if msg2_a, ok := client2.RunUntilMessage(ctx); ok { + if in_call_1, ok := checkMessageParticipantsInCall(t, msg2_a); ok { + if len(in_call_1.Users) != 2 { + msg2_b, _ := client2.RunUntilMessage(ctx) + if in_call_2, ok := checkMessageParticipantsInCall(t, msg2_b); ok { + assert.Len(in_call_2.Users, 2) + } } } } - ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second+100*time.Millisecond) + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel2() - if msg1_c, _ := client1.RunUntilMessage(ctx2); msg1_c != nil { - if in_call_2, err := checkMessageParticipantsInCall(msg1_c); assert.NoError(err) { - assert.Len(in_call_2.Users, 2) - } - } + client1.RunUntilErrorIs(ctx2, context.DeadlineExceeded) - ctx3, cancel3 := context.WithTimeout(context.Background(), time.Second+100*time.Millisecond) + ctx3, cancel3 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel3() - if msg2_c, _ := client2.RunUntilMessage(ctx3); msg2_c != nil { - if in_call_2, err := checkMessageParticipantsInCall(msg2_c); assert.NoError(err) { - assert.Len(in_call_2.Users, 2) - } - } + + client2.RunUntilErrorIs(ctx3, context.DeadlineExceeded) } func TestBackendServer_InCallAll(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) var hub1 *Hub @@ -1139,20 +1120,13 @@ func TestBackendServer_InCallAll(t *testing.T) { _, _, hub1, hub2, server1, server2 = CreateBackendServerWithClusteringForTest(t) } - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + defer client1.CloseWithBye() + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") + defer client2.CloseWithBye() session1 := hub1.GetSessionByPublicId(hello1.Hello.SessionId) require.NotNil(session1, "Could not find session %s", hello1.Hello.SessionId) @@ -1161,15 +1135,13 @@ func TestBackendServer_InCallAll(t *testing.T) { // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Give message processing some time. time.Sleep(10 * time.Millisecond) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) @@ -1183,12 +1155,10 @@ func TestBackendServer_InCallAll(t *testing.T) { assert.False(room2.IsSessionInCall(session2), "Session %s should not be in room %s", session2.PublicId(), room2.Id()) var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - msg := &BackendServerRoomRequest{ + wg.Go(func() { + msg := &talk.BackendServerRoomRequest{ Type: "incall", - InCall: &BackendRoomInCallRequest{ + InCall: &talk.BackendRoomInCallRequest{ InCall: json.RawMessage("7"), All: true, }, @@ -1206,22 +1176,22 @@ func TestBackendServer_InCallAll(t *testing.T) { body, err := io.ReadAll(res.Body) assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - }() + }) wg.Wait() if t.Failed() { return } - if msg1_a, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - if in_call_1, err := checkMessageParticipantsInCall(msg1_a); assert.NoError(err) { + if msg1_a, ok := client1.RunUntilMessage(ctx); ok { + if in_call_1, ok := checkMessageParticipantsInCall(t, msg1_a); ok { assert.True(in_call_1.All, "All flag not set in message %+v", in_call_1) assert.Equal("7", string(in_call_1.InCall)) } } - if msg2_a, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - if in_call_1, err := checkMessageParticipantsInCall(msg2_a); assert.NoError(err) { + if msg2_a, ok := client2.RunUntilMessage(ctx); ok { + if in_call_1, ok := checkMessageParticipantsInCall(t, msg2_a); ok { assert.True(in_call_1.All, "All flag not set in message %+v", in_call_1) assert.Equal("7", string(in_call_1.InCall)) } @@ -1233,27 +1203,17 @@ func TestBackendServer_InCallAll(t *testing.T) { ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel2() - if message, err := client1.RunUntilMessage(ctx2); err == nil { - assert.Fail("Expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client1.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) ctx3, cancel3 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel3() - if message, err := client2.RunUntilMessage(ctx3); err == nil { - assert.Fail("Expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client2.RunUntilErrorIs(ctx3, ErrNoMessageReceived, context.DeadlineExceeded) - wg.Add(1) - go func() { - defer wg.Done() - msg := &BackendServerRoomRequest{ + wg.Go(func() { + msg := &talk.BackendServerRoomRequest{ Type: "incall", - InCall: &BackendRoomInCallRequest{ + InCall: &talk.BackendRoomInCallRequest{ InCall: json.RawMessage("0"), All: true, }, @@ -1271,22 +1231,22 @@ func TestBackendServer_InCallAll(t *testing.T) { body, err := io.ReadAll(res.Body) assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - }() + }) wg.Wait() if t.Failed() { return } - if msg1_a, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - if in_call_1, err := checkMessageParticipantsInCall(msg1_a); assert.NoError(err) { + if msg1_a, ok := client1.RunUntilMessage(ctx); ok { + if in_call_1, ok := checkMessageParticipantsInCall(t, msg1_a); ok { assert.True(in_call_1.All, "All flag not set in message %+v", in_call_1) assert.Equal("0", string(in_call_1.InCall)) } } - if msg2_a, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - if in_call_1, err := checkMessageParticipantsInCall(msg2_a); assert.NoError(err) { + if msg2_a, ok := client2.RunUntilMessage(ctx); ok { + if in_call_1, ok := checkMessageParticipantsInCall(t, msg2_a); ok { assert.True(in_call_1.All, "All flag not set in message %+v", in_call_1) assert.Equal("0", string(in_call_1.InCall)) } @@ -1298,54 +1258,42 @@ func TestBackendServer_InCallAll(t *testing.T) { ctx4, cancel4 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel4() - if message, err := client1.RunUntilMessage(ctx4); err == nil { - assert.Fail("Expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client1.RunUntilErrorIs(ctx4, ErrNoMessageReceived, context.DeadlineExceeded) ctx5, cancel5 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel5() - if message, err := client2.RunUntilMessage(ctx5); err == nil { - assert.Fail("Expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client2.RunUntilErrorIs(ctx5, ErrNoMessageReceived, context.DeadlineExceeded) }) } } func TestBackendServer_RoomMessage(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) _, _, _, hub, _, server := CreateBackendServerForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - require.NoError(client.SendHello(testDefaultUserId + "1")) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - _, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + defer client.CloseWithBye() // Join room by id. roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Ignore "join" events. - assert.NoError(client.DrainMessages(ctx)) + client.RunUntilJoined(ctx, hello.Hello) messageData := json.RawMessage("{\"foo\":\"bar\"}") - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "message", - Message: &BackendRoomMessageRequest{ + Message: &talk.BackendRoomMessageRequest{ Data: messageData, }, } @@ -1359,7 +1307,7 @@ func TestBackendServer_RoomMessage(t *testing.T) { assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - if message, err := client.RunUntilRoomMessage(ctx); assert.NoError(err) { + if message, ok := client.RunUntilRoomMessage(ctx); ok { assert.Equal(roomId, message.RoomId) assert.Equal(string(messageData), string(message.Data)) } @@ -1367,7 +1315,6 @@ func TestBackendServer_RoomMessage(t *testing.T) { func TestBackendServer_TurnCredentials(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) _, _, _, _, _, server := CreateBackendServerForTestWithTurn(t) @@ -1385,19 +1332,19 @@ func TestBackendServer_TurnCredentials(t *testing.T) { assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - var cred TurnCredentials + var cred talk.TurnCredentials require.NoError(json.Unmarshal(body, &cred)) m := hmac.New(sha1.New, []byte(turnSecret)) m.Write([]byte(cred.Username)) // nolint password := base64.StdEncoding.EncodeToString(m.Sum(nil)) assert.Equal(password, cred.Password) - assert.EqualValues((24 * time.Hour).Seconds(), cred.TTL) + assert.InEpsilon((24 * time.Hour).Seconds(), cred.TTL, 0.0001) assert.Equal(turnServers, cred.URIs) } func TestBackendServer_StatsAllowedIps(t *testing.T) { - CatchLogForTest(t) + t.Parallel() config := goconf.NewConfigFile() config.AddOption("app", "trustedproxies", "1.2.3.4") config.AddOption("stats", "allowed_ips", "127.0.0.1, 192.168.0.1, 192.168.1.1/24") @@ -1416,7 +1363,6 @@ func TestBackendServer_StatsAllowedIps(t *testing.T) { } for _, addr := range allowed { - addr := addr t.Run(addr, func(t *testing.T) { t.Parallel() assert := assert.New(t) @@ -1456,7 +1402,6 @@ func TestBackendServer_StatsAllowedIps(t *testing.T) { } for _, addr := range notAllowed { - addr := addr t.Run(addr, func(t *testing.T) { t.Parallel() r := &http.Request{ @@ -1495,7 +1440,8 @@ func Test_IsNumeric(t *testing.T) { func TestBackendServer_DialoutNoSipBridge(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) _, _, _, hub, _, server := CreateBackendServerForTest(t) @@ -1504,16 +1450,15 @@ func TestBackendServer_DialoutNoSipBridge(t *testing.T) { defer client.CloseWithBye() require.NoError(client.SendHelloInternal()) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - _, err := client.RunUntilHello(ctx) - require.NoError(err) + MustSucceed1(t, client.RunUntilHello, ctx) roomId := "12345" - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "dialout", - Dialout: &BackendRoomDialoutRequest{ + Dialout: &talk.BackendRoomDialoutRequest{ Number: "+1234567890", }, } @@ -1527,7 +1472,7 @@ func TestBackendServer_DialoutNoSipBridge(t *testing.T) { assert.NoError(err) require.Equal(http.StatusNotFound, res.StatusCode, "Expected error, got %s", string(body)) - var response BackendServerRoomResponse + var response talk.BackendServerRoomResponse if assert.NoError(json.Unmarshal(body, &response)) { assert.Equal("dialout", response.Type) if assert.NotNil(response.Dialout) && @@ -1539,7 +1484,8 @@ func TestBackendServer_DialoutNoSipBridge(t *testing.T) { func TestBackendServer_DialoutAccepted(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) _, _, _, hub, _, server := CreateBackendServerForTest(t) @@ -1548,11 +1494,10 @@ func TestBackendServer_DialoutAccepted(t *testing.T) { defer client.CloseWithBye() require.NoError(client.SendHelloInternalWithFeatures([]string{"start-dialout"})) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - _, err := client.RunUntilHello(ctx) - require.NoError(err) + MustSucceed1(t, client.RunUntilHello, ctx) roomId := "12345" callId := "call-123" @@ -1561,8 +1506,8 @@ func TestBackendServer_DialoutAccepted(t *testing.T) { go func() { defer close(stopped) - msg, err := client.RunUntilMessage(ctx) - if !assert.NoError(err) { + msg, ok := client.RunUntilMessage(ctx) + if !ok { return } @@ -1576,15 +1521,15 @@ func TestBackendServer_DialoutAccepted(t *testing.T) { assert.Equal(roomId, msg.Internal.Dialout.RoomId) assert.Equal(server.URL+"/", msg.Internal.Dialout.Backend) - response := &ClientMessage{ + response := &api.ClientMessage{ Id: msg.Id, Type: "internal", - Internal: &InternalClientMessage{ + Internal: &api.InternalClientMessage{ Type: "dialout", - Dialout: &DialoutInternalClientMessage{ + Dialout: &api.DialoutInternalClientMessage{ Type: "status", RoomId: msg.Internal.Dialout.RoomId, - Status: &DialoutStatusInternalClientMessage{ + Status: &api.DialoutStatusInternalClientMessage{ Status: "accepted", CallId: callId, }, @@ -1598,9 +1543,9 @@ func TestBackendServer_DialoutAccepted(t *testing.T) { <-stopped }() - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "dialout", - Dialout: &BackendRoomDialoutRequest{ + Dialout: &talk.BackendRoomDialoutRequest{ Number: "+1234567890", }, } @@ -1614,7 +1559,7 @@ func TestBackendServer_DialoutAccepted(t *testing.T) { assert.NoError(err) require.Equal(http.StatusOK, res.StatusCode, "Expected success, got %s", string(body)) - var response BackendServerRoomResponse + var response talk.BackendServerRoomResponse if err := json.Unmarshal(body, &response); assert.NoError(err) { assert.Equal("dialout", response.Type) if assert.NotNil(response.Dialout) { @@ -1626,7 +1571,8 @@ func TestBackendServer_DialoutAccepted(t *testing.T) { func TestBackendServer_DialoutAcceptedCompat(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) _, _, _, hub, _, server := CreateBackendServerForTest(t) @@ -1635,11 +1581,10 @@ func TestBackendServer_DialoutAcceptedCompat(t *testing.T) { defer client.CloseWithBye() require.NoError(client.SendHelloInternalWithFeatures([]string{"start-dialout"})) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - _, err := client.RunUntilHello(ctx) - require.NoError(err) + MustSucceed1(t, client.RunUntilHello, ctx) roomId := "12345" callId := "call-123" @@ -1648,8 +1593,8 @@ func TestBackendServer_DialoutAcceptedCompat(t *testing.T) { go func() { defer close(stopped) - msg, err := client.RunUntilMessage(ctx) - if !assert.NoError(err) { + msg, ok := client.RunUntilMessage(ctx) + if !ok { return } @@ -1663,15 +1608,15 @@ func TestBackendServer_DialoutAcceptedCompat(t *testing.T) { assert.Equal(roomId, msg.Internal.Dialout.RoomId) assert.Equal(server.URL+"/", msg.Internal.Dialout.Backend) - response := &ClientMessage{ + response := &api.ClientMessage{ Id: msg.Id, Type: "internal", - Internal: &InternalClientMessage{ + Internal: &api.InternalClientMessage{ Type: "dialout", - Dialout: &DialoutInternalClientMessage{ + Dialout: &api.DialoutInternalClientMessage{ Type: "status", RoomId: msg.Internal.Dialout.RoomId, - Status: &DialoutStatusInternalClientMessage{ + Status: &api.DialoutStatusInternalClientMessage{ Status: "accepted", CallId: callId, }, @@ -1685,9 +1630,9 @@ func TestBackendServer_DialoutAcceptedCompat(t *testing.T) { <-stopped }() - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "dialout", - Dialout: &BackendRoomDialoutRequest{ + Dialout: &talk.BackendRoomDialoutRequest{ Number: "+1234567890", }, } @@ -1701,7 +1646,7 @@ func TestBackendServer_DialoutAcceptedCompat(t *testing.T) { assert.NoError(err) require.Equal(http.StatusOK, res.StatusCode, "Expected success, got %s", string(body)) - var response BackendServerRoomResponse + var response talk.BackendServerRoomResponse if err := json.Unmarshal(body, &response); assert.NoError(err) { assert.Equal("dialout", response.Type) if assert.NotNil(response.Dialout) { @@ -1713,7 +1658,8 @@ func TestBackendServer_DialoutAcceptedCompat(t *testing.T) { func TestBackendServer_DialoutRejected(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) _, _, _, hub, _, server := CreateBackendServerForTest(t) @@ -1722,11 +1668,10 @@ func TestBackendServer_DialoutRejected(t *testing.T) { defer client.CloseWithBye() require.NoError(client.SendHelloInternalWithFeatures([]string{"start-dialout"})) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - _, err := client.RunUntilHello(ctx) - require.NoError(err) + MustSucceed1(t, client.RunUntilHello, ctx) roomId := "12345" errorCode := "error-code" @@ -1736,8 +1681,8 @@ func TestBackendServer_DialoutRejected(t *testing.T) { go func() { defer close(stopped) - msg, err := client.RunUntilMessage(ctx) - if !assert.NoError(err) { + msg, ok := client.RunUntilMessage(ctx) + if !ok { return } @@ -1751,14 +1696,14 @@ func TestBackendServer_DialoutRejected(t *testing.T) { assert.Equal(roomId, msg.Internal.Dialout.RoomId) assert.Equal(server.URL+"/", msg.Internal.Dialout.Backend) - response := &ClientMessage{ + response := &api.ClientMessage{ Id: msg.Id, Type: "internal", - Internal: &InternalClientMessage{ + Internal: &api.InternalClientMessage{ Type: "dialout", - Dialout: &DialoutInternalClientMessage{ + Dialout: &api.DialoutInternalClientMessage{ Type: "error", - Error: NewError(errorCode, errorMessage), + Error: api.NewError(errorCode, errorMessage), }, }, } @@ -1769,9 +1714,9 @@ func TestBackendServer_DialoutRejected(t *testing.T) { <-stopped }() - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "dialout", - Dialout: &BackendRoomDialoutRequest{ + Dialout: &talk.BackendRoomDialoutRequest{ Number: "+1234567890", }, } @@ -1785,7 +1730,7 @@ func TestBackendServer_DialoutRejected(t *testing.T) { assert.NoError(err) require.Equal(http.StatusBadGateway, res.StatusCode, "Expected error, got %s", string(body)) - var response BackendServerRoomResponse + var response talk.BackendServerRoomResponse if err := json.Unmarshal(body, &response); assert.NoError(err) { assert.Equal("dialout", response.Type) if assert.NotNil(response.Dialout) && @@ -1795,3 +1740,116 @@ func TestBackendServer_DialoutRejected(t *testing.T) { } } } + +func TestBackendServer_DialoutFirstFailed(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + require := require.New(t) + assert := assert.New(t) + _, _, _, hub, _, server := CreateBackendServerForTest(t) + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + require.NoError(client1.SendHelloInternalWithFeatures([]string{"start-dialout"})) + + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + require.NoError(client2.SendHelloInternalWithFeatures([]string{"start-dialout"})) + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + MustSucceed1(t, client1.RunUntilHello, ctx) + MustSucceed1(t, client2.RunUntilHello, ctx) + + roomId := "12345" + callId := "call-123" + + var returnedError atomic.Bool + + var wg sync.WaitGroup + runClient := func(client *TestClient) { + msg, ok := client.RunUntilMessage(ctx) + if !ok { + return + } + + if !assert.Equal("internal", msg.Type) || + !assert.NotNil(msg.Internal) || + !assert.Equal("dialout", msg.Internal.Type) || + !assert.NotNil(msg.Internal.Dialout) { + return + } + + assert.Equal(roomId, msg.Internal.Dialout.RoomId) + assert.Equal(server.URL+"/", msg.Internal.Dialout.Backend) + + var dialout *api.DialoutInternalClientMessage + // The first session should return an error to make sure the second is retried afterwards. + if returnedError.CompareAndSwap(false, true) { + errorCode := "error-code" + errorMessage := "rejected call" + + dialout = &api.DialoutInternalClientMessage{ + Type: "error", + Error: api.NewError(errorCode, errorMessage), + } + } else { + dialout = &api.DialoutInternalClientMessage{ + Type: "status", + RoomId: msg.Internal.Dialout.RoomId, + Status: &api.DialoutStatusInternalClientMessage{ + Status: "accepted", + CallId: callId, + }, + } + } + + response := &api.ClientMessage{ + Id: msg.Id, + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "dialout", + Dialout: dialout, + }, + } + assert.NoError(client.WriteJSON(response)) + } + + wg.Go(func() { + runClient(client1) + }) + wg.Go(func() { + runClient(client2) + }) + + defer func() { + wg.Wait() + }() + + msg := &talk.BackendServerRoomRequest{ + Type: "dialout", + Dialout: &talk.BackendRoomDialoutRequest{ + Number: "+1234567890", + }, + } + + data, err := json.Marshal(msg) + require.NoError(err) + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + require.NoError(err) + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + assert.NoError(err) + require.Equal(http.StatusOK, res.StatusCode, "Expected success, got %s", string(body)) + + var response talk.BackendServerRoomResponse + if err := json.Unmarshal(body, &response); assert.NoError(err) { + assert.Equal("dialout", response.Type) + if assert.NotNil(response.Dialout) { + assert.Nil(response.Dialout.Error, "expected dialout success, got %s", string(body)) + assert.Equal(callId, response.Dialout.CallId) + } + } +} diff --git a/server/clientsession.go b/server/clientsession.go new file mode 100644 index 0000000..2ad59c1 --- /dev/null +++ b/server/clientsession.go @@ -0,0 +1,1691 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "net/url" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/pion/sdp/v3" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" + "github.com/strukturag/nextcloud-spreed-signaling/v2/session" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +var ( + // Warn if a session has 32 or more pending messages. + warnPendingMessagesCount = 32 + + // The "/api/v1/signaling/" URL will be changed to use "v3" as the "signaling-v3" + // feature is returned by the capabilities endpoint. + PathToOcsSignalingBackend = "ocs/v2.php/apps/spreed/api/v1/signaling/backend" +) + +// ResponseHandlerFunc will return "true" has been fully processed. +type ResponseHandlerFunc func(message *api.ClientMessage) bool + +type ClientSession struct { + logger log.Logger + hub *Hub + events events.AsyncEvents + privateId api.PrivateSessionId + publicId api.PublicSessionId + data *session.SessionIdData + ctx context.Context + closeFunc context.CancelFunc + + clientType api.ClientType + features []string + userId string + userData json.RawMessage + + parseUserData func() (api.StringMap, error) + + inCall internal.Flags + // +checklocks:mu + supportsPermissions bool + // +checklocks:mu + permissions map[api.Permission]bool + + backend *talk.Backend + backendUrl string + parsedBackendUrl *url.URL + + mu sync.Mutex + asyncCh events.AsyncChannel + + // +checklocks:mu + client ClientWithSession + room atomic.Pointer[Room] + roomJoinTime atomic.Int64 + federation atomic.Pointer[FederationClient] + + roomSessionIdLock sync.RWMutex + // +checklocks:roomSessionIdLock + roomSessionId api.RoomSessionId + + publishersCond sync.Cond + + // +checklocks:mu + publishers map[sfu.StreamType]sfu.Publisher + // +checklocks:mu + subscribers map[sfu.StreamId]sfu.Subscriber + + // +checklocks:mu + pendingClientMessages []*api.ServerMessage + // +checklocks:mu + hasPendingChat bool + // +checklocks:mu + hasPendingParticipantsUpdate bool + + // +checklocks:mu + virtualSessions map[*VirtualSession]bool + + filterDuplicateLock sync.Mutex + // +checklocks:filterDuplicateLock + seenJoinedEvents map[api.PublicSessionId]bool + // +checklocks:filterDuplicateLock + seenFlags map[api.PublicSessionId]uint32 + + responseHandlersLock sync.Mutex + // +checklocks:responseHandlersLock + responseHandlers map[string]ResponseHandlerFunc +} + +func NewClientSession(hub *Hub, privateId api.PrivateSessionId, publicId api.PublicSessionId, data *session.SessionIdData, backend *talk.Backend, hello *api.HelloClientMessage, auth *talk.BackendClientAuthResponse) (*ClientSession, error) { + ctx := log.NewLoggerContext(context.Background(), hub.logger) + ctx, closeFunc := context.WithCancel(ctx) + s := &ClientSession{ + logger: hub.logger, + hub: hub, + events: hub.events, + privateId: privateId, + publicId: publicId, + data: data, + ctx: ctx, + closeFunc: closeFunc, + + clientType: hello.Auth.Type, + features: hello.Features, + userId: auth.UserId, + userData: auth.User, + parseUserData: parseUserData(auth.User), + + backend: backend, + asyncCh: make(events.AsyncChannel, events.DefaultAsyncChannelSize), + } + s.publishersCond.L = &s.mu + if s.clientType == api.HelloClientTypeInternal { + s.backendUrl = hello.Auth.InternalParams.Backend + s.parsedBackendUrl = hello.Auth.InternalParams.ParsedBackend + if !s.HasFeature(api.ClientFeatureInternalInCall) { + s.SetInCall(FlagInCall | FlagWithAudio) + } + } else { + s.backendUrl = hello.Auth.Url + s.parsedBackendUrl = hello.Auth.ParsedUrl + } + + if err := s.SubscribeEvents(); err != nil { + return nil, err + } + go s.run() + return s, nil +} + +func (s *ClientSession) Context() context.Context { + return s.ctx +} + +func (s *ClientSession) PrivateId() api.PrivateSessionId { + return s.privateId +} + +func (s *ClientSession) PublicId() api.PublicSessionId { + return s.publicId +} + +func (s *ClientSession) RoomSessionId() api.RoomSessionId { + s.roomSessionIdLock.RLock() + defer s.roomSessionIdLock.RUnlock() + return s.roomSessionId +} + +func (s *ClientSession) Data() *session.SessionIdData { + return s.data +} + +func (s *ClientSession) ClientType() api.ClientType { + return s.clientType +} + +// GetInCall is only used for internal clients. +func (s *ClientSession) GetInCall() int { + return int(s.inCall.Get()) +} + +func (s *ClientSession) SetInCall(inCall int) bool { + if inCall < 0 { + inCall = 0 + } + + return s.inCall.Set(uint32(inCall)) +} + +func (s *ClientSession) GetFeatures() []string { + return s.features +} + +func (s *ClientSession) HasFeature(feature string) bool { + return slices.Contains(s.features, feature) +} + +// HasPermission checks if the session has the passed permissions. +func (s *ClientSession) HasPermission(permission api.Permission) bool { + s.mu.Lock() + defer s.mu.Unlock() + + return s.hasPermissionLocked(permission) +} + +func (s *ClientSession) GetPermissions() []api.Permission { + s.mu.Lock() + defer s.mu.Unlock() + + result := make([]api.Permission, len(s.permissions)) + for p, ok := range s.permissions { + if ok { + result = append(result, p) + } + } + return result +} + +// HasAnyPermission checks if the session has one of the passed permissions. +func (s *ClientSession) HasAnyPermission(permission ...api.Permission) bool { + if len(permission) == 0 { + return false + } + + s.mu.Lock() + defer s.mu.Unlock() + + return s.hasAnyPermissionLocked(permission...) +} + +// +checklocks:s.mu +func (s *ClientSession) hasAnyPermissionLocked(permission ...api.Permission) bool { + if len(permission) == 0 { + return false + } + + return slices.ContainsFunc(permission, s.hasPermissionLocked) +} + +// +checklocks:s.mu +func (s *ClientSession) hasPermissionLocked(permission api.Permission) bool { + if !s.supportsPermissions { + // Old-style session that doesn't receive permissions from Nextcloud. + if result, found := api.DefaultPermissionOverrides[permission]; found { + return result + } + return true + } + + if val, found := s.permissions[permission]; found { + return val + } + return false +} + +func (s *ClientSession) SetPermissions(permissions []api.Permission) { + var p map[api.Permission]bool + for _, permission := range permissions { + if p == nil { + p = make(map[api.Permission]bool) + } + p[permission] = true + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.supportsPermissions && maps.Equal(s.permissions, p) { + return + } + + s.permissions = p + s.supportsPermissions = true + s.logger.Printf("Permissions of session %s changed: %s", s.PublicId(), permissions) +} + +func (s *ClientSession) Backend() *talk.Backend { + return s.backend +} + +func (s *ClientSession) BackendUrl() string { + return s.backendUrl +} + +func (s *ClientSession) ParsedBackendUrl() *url.URL { + return s.parsedBackendUrl +} + +func (s *ClientSession) ParsedBackendOcsUrl() *url.URL { + return s.parsedBackendUrl.JoinPath(PathToOcsSignalingBackend) +} + +func (s *ClientSession) AuthUserId() string { + return s.userId +} + +func (s *ClientSession) UserId() string { + userId := s.userId + if userId == "" { + if room := s.GetRoom(); room != nil { + if data := room.GetRoomSessionData(s); data != nil { + userId = data.UserId + } + } + } + return userId +} + +func (s *ClientSession) UserData() json.RawMessage { + return s.userData +} + +func (s *ClientSession) ParsedUserData() (api.StringMap, error) { + return s.parseUserData() +} + +func (s *ClientSession) SetRoom(room *Room, joinTime time.Time) { + s.room.Store(room) + s.onRoomSet(room != nil, joinTime) +} + +func (s *ClientSession) onRoomSet(hasRoom bool, joinTime time.Time) { + if hasRoom { + s.roomJoinTime.Store(joinTime.UnixNano()) + } else { + s.roomJoinTime.Store(0) + } + + s.filterDuplicateLock.Lock() + defer s.filterDuplicateLock.Unlock() + s.seenJoinedEvents = nil + s.seenFlags = nil +} + +func (s *ClientSession) IsInRoom(id string) bool { + room := s.GetRoom() + return room != nil && room.Id() == id +} + +func (s *ClientSession) GetFederationClient() *FederationClient { + return s.federation.Load() +} + +func (s *ClientSession) SetFederationClient(federation *FederationClient) { + s.mu.Lock() + defer s.mu.Unlock() + + s.doLeaveRoom(true) + s.onRoomSet(federation != nil, time.Now()) + + if prev := s.federation.Swap(federation); prev != nil && prev != federation { + prev.Close() + } +} + +func (s *ClientSession) GetRoom() *Room { + return s.room.Load() +} + +func (s *ClientSession) getRoomJoinTime() time.Time { + t := s.roomJoinTime.Load() + if t == 0 { + return time.Time{} + } + + return time.Unix(0, t) +} + +// +checklocks:s.mu +func (s *ClientSession) releaseMcuObjects() { + if len(s.publishers) > 0 { + go func(publishers map[sfu.StreamType]sfu.Publisher) { + ctx := context.Background() + for _, publisher := range publishers { + publisher.Close(ctx) + } + }(s.publishers) + s.publishers = nil + } + if len(s.subscribers) > 0 { + go func(subscribers map[sfu.StreamId]sfu.Subscriber) { + ctx := context.Background() + for _, subscriber := range subscribers { + subscriber.Close(ctx) + } + }(s.subscribers) + s.subscribers = nil + } +} + +func (s *ClientSession) AsyncChannel() events.AsyncChannel { + return s.asyncCh +} + +func (s *ClientSession) run() { + for { + select { + case <-s.ctx.Done(): + return + case msg := <-s.asyncCh: + s.processAsyncNatsMessage(msg) + for count := len(s.asyncCh); count > 0; count-- { + s.processAsyncNatsMessage(<-s.asyncCh) + } + } + } +} + +func (s *ClientSession) Close() { + s.closeAndWait(true) +} + +func (s *ClientSession) closeAndWait(wait bool) { + s.closeFunc() + s.hub.removeSession(s) + + if prev := s.federation.Swap(nil); prev != nil { + prev.Close() + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.userId != "" { + if err := s.events.UnregisterUserListener(s.userId, s.backend, s); err != nil && !errors.Is(err, nats.ErrConnectionClosed) { + s.logger.Printf("Error unsubscribing user listener for %s in session %s: %s", s.userId, s.publicId, err) + } + } + if err := s.events.UnregisterSessionListener(s.publicId, s.backend, s); err != nil && !errors.Is(err, nats.ErrConnectionClosed) { + s.logger.Printf("Error unsubscribing listener in session %s: %s", s.publicId, err) + } + go func(virtualSessions map[*VirtualSession]bool) { + for session := range virtualSessions { + session.Close() + } + }(s.virtualSessions) + s.virtualSessions = nil + s.releaseMcuObjects() + s.clearClientLocked(nil) + s.backend.RemoveSession(s) +} + +func (s *ClientSession) SubscribeEvents() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.userId != "" { + if err := s.events.RegisterUserListener(s.userId, s.backend, s); err != nil { + return err + } + } + + return s.events.RegisterSessionListener(s.publicId, s.backend, s) +} + +func (s *ClientSession) UpdateRoomSessionId(roomSessionId api.RoomSessionId) error { + s.roomSessionIdLock.Lock() + defer s.roomSessionIdLock.Unlock() + + if s.roomSessionId == roomSessionId { + return nil + } + + if err := s.hub.roomSessions.SetRoomSession(s, roomSessionId); err != nil { + return err + } + + if roomSessionId != "" { + if room := s.GetRoom(); room != nil { + s.logger.Printf("Session %s updated room session id to %s in room %s", s.PublicId(), roomSessionId, room.Id()) + } else if client := s.GetFederationClient(); client != nil { + s.logger.Printf("Session %s updated room session id to %s in federated room %s", s.PublicId(), roomSessionId, client.RemoteRoomId()) + } else { + s.logger.Printf("Session %s updated room session id to %s in unknown room", s.PublicId(), roomSessionId) + } + } else { + if room := s.GetRoom(); room != nil { + s.logger.Printf("Session %s cleared room session id in room %s", s.PublicId(), room.Id()) + } else if client := s.GetFederationClient(); client != nil { + s.logger.Printf("Session %s cleared room session id in federated room %s", s.PublicId(), client.RemoteRoomId()) + } else { + s.logger.Printf("Session %s cleared room session id in unknown room", s.PublicId()) + } + } + + s.roomSessionId = roomSessionId + return nil +} + +func (s *ClientSession) SubscribeRoomEvents(roomid string, roomSessionId api.RoomSessionId) error { + s.roomSessionIdLock.Lock() + defer s.roomSessionIdLock.Unlock() + + if err := s.events.RegisterRoomListener(roomid, s.backend, s); err != nil { + return err + } + + if roomSessionId != "" { + if err := s.hub.roomSessions.SetRoomSession(s, roomSessionId); err != nil { + s.doUnsubscribeRoomEvents(true) + return err + } + } + s.logger.Printf("Session %s joined room %s with room session id %s", s.PublicId(), roomid, roomSessionId) + s.roomSessionId = roomSessionId + return nil +} + +func (s *ClientSession) LeaveCall() { + s.mu.Lock() + defer s.mu.Unlock() + + room := s.GetRoom() + if room == nil { + return + } + + s.logger.Printf("Session %s left call %s", s.PublicId(), room.Id()) + s.releaseMcuObjects() +} + +func (s *ClientSession) LeaveRoom(notify bool) *Room { + return s.LeaveRoomWithMessage(notify, nil) +} + +func (s *ClientSession) LeaveRoomWithMessage(notify bool, message *api.ClientMessage) *Room { + if prev := s.federation.Swap(nil); prev != nil { + // Session was connected to a federation room. + if err := prev.Leave(message); err != nil { + s.logger.Printf("Error leaving room for session %s on federation client %s: %s", s.PublicId(), prev.URL(), err) + prev.Close() + } + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + return s.doLeaveRoom(notify) +} + +// +checklocks:s.mu +func (s *ClientSession) doLeaveRoom(notify bool) *Room { + room := s.GetRoom() + if room == nil { + return nil + } + + s.doUnsubscribeRoomEvents(notify) + s.SetRoom(nil, time.Time{}) + s.releaseMcuObjects() + room.RemoveSession(s) + return room +} + +func (s *ClientSession) UnsubscribeRoomEvents() { + s.mu.Lock() + defer s.mu.Unlock() + + s.doUnsubscribeRoomEvents(true) +} + +func (s *ClientSession) doUnsubscribeRoomEvents(notify bool) { + room := s.GetRoom() + if room != nil { + if err := s.events.UnregisterRoomListener(room.Id(), s.Backend(), s); err != nil && !errors.Is(err, nats.ErrConnectionClosed) { + s.logger.Printf("Error unsubscribing room listener for %s in session %s: %s", room.Id(), s.publicId, err) + } + } + s.hub.roomSessions.DeleteRoomSession(s) + + s.roomSessionIdLock.Lock() + defer s.roomSessionIdLock.Unlock() + if notify && room != nil && s.roomSessionId != "" && !s.roomSessionId.IsFederated() { + // Notify + go func(sid api.RoomSessionId) { + ctx := log.NewLoggerContext(context.Background(), s.logger) + request := talk.NewBackendClientRoomRequest(room.Id(), s.userId, sid) + request.Room.UpdateFromSession(s) + request.Room.Action = "leave" + var response api.StringMap + if err := s.hub.backend.PerformJSONRequest(ctx, s.ParsedBackendOcsUrl(), request, &response); err != nil { + s.logger.Printf("Could not notify about room session %s left room %s: %s", sid, room.Id(), err) + } else { + s.logger.Printf("Removed room session %s: %+v", sid, response) + } + }(s.roomSessionId) + } + s.roomSessionId = "" +} + +func (s *ClientSession) ClearClient(client ClientWithSession) { + s.mu.Lock() + defer s.mu.Unlock() + + s.clearClientLocked(client) +} + +// +checklocks:s.mu +func (s *ClientSession) clearClientLocked(client ClientWithSession) { + if s.client == nil { + return + } else if client != nil && s.client != client { + s.logger.Printf("Trying to clear other client in session %s", s.PublicId()) + return + } + + prevClient := s.client + s.client = nil + prevClient.SetSession(nil) +} + +func (s *ClientSession) GetClient() ClientWithSession { + s.mu.Lock() + defer s.mu.Unlock() + + return s.getClientUnlocked() +} + +// +checklocks:s.mu +func (s *ClientSession) getClientUnlocked() ClientWithSession { + return s.client +} + +func (s *ClientSession) SetClient(client ClientWithSession) ClientWithSession { + if client == nil { + panic("Use ClearClient to set the client to nil") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if client == s.client { + // No change + return nil + } + + client.SetSession(s) + prev := s.client + if prev != nil { + s.clearClientLocked(prev) + } + s.client = client + return prev +} + +// +checklocks:s.mu +func (s *ClientSession) sendOffer(client sfu.Client, sender api.PublicSessionId, streamType sfu.StreamType, offer api.StringMap) { + offer_message := &api.AnswerOfferMessage{ + To: s.PublicId(), + From: sender, + Type: "offer", + RoomType: string(streamType), + Payload: offer, + Sid: client.Sid(), + } + offer_data, err := json.Marshal(offer_message) + if err != nil { + s.logger.Println("Could not serialize offer", offer_message, err) + return + } + response_message := &api.ServerMessage{ + Type: "message", + Message: &api.MessageServerMessage{ + Sender: &api.MessageServerMessageSender{ + Type: "session", + SessionId: sender, + }, + Data: offer_data, + }, + } + + s.sendMessageUnlocked(response_message) +} + +// +checklocks:s.mu +func (s *ClientSession) sendCandidate(client sfu.Client, sender api.PublicSessionId, streamType sfu.StreamType, candidate any) { + candidate_message := &api.AnswerOfferMessage{ + To: s.PublicId(), + From: sender, + Type: "candidate", + RoomType: string(streamType), + Payload: api.StringMap{ + "candidate": candidate, + }, + Sid: client.Sid(), + } + candidate_data, err := json.Marshal(candidate_message) + if err != nil { + s.logger.Println("Could not serialize candidate", candidate_message, err) + return + } + response_message := &api.ServerMessage{ + Type: "message", + Message: &api.MessageServerMessage{ + Sender: &api.MessageServerMessageSender{ + Type: "session", + SessionId: sender, + }, + Data: candidate_data, + }, + } + + s.sendMessageUnlocked(response_message) +} + +// +checklocks:s.mu +func (s *ClientSession) sendMessageUnlocked(message *api.ServerMessage) { + if c := s.getClientUnlocked(); c != nil { + if c.SendMessage(message) { + return + } + } + + s.storePendingMessage(message) +} + +func (s *ClientSession) SendError(e *api.Error) bool { + message := &api.ServerMessage{ + Type: "error", + Error: e, + } + return s.SendMessage(message) +} + +func (s *ClientSession) SendMessage(message *api.ServerMessage) bool { + message, messages := s.filterMessage(message) + if message == nil && len(messages) == 0 { + return true + } + + s.mu.Lock() + defer s.mu.Unlock() + + if message != nil { + s.sendMessageUnlocked(message) + } + for _, msg := range messages { + s.sendMessageUnlocked(msg) + } + return true +} + +func (s *ClientSession) SendMessages(messages []*api.ServerMessage) bool { + s.mu.Lock() + defer s.mu.Unlock() + + for _, message := range messages { + s.sendMessageUnlocked(message) + } + return true +} + +func (s *ClientSession) OnUpdateOffer(client sfu.Client, offer api.StringMap) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, sub := range s.subscribers { + if sub.Id() == client.Id() { + s.sendOffer(client, sub.Publisher(), client.StreamType(), offer) + return + } + } +} + +func (s *ClientSession) OnIceCandidate(client sfu.Client, candidate any) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, sub := range s.subscribers { + if sub.Id() == client.Id() { + s.sendCandidate(client, sub.Publisher(), client.StreamType(), candidate) + return + } + } + + for _, pub := range s.publishers { + if pub.Id() == client.Id() { + s.sendCandidate(client, s.PublicId(), client.StreamType(), candidate) + return + } + } + + s.logger.Printf("Session %s received candidate %+v for unknown client %s", s.PublicId(), candidate, client.Id()) +} + +func (s *ClientSession) OnIceCompleted(client sfu.Client) { + // TODO(jojo): This causes a JavaScript error when creating a candidate from "null". + // Figure out a better way to signal this. + + // An empty candidate signals the end of candidates. + // s.OnIceCandidate(client, nil) +} + +func (s *ClientSession) SubscriberSidUpdated(subscriber sfu.Subscriber) { +} + +func (s *ClientSession) PublisherClosed(publisher sfu.Publisher) { + s.mu.Lock() + defer s.mu.Unlock() + + for id, p := range s.publishers { + if p == publisher { + delete(s.publishers, id) + break + } + } +} + +func (s *ClientSession) SubscriberClosed(subscriber sfu.Subscriber) { + s.mu.Lock() + defer s.mu.Unlock() + + for id, sub := range s.subscribers { + if sub == subscriber { + delete(s.subscribers, id) + break + } + } +} + +type PermissionError struct { + permission api.Permission +} + +func (e *PermissionError) Permission() api.Permission { + return e.permission +} + +func (e *PermissionError) Error() string { + return fmt.Sprintf("permission \"%s\" not found", e.permission) +} + +// +checklocks:s.mu +func (s *ClientSession) isSdpAllowedToSendLocked(sdp *sdp.SessionDescription) (sfu.MediaType, error) { + if sdp == nil { + // Should have already been checked when data was validated. + return 0, api.ErrNoSdp + } + + var mediaTypes sfu.MediaType + mayPublishMedia := s.hasPermissionLocked(api.PERMISSION_MAY_PUBLISH_MEDIA) + for _, md := range sdp.MediaDescriptions { + switch md.MediaName.Media { + case "audio": + if !mayPublishMedia && !s.hasPermissionLocked(api.PERMISSION_MAY_PUBLISH_AUDIO) { + return 0, &PermissionError{api.PERMISSION_MAY_PUBLISH_AUDIO} + } + + mediaTypes |= sfu.MediaTypeAudio + case "video": + if !mayPublishMedia && !s.hasPermissionLocked(api.PERMISSION_MAY_PUBLISH_VIDEO) { + return 0, &PermissionError{api.PERMISSION_MAY_PUBLISH_VIDEO} + } + + mediaTypes |= sfu.MediaTypeVideo + } + } + + return mediaTypes, nil +} + +func (s *ClientSession) IsAllowedToSend(data *api.MessageClientMessageData) error { + s.mu.Lock() + defer s.mu.Unlock() + + switch { + case data != nil && data.RoomType == "screen": + if s.hasPermissionLocked(api.PERMISSION_MAY_PUBLISH_SCREEN) { + return nil + } + return &PermissionError{api.PERMISSION_MAY_PUBLISH_SCREEN} + case s.hasPermissionLocked(api.PERMISSION_MAY_PUBLISH_MEDIA): + // Client is allowed to publish any media (audio / video). + return nil + case data != nil && data.Type == "offer": + // Check what user is trying to publish and check permissions accordingly. + if _, err := s.isSdpAllowedToSendLocked(data.OfferSdp); err != nil { + return err + } + + return nil + default: + // Candidate or unknown event, check if client is allowed to publish any media. + if s.hasAnyPermissionLocked(api.PERMISSION_MAY_PUBLISH_AUDIO, api.PERMISSION_MAY_PUBLISH_VIDEO) { + return nil + } + + return errors.New("permission check failed") + } +} + +func (s *ClientSession) CheckOfferType(streamType sfu.StreamType, data *api.MessageClientMessageData) (sfu.MediaType, error) { + s.mu.Lock() + defer s.mu.Unlock() + + return s.checkOfferTypeLocked(streamType, data) +} + +// +checklocks:s.mu +func (s *ClientSession) checkOfferTypeLocked(streamType sfu.StreamType, data *api.MessageClientMessageData) (sfu.MediaType, error) { + if streamType == sfu.StreamTypeScreen { + if !s.hasPermissionLocked(api.PERMISSION_MAY_PUBLISH_SCREEN) { + return 0, &PermissionError{api.PERMISSION_MAY_PUBLISH_SCREEN} + } + + return sfu.MediaTypeScreen, nil + } else if data != nil && data.Type == "offer" { + mediaTypes, err := s.isSdpAllowedToSendLocked(data.OfferSdp) + if err != nil { + return 0, err + } + + return mediaTypes, nil + } + + return 0, nil +} + +func (s *ClientSession) GetOrCreatePublisher(ctx context.Context, mcu sfu.SFU, streamType sfu.StreamType, data *api.MessageClientMessageData) (sfu.Publisher, error) { + s.mu.Lock() + defer s.mu.Unlock() + + mediaTypes, err := s.checkOfferTypeLocked(streamType, data) + if err != nil { + return nil, err + } + + publisher, found := s.publishers[streamType] + if !found { + client := s.getClientUnlocked() + s.mu.Unlock() + defer s.mu.Lock() + + settings := sfu.NewPublisherSettings{ + Bitrate: data.Bitrate, + MediaTypes: mediaTypes, + + AudioCodec: data.AudioCodec, + VideoCodec: data.VideoCodec, + VP9Profile: data.VP9Profile, + H264Profile: data.H264Profile, + } + if backend := s.Backend(); backend != nil { + var maxBitrate api.Bandwidth + if streamType == sfu.StreamTypeScreen { + maxBitrate = backend.MaxScreenBitrate() + } else { + maxBitrate = backend.MaxStreamBitrate() + } + if settings.Bitrate <= 0 { + settings.Bitrate = maxBitrate + } else if maxBitrate > 0 && settings.Bitrate > maxBitrate { + settings.Bitrate = maxBitrate + } + } + var err error + publisher, err = mcu.NewPublisher(ctx, s, s.PublicId(), data.Sid, streamType, settings, client) + if err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.publishers == nil { + s.publishers = make(map[sfu.StreamType]sfu.Publisher) + } + if prev, found := s.publishers[streamType]; found { + // Another thread created the publisher while we were waiting. + go func(pub sfu.Publisher) { + closeCtx := context.Background() + pub.Close(closeCtx) + }(publisher) + publisher = prev + } else { + s.publishers[streamType] = publisher + } + s.logger.Printf("Publishing %s as %s for session %s", streamType, publisher.Id(), s.PublicId()) + s.publishersCond.Broadcast() + } else { + publisher.SetMedia(mediaTypes) + } + + return publisher, nil +} + +// +checklocks:s.mu +func (s *ClientSession) getPublisherLocked(streamType sfu.StreamType) sfu.Publisher { + return s.publishers[streamType] +} + +func (s *ClientSession) GetPublisher(streamType sfu.StreamType) sfu.Publisher { + s.mu.Lock() + defer s.mu.Unlock() + + return s.getPublisherLocked(streamType) +} + +func (s *ClientSession) GetOrWaitForPublisher(ctx context.Context, streamType sfu.StreamType) sfu.Publisher { + s.mu.Lock() + defer s.mu.Unlock() + + publisher := s.getPublisherLocked(streamType) + if publisher != nil { + return publisher + } + + stop := context.AfterFunc(ctx, func() { + s.publishersCond.Broadcast() + }) + defer stop() + + for publisher == nil { + if err := ctx.Err(); err != nil { + return nil + } + + s.publishersCond.Wait() + publisher = s.getPublisherLocked(streamType) + } + return publisher +} + +func (s *ClientSession) GetOrCreateSubscriber(ctx context.Context, mcu sfu.SFU, id api.PublicSessionId, streamType sfu.StreamType) (sfu.Subscriber, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // TODO(jojo): Add method to remove subscribers. + + subscriber, found := s.subscribers[sfu.GetStreamId(id, streamType)] + if !found { + client := s.getClientUnlocked() + s.mu.Unlock() + var err error + subscriber, err = mcu.NewSubscriber(ctx, s, id, streamType, client) + s.mu.Lock() + if err != nil { + return nil, err + } + if s.subscribers == nil { + s.subscribers = make(map[sfu.StreamId]sfu.Subscriber) + } + if prev, found := s.subscribers[sfu.GetStreamId(id, streamType)]; found { + // Another thread created the subscriber while we were waiting. + go func(sub sfu.Subscriber) { + closeCtx := context.Background() + sub.Close(closeCtx) + }(subscriber) + subscriber = prev + } else { + s.subscribers[sfu.GetStreamId(id, streamType)] = subscriber + } + s.logger.Printf("Subscribing %s from %s as %s in session %s", streamType, id, subscriber.Id(), s.PublicId()) + } + + return subscriber, nil +} + +func (s *ClientSession) GetSubscriber(id api.PublicSessionId, streamType sfu.StreamType) sfu.Subscriber { + s.mu.Lock() + defer s.mu.Unlock() + + return s.subscribers[sfu.GetStreamId(id, streamType)] +} + +func (s *ClientSession) processAsyncNatsMessage(msg *nats.Msg) { + var message events.AsyncMessage + if err := nats.Decode(msg, &message); err != nil { + s.logger.Printf("Could not decode NATS message %+v: %s", msg, err) + return + } + + s.processAsyncMessage(&message) +} + +func (s *ClientSession) processAsyncMessage(message *events.AsyncMessage) { + switch message.Type { + case "permissions": + s.SetPermissions(message.Permissions) + go func() { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.hasPermissionLocked(api.PERMISSION_MAY_PUBLISH_MEDIA) { + if publisher, found := s.publishers[sfu.StreamTypeVideo]; found { + if (publisher.HasMedia(sfu.MediaTypeAudio) && !s.hasPermissionLocked(api.PERMISSION_MAY_PUBLISH_AUDIO)) || + (publisher.HasMedia(sfu.MediaTypeVideo) && !s.hasPermissionLocked(api.PERMISSION_MAY_PUBLISH_VIDEO)) { + delete(s.publishers, sfu.StreamTypeVideo) + s.logger.Printf("Session %s is no longer allowed to publish media, closing publisher %s", s.PublicId(), publisher.Id()) + go func() { + publisher.Close(context.Background()) + }() + return + } + } + } + if !s.hasPermissionLocked(api.PERMISSION_MAY_PUBLISH_SCREEN) { + if publisher, found := s.publishers[sfu.StreamTypeScreen]; found { + delete(s.publishers, sfu.StreamTypeScreen) + s.logger.Printf("Session %s is no longer allowed to publish screen, closing publisher %s", s.PublicId(), publisher.Id()) + go func() { + publisher.Close(context.Background()) + }() + return + } + } + }() + return + case "message": + if message.Message.Type == "bye" && message.Message.Bye.Reason == "room_session_reconnected" { + s.logger.Printf("Closing session %s because same room session %s connected", s.PublicId(), s.RoomSessionId()) + s.LeaveRoom(false) + defer s.closeAndWait(false) + } + case "sendoffer": + // Process asynchronously to not block other messages received. + go func() { + ctx, cancel := context.WithTimeout(s.Context(), s.hub.mcuTimeout) + defer cancel() + + mc, err := s.GetOrCreateSubscriber(ctx, s.hub.mcu, message.SendOffer.SessionId, sfu.StreamType(message.SendOffer.Data.RoomType)) + if err != nil { + s.logger.Printf("Could not create MCU subscriber for session %s to process sendoffer in %s: %s", message.SendOffer.SessionId, s.PublicId(), err) + if err := s.events.PublishSessionMessage(message.SendOffer.SessionId, s.backend, &events.AsyncMessage{ + Type: "message", + Message: &api.ServerMessage{ + Id: message.SendOffer.MessageId, + Type: "error", + Error: api.NewError("client_not_found", "No MCU client found to send message to."), + }, + }); err != nil { + s.logger.Printf("Error sending sendoffer error response to %s: %s", message.SendOffer.SessionId, err) + } + return + } else if mc == nil { + s.logger.Printf("No MCU subscriber found for session %s to process sendoffer in %s", message.SendOffer.SessionId, s.PublicId()) + if err := s.events.PublishSessionMessage(message.SendOffer.SessionId, s.backend, &events.AsyncMessage{ + Type: "message", + Message: &api.ServerMessage{ + Id: message.SendOffer.MessageId, + Type: "error", + Error: api.NewError("client_not_found", "No MCU client found to send message to."), + }, + }); err != nil { + s.logger.Printf("Error sending sendoffer error response to %s: %s", message.SendOffer.SessionId, err) + } + return + } + + mc.SendMessage(s.Context(), nil, message.SendOffer.Data, func(err error, response api.StringMap) { + if err != nil { + s.logger.Printf("Could not send MCU message %+v for session %s to %s: %s", message.SendOffer.Data, message.SendOffer.SessionId, s.PublicId(), err) + if err := s.events.PublishSessionMessage(message.SendOffer.SessionId, s.backend, &events.AsyncMessage{ + Type: "message", + Message: &api.ServerMessage{ + Id: message.SendOffer.MessageId, + Type: "error", + Error: api.NewError("processing_failed", "Processing of the message failed, please check server logs."), + }, + }); err != nil { + s.logger.Printf("Error sending sendoffer error response to %s: %s", message.SendOffer.SessionId, err) + } + return + } else if response == nil { + // No response received + return + } + + s.hub.sendMcuMessageResponse(s, mc, &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + SessionId: message.SendOffer.SessionId, + }, + }, message.SendOffer.Data, response) + }) + }() + return + } + + serverMessage := s.filterAsyncMessage(message) + if serverMessage == nil { + return + } + + s.SendMessage(serverMessage) +} + +// +checklocks:s.mu +func (s *ClientSession) storePendingMessage(message *api.ServerMessage) { + if message.IsChatRefresh() { + if s.hasPendingChat { + // Only send a single "chat-refresh" message on resume. + return + } + + s.hasPendingChat = true + } + if !s.hasPendingParticipantsUpdate && message.IsParticipantsUpdate() { + s.hasPendingParticipantsUpdate = true + } + s.pendingClientMessages = append(s.pendingClientMessages, message) + if len(s.pendingClientMessages) >= warnPendingMessagesCount { + s.logger.Printf("Session %s has %d pending messages", s.PublicId(), len(s.pendingClientMessages)) + } +} + +func filterDisplayNames(events []api.EventServerMessageSessionEntry) []api.EventServerMessageSessionEntry { + result := make([]api.EventServerMessageSessionEntry, 0, len(events)) + for _, event := range events { + if len(event.User) == 0 { + result = append(result, event) + continue + } + + var userdata api.StringMap + if err := json.Unmarshal(event.User, &userdata); err != nil { + result = append(result, event) + continue + } + + if _, found := userdata["displayname"]; !found { + result = append(result, event) + continue + } + + delete(userdata, "displayname") + if len(userdata) == 0 { + // No more userdata, no need to serialize empty map. + e := event.Clone() + e.User = nil + result = append(result, e) + continue + } + + data, err := json.Marshal(userdata) + if err != nil { + result = append(result, event) + continue + } + + e := event.Clone() + e.User = data + result = append(result, e) + } + return result +} + +// +checklocks:s.filterDuplicateLock +func (s *ClientSession) filterUnknownLeave(entries []api.PublicSessionId) []api.PublicSessionId { + idx := slices.IndexFunc(entries, func(e api.PublicSessionId) bool { + return !s.seenJoinedEvents[e] // +checklocksignore + }) + if idx == -1 { + return entries + } else if idx+1 == len(entries) { + // Simple case: all entries filtered. + s.logger.Printf("Session %s got unknown leave events for %+v", s.publicId, entries) + return nil + } + + // Filter remaining entries. + filtered := []api.PublicSessionId{ + entries[idx], + } + result := append([]api.PublicSessionId{}, entries[:idx]...) + for _, e := range entries[idx+1:] { + if s.seenJoinedEvents[e] { + result = append(result, e) + } else { + filtered = append(filtered, e) + } + } + s.logger.Printf("Session %s got unknown leave events for %+v", s.publicId, filtered) + return result +} + +func (s *ClientSession) filterDuplicateJoin(entries []api.EventServerMessageSessionEntry) []api.EventServerMessageSessionEntry { + s.filterDuplicateLock.Lock() + defer s.filterDuplicateLock.Unlock() + + // Due to the asynchronous events, a session might received a "Joined" event + // for the same (other) session twice, so filter these out on a per-session + // level. + result := make([]api.EventServerMessageSessionEntry, 0, len(entries)) + for _, e := range entries { + if s.seenJoinedEvents[e.SessionId] { + s.logger.Printf("Session %s got duplicate joined event for %s, ignoring", s.publicId, e.SessionId) + continue + } + + if s.seenJoinedEvents == nil { + s.seenJoinedEvents = make(map[api.PublicSessionId]bool) + } + s.seenJoinedEvents[e.SessionId] = true + result = append(result, e) + } + return result +} + +func (s *ClientSession) filterDuplicateFlags(message *api.RoomFlagsServerMessage) bool { + if message == nil { + return true + } + + s.filterDuplicateLock.Lock() + defer s.filterDuplicateLock.Unlock() + + // Due to the asynchronous events, a session might received a "flags" event + // for the same (other) session twice, so filter these out on a per-session + // level. + if prev, found := s.seenFlags[message.SessionId]; found && prev == message.Flags { + s.logger.Printf("Session %s got duplicate flags event for %s, ignoring", s.publicId, message.SessionId) + return true + } + + if s.seenFlags == nil { + s.seenFlags = make(map[api.PublicSessionId]uint32) + } + s.seenFlags[message.SessionId] = message.Flags + return false +} + +func (s *ClientSession) filterMessage(message *api.ServerMessage) (*api.ServerMessage, []*api.ServerMessage) { + switch message.Type { + case "event": + switch message.Event.Target { + case "participants": + switch message.Event.Type { + case "update": + m := message.Event.Update + users := make(map[any]bool) + for _, entry := range m.Users { + users[entry["sessionId"]] = true + } + for _, entry := range m.Changed { + if users[entry["sessionId"]] { + continue + } + m.Users = append(m.Users, entry) + } + // TODO(jojo): Only send all users if current session id has + // changed its "inCall" flag to true. + m.Changed = nil + case "flags": + if s.filterDuplicateFlags(message.Event.Flags) { + return nil, nil + } + } + case "room": + switch message.Event.Type { + case "join": + join := s.filterDuplicateJoin(message.Event.Join) + if len(join) == 0 { + return nil, nil + } + copied := false + if len(join) != len(message.Event.Join) { + // Create unique copy of message for only this client. + copied = true + message = &api.ServerMessage{ + Id: message.Id, + Type: message.Type, + Event: &api.EventServerMessage{ + Type: message.Event.Type, + Target: message.Event.Target, + Join: join, + }, + } + } + + if s.HasPermission(api.PERMISSION_HIDE_DISPLAYNAMES) { + if copied { + message.Event.Join = filterDisplayNames(message.Event.Join) + } else { + message = &api.ServerMessage{ + Id: message.Id, + Type: message.Type, + Event: &api.EventServerMessage{ + Type: message.Event.Type, + Target: message.Event.Target, + Join: filterDisplayNames(message.Event.Join), + }, + } + } + } + case "leave": + s.filterDuplicateLock.Lock() + defer s.filterDuplicateLock.Unlock() + + leave := s.filterUnknownLeave(message.Event.Leave) + if len(leave) == 0 { + return nil, nil + } + + for _, e := range message.Event.Leave { + delete(s.seenJoinedEvents, e) + delete(s.seenFlags, e) + } + + if len(leave) != len(message.Event.Leave) { + message = &api.ServerMessage{ + Id: message.Id, + Type: message.Type, + Event: &api.EventServerMessage{ + Type: message.Event.Type, + Target: message.Event.Target, + Leave: leave, + }, + } + } + case "message": + if message.Event.Message == nil || len(message.Event.Message.Data) == 0 { + return message, nil + } + + data, err := message.Event.Message.GetData() + if data == nil || err != nil { + return message, nil + } + + if data.Type == "chat" && data.Chat != nil { + update := false + if data.Chat.Refresh && data.Chat.HasComment() { + // New-style chat event, check what the client supports. + if s.HasFeature(api.ClientFeatureChatRelay) { + data.Chat.Refresh = false + } else { + data.Chat.Comment = nil + data.Chat.Comments = nil + } + update = true + } + + if data.Chat.HasComment() { + data.Chat.Comments = slices.DeleteFunc(data.Chat.Comments, func(comment json.RawMessage) bool { + return len(comment) == 0 + }) + if len(data.Chat.Comment) > 0 { + if len(data.Chat.Comments) == 0 { + data.Chat.Comments = []json.RawMessage{data.Chat.Comment} + } else { + data.Chat.Comments = append([]json.RawMessage{data.Chat.Comment}, data.Chat.Comments...) + } + data.Chat.Comment = nil + } + if len(data.Chat.Comments) > 0 && s.HasPermission(api.PERMISSION_HIDE_DISPLAYNAMES) { + for i, commentData := range data.Chat.Comments { + var comment api.ChatComment + if err := json.Unmarshal(commentData, &comment); err != nil { + continue + } + + if displayName, found := comment["actorDisplayName"]; found && displayName != "" { + comment["actorDisplayName"] = "" + var err error + if commentData, err = json.Marshal(comment); err != nil { + continue + } + data.Chat.Comments[i] = commentData + update = true + } + } + } + } + + if update || len(data.Chat.Comments) > 0 { + if len(data.Chat.Comment) == 0 && len(data.Chat.Comments) == 0 { + if encoded, err := json.Marshal(data); err == nil { + // Create unique copy of message for only this client. + message = &api.ServerMessage{ + Id: message.Id, + Type: message.Type, + Event: &api.EventServerMessage{ + Type: message.Event.Type, + Target: message.Event.Target, + Message: &api.RoomEventMessage{ + RoomId: message.Event.Message.RoomId, + Data: encoded, + }, + }, + } + } + } else { + // Forward different chat comments individually. + var result []*api.ServerMessage + for _, comment := range data.Chat.Comments { + commentData := api.RoomEventMessageData{ + Type: data.Type, + Chat: &api.RoomEventMessageDataChat{ + Refresh: data.Chat.Refresh, + Comment: comment, + }, + } + if encoded, err := json.Marshal(commentData); err == nil { + // Create unique copy of message for only this client. + result = append(result, &api.ServerMessage{ + Id: message.Id, + Type: message.Type, + Event: &api.EventServerMessage{ + Type: message.Event.Type, + Target: message.Event.Target, + Message: &api.RoomEventMessage{ + RoomId: message.Event.Message.RoomId, + Data: encoded, + }, + }, + }) + } + } + return nil, result + } + } + } + } + } + case "message": + if message.Message != nil && len(message.Message.Data) > 0 && s.HasPermission(api.PERMISSION_HIDE_DISPLAYNAMES) { + var data api.MessageServerMessageData + if err := json.Unmarshal(message.Message.Data, &data); err != nil { + return message, nil + } + + if data.Type == "nickChanged" { + return nil, nil + } + } + } + + return message, nil +} + +func (s *ClientSession) filterAsyncMessage(msg *events.AsyncMessage) *api.ServerMessage { + switch msg.Type { + case "message": + if msg.Message == nil { + s.logger.Printf("Received asynchronous message without payload: %+v", msg) + return nil + } + + switch msg.Message.Type { + case "message": + if msg.Message.Message != nil { + if sender := msg.Message.Message.Sender; sender != nil { + if sender.SessionId == s.PublicId() { + // Don't send message back to sender (can happen if sent to user or room) + return nil + } + if sender.Type == api.RecipientTypeCall { + if room := s.GetRoom(); room == nil || !room.IsSessionInCall(s) { + // Session is not in call, so discard. + return nil + } + } + } + } + case "control": + if msg.Message.Control != nil { + if sender := msg.Message.Control.Sender; sender != nil { + if sender.SessionId == s.PublicId() { + // Don't send message back to sender (can happen if sent to user or room) + return nil + } + if sender.Type == api.RecipientTypeCall { + if room := s.GetRoom(); room == nil || !room.IsSessionInCall(s) { + // Session is not in call, so discard. + return nil + } + } + } + } + case "event": + if msg.Message.Event.Target == "room" || msg.Message.Event.Target == "participants" { + // Can happen mostly during tests where an older room async message + // could be received by a subscriber that joined after it was sent. + if joined := s.getRoomJoinTime(); joined.IsZero() || msg.SendTime.Before(joined) { + s.logger.Printf("Message %+v was sent on %s before session %s join room on %s, ignoring", msg.Message, msg.SendTime, s.publicId, joined) + return nil + } + } + } + + return msg.Message + default: + s.logger.Printf("Received async message with unsupported type %s: %+v", msg.Type, msg) + return nil + } +} + +func (s *ClientSession) NotifySessionResumed(client ClientWithSession) { + s.mu.Lock() + if len(s.pendingClientMessages) == 0 { + s.mu.Unlock() + if room := s.GetRoom(); room != nil { + room.NotifySessionResumed(s) + } + return + } + + messages := s.pendingClientMessages + hasPendingParticipantsUpdate := s.hasPendingParticipantsUpdate + s.pendingClientMessages = nil + s.hasPendingChat = false + s.hasPendingParticipantsUpdate = false + s.mu.Unlock() + + s.logger.Printf("Send %d pending messages to session %s", len(messages), s.PublicId()) + // Send through session to handle connection interruptions. + s.SendMessages(messages) + + if !hasPendingParticipantsUpdate { + // Only need to send initial participants list update if none was part of the pending messages. + if room := s.GetRoom(); room != nil { + room.NotifySessionResumed(s) + } + } +} + +func (s *ClientSession) AddVirtualSession(session *VirtualSession) { + s.mu.Lock() + if s.virtualSessions == nil { + s.virtualSessions = make(map[*VirtualSession]bool) + } + s.virtualSessions[session] = true + s.mu.Unlock() +} + +func (s *ClientSession) RemoveVirtualSession(session *VirtualSession) { + s.mu.Lock() + delete(s.virtualSessions, session) + s.mu.Unlock() +} + +func (s *ClientSession) GetVirtualSessions() []*VirtualSession { + s.mu.Lock() + defer s.mu.Unlock() + + result := make([]*VirtualSession, 0, len(s.virtualSessions)) + for session := range s.virtualSessions { + result = append(result, session) + } + return result +} + +func (s *ClientSession) HandleResponse(id string, handler ResponseHandlerFunc) { + s.responseHandlersLock.Lock() + defer s.responseHandlersLock.Unlock() + + if s.responseHandlers == nil { + s.responseHandlers = make(map[string]ResponseHandlerFunc) + } + + s.responseHandlers[id] = handler +} + +func (s *ClientSession) ClearResponseHandler(id string) { + s.responseHandlersLock.Lock() + defer s.responseHandlersLock.Unlock() + + delete(s.responseHandlers, id) +} + +func (s *ClientSession) ProcessResponse(message *api.ClientMessage) bool { + id := message.Id + if id == "" { + return false + } + + s.responseHandlersLock.Lock() + cb, found := s.responseHandlers[id] + defer s.responseHandlersLock.Unlock() + + if !found { + return false + } + + return cb(message) +} diff --git a/server/clientsession_test.go b/server/clientsession_test.go new file mode 100644 index 0000000..6152a71 --- /dev/null +++ b/server/clientsession_test.go @@ -0,0 +1,919 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + "github.com/strukturag/nextcloud-spreed-signaling/v2/mock" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +func TestBandwidth_Client(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + mcu := test.NewSFU(t) + require.NoError(mcu.Start(ctx)) + defer mcu.Stop() + + hub.SetMcu(mcu) + + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + defer client.CloseWithBye() + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + // We will receive a "joined" event. + client.RunUntilJoined(ctx, hello.Hello) + + // Client may not send an offer with audio and video. + bitrate := api.BandwidthFromBits(10000) + require.NoError(client.SendMessage(api.MessageClientMessageRecipient{ + Type: "session", + SessionId: hello.Hello.SessionId, + }, api.MessageClientMessageData{ + Type: "offer", + Sid: "54321", + RoomType: "video", + Bitrate: bitrate, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + })) + + require.True(client.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioAndVideo)) + + pub := mcu.GetPublisher(hello.Hello.SessionId) + require.NotNil(pub) + assert.Equal(bitrate, pub.Settings().Bitrate) +} + +func TestBandwidth_Backend(t *testing.T) { + t.Parallel() + + streamTypes := []sfu.StreamType{ + sfu.StreamTypeVideo, + sfu.StreamTypeScreen, + } + + for _, streamType := range streamTypes { + t.Run(string(streamType), func(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + hub, _, _, server := CreateHubWithMultipleBackendsForTest(t) + + u, err := url.Parse(server.URL + "/one") + require.NoError(err) + backend := hub.backend.GetBackend(u) + require.NotNil(backend, "Could not get backend") + + backend.SetMaxScreenBitrate(1000) + backend.SetMaxStreamBitrate(2000) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + mcu := test.NewSFU(t) + require.NoError(mcu.Start(ctx)) + defer mcu.Stop() + + hub.SetMcu(mcu) + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + params := TestBackendClientAuthParams{ + UserId: testDefaultUserId, + } + require.NoError(client.SendHelloParams(server.URL+"/one", api.HelloVersionV1, "client", nil, params)) + + hello := MustSucceed1(t, client.RunUntilHello, ctx) + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + // We will receive a "joined" event. + require.True(client.RunUntilJoined(ctx, hello.Hello)) + + // Client may not send an offer with audio and video. + bitrate := api.BandwidthFromBits(10000) + require.NoError(client.SendMessage(api.MessageClientMessageRecipient{ + Type: "session", + SessionId: hello.Hello.SessionId, + }, api.MessageClientMessageData{ + Type: "offer", + Sid: "54321", + RoomType: string(streamType), + Bitrate: bitrate, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + })) + + require.True(client.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioAndVideo)) + + pub := mcu.GetPublisher(hello.Hello.SessionId) + require.NotNil(pub, "Could not find publisher") + + var expectBitrate api.Bandwidth + if streamType == sfu.StreamTypeVideo { + expectBitrate = backend.MaxStreamBitrate() + } else { + expectBitrate = backend.MaxScreenBitrate() + } + assert.Equal(expectBitrate, pub.Settings().Bitrate) + }) + } +} + +func TestFeatureChatRelay(t *testing.T) { + t.Parallel() + + testFunc := func(feature bool) func(t *testing.T) { + return func(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + var features []string + if feature { + features = append(features, api.ClientFeatureChatRelay) + } + require.NoError(client.SendHelloClientWithFeatures(testDefaultUserId, features)) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello := MustSucceed1(t, client.RunUntilHello, ctx) + + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + client.RunUntilJoined(ctx, hello.Hello) + + room := hub.getRoom(roomId) + require.NotNil(room) + + chatComment := api.StringMap{ + "foo": "bar", + "baz": true, + "lala": map[string]any{ + "one": "eins", + }, + "token": roomId, + } + message := api.StringMap{ + "type": "chat", + "chat": api.StringMap{ + "refresh": true, + "comment": chatComment, + }, + } + data, err := json.Marshal(message) + require.NoError(err) + + // Simulate request from the backend. + room.processAsyncMessage(&events.AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "message", + Message: &talk.BackendRoomMessageRequest{ + Data: data, + }, + }, + }) + + if msg, ok := client.RunUntilRoomMessage(ctx); ok { + assert.Equal(roomId, msg.RoomId) + var data api.StringMap + if err := json.Unmarshal(msg.Data, &data); assert.NoError(err) { + assert.Equal("chat", data["type"], "invalid type entry in %+v", data) + if chat, found := api.GetStringMapEntry[map[string]any](data, "chat"); assert.True(found, "chat entry is missing in %+v", data) { + if feature { + assert.EqualValues(chatComment, chat["comment"]) + _, found := chat["refresh"] + assert.False(found, "refresh should not be included") + } else { + assert.Equal(true, chat["refresh"]) + _, found := chat["comment"] + assert.False(found, "the comment should not be included") + } + } + } + } + + chatComment2 := api.StringMap{ + "hello": "world", + } + message2 := api.StringMap{ + "type": "chat", + "chat": api.StringMap{ + "refresh": true, + "comments": []api.StringMap{ + chatComment, + chatComment2, + }, + }, + } + data2, err := json.Marshal(message2) + require.NoError(err) + + // Simulate request from the backend. + room.processAsyncMessage(&events.AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "message", + Message: &talk.BackendRoomMessageRequest{ + Data: data2, + }, + }, + }) + + if msg, ok := client.RunUntilRoomMessage(ctx); ok { + assert.Equal(roomId, msg.RoomId) + var data api.StringMap + if err := json.Unmarshal(msg.Data, &data); assert.NoError(err) { + assert.Equal("chat", data["type"], "invalid type entry in %+v", data) + if chat, found := api.GetStringMapEntry[map[string]any](data, "chat"); assert.True(found, "chat entry is missing in %+v", data) { + if feature { + assert.EqualValues(chatComment, chat["comment"]) + _, found := chat["refresh"] + assert.False(found, "refresh should not be included") + + // A second message with the second comment will be sent + if msg, ok := client.RunUntilRoomMessage(ctx); ok { + assert.Equal(roomId, msg.RoomId) + + if err := json.Unmarshal(msg.Data, &data); assert.NoError(err) { + assert.Equal("chat", data["type"], "invalid type entry in %+v", data) + if chat, found := api.GetStringMapEntry[map[string]any](data, "chat"); assert.True(found, "chat entry is missing in %+v", data) { + assert.EqualValues(chatComment2, chat["comment"]) + _, found := chat["refresh"] + assert.False(found, "refresh should not be included") + } + } + } + } else { + // Only a single refresh will be sent + assert.Equal(true, chat["refresh"]) + _, found := chat["comment"] + assert.False(found, "the comment should not be included") + + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel2() + + client.RunUntilErrorIs(ctx2, context.DeadlineExceeded) + } + } + } + } + } + } + + t.Run("without-chat-relay", testFunc(false)) // nolint:paralleltest + t.Run("with-chat-relay", testFunc(true)) // nolint:paralleltest +} + +func TestFeatureChatRelayFederation(t *testing.T) { + t.Parallel() + + var testFunc = func(feature bool) func(t *testing.T) { + return func(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + hub1, hub2, server1, server2 := CreateClusteredHubsForTest(t) + + localFeatures := []string{ + api.ClientFeatureChatRelay, + } + var federatedFeatures []string + if feature { + federatedFeatures = append(federatedFeatures, api.ClientFeatureChatRelay) + } + + client1 := NewTestClient(t, server1, hub1) + defer client1.CloseWithBye() + require.NoError(client1.SendHelloClientWithFeatures(testDefaultUserId+"1", localFeatures)) + + client2 := NewTestClient(t, server2, hub2) + defer client2.CloseWithBye() + require.NoError(client2.SendHelloClientWithFeatures(testDefaultUserId+"2", federatedFeatures)) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) + + roomId := "test-room" + federatedRoomId := roomId + "@federated" + room1 := MustSucceed2(t, client1.JoinRoom, ctx, roomId) + require.Equal(roomId, room1.Room.RoomId) + + client1.RunUntilJoined(ctx, hello1.Hello) + + now := time.Now() + userdata := api.StringMap{ + "displayname": "Federated user", + "actorType": "federated_users", + "actorId": "the-federated-user-id", + } + token, err := client1.CreateHelloV2TokenWithUserdata(testDefaultUserId+"2", now, now.Add(time.Minute), userdata) + require.NoError(err) + + msg := &api.ClientMessage{ + Id: "join-room-fed", + Type: "room", + Room: &api.RoomClientMessage{ + RoomId: federatedRoomId, + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ + SignalingUrl: server1.URL, + NextcloudUrl: server1.URL, + RoomId: roomId, + Token: token, + }, + }, + } + require.NoError(client2.WriteJSON(msg)) + + if message, ok := client2.RunUntilMessage(ctx); ok { + assert.Equal(msg.Id, message.Id) + require.Equal("room", message.Type) + require.Equal(federatedRoomId, message.Room.RoomId) + } + + // The client1 will see the remote session id for client2. + var remoteSessionId api.PublicSessionId + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) + evt := message.Event.Join[0] + remoteSessionId = evt.SessionId + assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) + assert.Equal(hello2.Hello.UserId, evt.UserId) + assert.True(evt.Federated) + } + + // The client2 will see its own session id, not the one from the remote server. + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) + + room := hub1.getRoom(roomId) + require.NotNil(room) + + chatComment := map[string]any{ + "token": roomId, + "actorId": hello1.Hello.UserId, + "actorType": "users", + "lastEditActorId": hello1.Hello.UserId, + "lastEditActorType": "users", + "parent": map[string]any{ + "actorId": hello1.Hello.UserId, + "actorType": "users", + "lastEditActorId": hello1.Hello.UserId, + "lastEditActorType": "users", + }, + "messageParameters": map[string]map[string]any{ + "mention-local-user": { + "type": "user", + "id": hello1.Hello.UserId, + "name": "User 1", + }, + "mention-remote-user": { + "type": "user", + "id": hello2.Hello.UserId, + "name": "User 2", + "mention-id": "federated_user/" + hello2.Hello.UserId + "@" + getCloudUrl(server2.URL), + "server": server2.URL, + }, + "mention-call": { + "type": "call", + "id": roomId, + }, + }, + } + federatedChatComment := map[string]any{ + "token": federatedRoomId, + "actorId": hello1.Hello.UserId + "@" + getCloudUrl(server1.URL), + "actorType": "federated_users", + "lastEditActorId": hello1.Hello.UserId + "@" + getCloudUrl(server1.URL), + "lastEditActorType": "federated_users", + "parent": map[string]any{ + "actorId": hello1.Hello.UserId + "@" + getCloudUrl(server1.URL), + "actorType": "federated_users", + "lastEditActorId": hello1.Hello.UserId + "@" + getCloudUrl(server1.URL), + "lastEditActorType": "federated_users", + }, + "messageParameters": map[string]map[string]any{ + "mention-local-user": { + "type": "user", + "id": hello1.Hello.UserId, + "mention-id": hello1.Hello.UserId, + "name": "User 1", + "server": server1.URL, + }, + "mention-remote-user": { + "type": "user", + "id": hello2.Hello.UserId, + "name": "User 2", + "mention-id": "federated_user/" + hello2.Hello.UserId + "@" + getCloudUrl(server2.URL), + }, + "mention-call": { + "type": "call", + "id": federatedRoomId, + }, + }, + } + message := api.StringMap{ + "type": "chat", + "chat": api.StringMap{ + "refresh": true, + "comment": chatComment, + }, + } + data, err := json.Marshal(message) + require.NoError(err) + + // Simulate request from the backend. + room.processAsyncMessage(&events.AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "message", + Message: &talk.BackendRoomMessageRequest{ + Data: data, + }, + }, + }) + + // The first client will receive the message for the local room (always including the actual message). + if msg, ok := client1.RunUntilRoomMessage(ctx); ok { + assert.Equal(roomId, msg.RoomId) + var data api.StringMap + if err := json.Unmarshal(msg.Data, &data); assert.NoError(err) { + assert.Equal("chat", data["type"], "invalid type entry in %+v", data) + if chat, found := api.GetStringMapEntry[map[string]any](data, "chat"); assert.True(found, "chat entry is missing in %+v", data) { + AssertEqualSerialized(t, chatComment, chat["comment"]) + _, found := chat["refresh"] + assert.False(found, "refresh should not be included") + } + } + } + + // The second client will receive the message from the federated room (either as refresh or with the message). + if msg, ok := client2.RunUntilRoomMessage(ctx); ok { + assert.Equal(federatedRoomId, msg.RoomId) + var data api.StringMap + if err := json.Unmarshal(msg.Data, &data); assert.NoError(err) { + assert.Equal("chat", data["type"], "invalid type entry in %+v", data) + if chat, found := api.GetStringMapEntry[map[string]any](data, "chat"); assert.True(found, "chat entry is missing in %+v", data) { + if feature { + AssertEqualSerialized(t, federatedChatComment, chat["comment"]) + _, found := chat["refresh"] + assert.False(found, "refresh should not be included") + } else { + assert.Equal(true, chat["refresh"]) + _, found := chat["comment"] + assert.False(found, "the comment should not be included") + } + } + } + } + + chatComment2 := api.StringMap{ + "hello": "world", + } + message2 := api.StringMap{ + "type": "chat", + "chat": api.StringMap{ + "refresh": true, + "comments": []api.StringMap{ + chatComment, + chatComment2, + }, + }, + } + data2, err := json.Marshal(message2) + require.NoError(err) + + // Simulate request from the backend. + room.processAsyncMessage(&events.AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "message", + Message: &talk.BackendRoomMessageRequest{ + Data: data2, + }, + }, + }) + + // The first client will receive the message for the local room (always including the actual message). + if msg, ok := client1.RunUntilRoomMessage(ctx); ok { + assert.Equal(roomId, msg.RoomId) + var data api.StringMap + if err := json.Unmarshal(msg.Data, &data); assert.NoError(err) { + assert.Equal("chat", data["type"], "invalid type entry in %+v", data) + if chat, found := api.GetStringMapEntry[map[string]any](data, "chat"); assert.True(found, "chat entry is missing in %+v", data) { + AssertEqualSerialized(t, chatComment, chat["comment"]) + _, found := chat["refresh"] + assert.False(found, "refresh should not be included") + } + } + } + // A second message with the second comment will be sent + if msg, ok := client1.RunUntilRoomMessage(ctx); ok { + assert.Equal(roomId, msg.RoomId) + var data api.StringMap + if err := json.Unmarshal(msg.Data, &data); assert.NoError(err) { + assert.Equal("chat", data["type"], "invalid type entry in %+v", data) + if chat, found := api.GetStringMapEntry[map[string]any](data, "chat"); assert.True(found, "chat entry is missing in %+v", data) { + assert.EqualValues(chatComment2, chat["comment"]) + _, found := chat["refresh"] + assert.False(found, "refresh should not be included") + } + } + } + + // The second client will receive the message from the federated room (either as refresh or with the message). + if msg, ok := client2.RunUntilRoomMessage(ctx); ok { + assert.Equal(federatedRoomId, msg.RoomId) + var data api.StringMap + if err := json.Unmarshal(msg.Data, &data); assert.NoError(err) { + assert.Equal("chat", data["type"], "invalid type entry in %+v", data) + if chat, found := api.GetStringMapEntry[map[string]any](data, "chat"); assert.True(found, "chat entry is missing in %+v", data) { + if feature { + AssertEqualSerialized(t, federatedChatComment, chat["comment"]) + _, found := chat["refresh"] + assert.False(found, "refresh should not be included") + + // A second message with the second comment will be sent + if msg, ok := client2.RunUntilRoomMessage(ctx); ok { + assert.Equal(federatedRoomId, msg.RoomId) + if err := json.Unmarshal(msg.Data, &data); assert.NoError(err) { + assert.Equal("chat", data["type"], "invalid type entry in %+v", data) + if chat, found := api.GetStringMapEntry[map[string]any](data, "chat"); assert.True(found, "chat entry is missing in %+v", data) { + assert.EqualValues(chatComment2, chat["comment"]) + _, found := chat["refresh"] + assert.False(found, "refresh should not be included") + } + } + } + } else { + // Only a single refresh will be sent + assert.Equal(true, chat["refresh"]) + _, found := chat["comment"] + assert.False(found, "the comment should not be included") + + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel2() + + client2.RunUntilErrorIs(ctx2, context.DeadlineExceeded) + } + } + } + } + } + } + + t.Run("without-chat-relay", testFunc(false)) // nolint:paralleltest + t.Run("with-chat-relay", testFunc(true)) // nolint:paralleltest +} + +func TestPermissionHideDisplayNames(t *testing.T) { + t.Parallel() + + testFunc := func(permission bool) func(t *testing.T) { + return func(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + defer client.CloseWithBye() + + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + client.RunUntilJoined(ctx, hello.Hello) + + room := hub.getRoom(roomId) + require.NotNil(room) + + if permission { + session := hub.GetSessionByPublicId(hello.Hello.SessionId).(*ClientSession) + require.NotNil(session, "Session %s does not exist", hello.Hello.SessionId) + + // Client may not receive display names. + session.SetPermissions([]api.Permission{api.PERMISSION_HIDE_DISPLAYNAMES}) + } + + chatComment := api.StringMap{ + "actorDisplayName": "John Doe", + "baz": true, + "lala": map[string]any{ + "one": "eins", + }, + } + message := api.StringMap{ + "type": "chat", + "chat": api.StringMap{ + "comment": chatComment, + }, + } + data, err := json.Marshal(message) + require.NoError(err) + + // Simulate request from the backend. + room.processAsyncMessage(&events.AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "message", + Message: &talk.BackendRoomMessageRequest{ + Data: data, + }, + }, + }) + + if msg, ok := client.RunUntilRoomMessage(ctx); ok { + assert.Equal(roomId, msg.RoomId) + var data api.StringMap + if err := json.Unmarshal(msg.Data, &data); assert.NoError(err) { + assert.Equal("chat", data["type"], "invalid type entry in %+v", data) + if chat, found := api.GetStringMapEntry[map[string]any](data, "chat"); assert.True(found, "chat entry is missing in %+v", data) { + comment, found := chat["comment"] + if assert.True(found, "comment is missing in %+v", chat) { + if permission { + displayName, found := comment.(map[string]any)["actorDisplayName"] + assert.True(!found || displayName == "", "the display name should not be included in %+v", comment) + } else { + displayName, found := comment.(map[string]any)["actorDisplayName"] + assert.True(found && displayName != "", "the display name should be included in %+v", comment) + } + } + } + } + + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") + defer client2.CloseWithBye() + + roomMsg2 := MustSucceed2(t, client2.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg2.Room.RoomId) + + client.RunUntilJoined(ctx, hello2.Hello) + client2.RunUntilJoined(ctx, hello.Hello, hello2.Hello) + + recipient1 := api.MessageClientMessageRecipient{ + Type: "session", + SessionId: hello.Hello.SessionId, + } + data1 := api.StringMap{ + "type": "nickChanged", + "message": "from-1-to-2", + } + client2.SendMessage(recipient1, data1) // nolint + if permission { + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel2() + + client.RunUntilErrorIs(ctx2, context.DeadlineExceeded) + } else { + var payload2 api.StringMap + if ok := checkReceiveClientMessage(ctx, t, client, "session", hello2.Hello, &payload2); ok { + assert.Equal(data1, payload2) + } + } + } + } + } + + t.Run("without-hide-displaynames", testFunc(false)) // nolint:paralleltest + t.Run("with-hide-displaynames", testFunc(true)) // nolint:paralleltest +} + +func Test_ClientSessionPublisherEvents(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + mcu := test.NewSFU(t) + require.NoError(mcu.Start(ctx)) + defer mcu.Stop() + + hub.SetMcu(mcu) + + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + defer client.CloseWithBye() + + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + client.RunUntilJoined(ctx, hello.Hello) + + room := hub.getRoom(roomId) + require.NotNil(room) + + session := hub.GetSessionByPublicId(hello.Hello.SessionId).(*ClientSession) + require.NotNil(session, "Session %s does not exist", hello.Hello.SessionId) + + require.NoError(client.SendMessage(api.MessageClientMessageRecipient{ + Type: "session", + SessionId: hello.Hello.SessionId, + }, api.MessageClientMessageData{ + Type: "offer", + Sid: "54321", + RoomType: string(sfu.StreamTypeVideo), + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + })) + + require.True(client.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioAndVideo)) + + pub := mcu.GetPublisher(hello.Hello.SessionId) + require.NotNil(pub) + + assert.Equal(pub, session.GetPublisher(sfu.StreamTypeVideo)) + session.OnIceCandidate(pub, "test-candidate") + + if message, ok := client.RunUntilMessage(ctx); ok { + assert.Equal("message", message.Type) + if msg := message.Message; assert.NotNil(msg) { + if sender := msg.Sender; assert.NotNil(sender) { + assert.Equal("session", sender.Type) + assert.Equal(hello.Hello.SessionId, sender.SessionId) + } + var ao api.AnswerOfferMessage + if assert.NoError(json.Unmarshal(msg.Data, &ao)) { + assert.Equal(hello.Hello.SessionId, ao.From) + assert.Equal(hello.Hello.SessionId, ao.To) + assert.Equal("candidate", ao.Type) + assert.EqualValues(sfu.StreamTypeVideo, ao.RoomType) + assert.Equal("test-candidate", ao.Payload["candidate"]) + } + } + } + + // No-op + session.OnIceCompleted(pub) + + session.PublisherClosed(pub) + assert.Nil(session.GetPublisher(sfu.StreamTypeVideo)) +} + +func Test_ClientSessionSubscriberEvents(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + hub.allowSubscribeAnyStream = true + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + mcu := test.NewSFU(t) + require.NoError(mcu.Start(ctx)) + defer mcu.Stop() + + hub.SetMcu(mcu) + + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + defer client1.CloseWithBye() + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") + defer client2.CloseWithBye() + + roomId := "test-room" + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) + + room := hub.getRoom(roomId) + require.NotNil(room) + + session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId).(*ClientSession) + require.NotNil(session1, "Session %s does not exist", hello1.Hello.SessionId) + session2 := hub.GetSessionByPublicId(hello2.Hello.SessionId).(*ClientSession) + require.NotNil(session2, "Session %s does not exist", hello2.Hello.SessionId) + + require.NoError(client1.SendMessage(api.MessageClientMessageRecipient{ + Type: "session", + SessionId: hello1.Hello.SessionId, + }, api.MessageClientMessageData{ + Type: "offer", + Sid: "54321", + RoomType: string(sfu.StreamTypeVideo), + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + })) + + require.True(client1.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioAndVideo)) + + require.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ + Type: "session", + SessionId: hello1.Hello.SessionId, + }, api.MessageClientMessageData{ + Type: "requestoffer", + Sid: "54321", + RoomType: string(sfu.StreamTypeVideo), + })) + + require.True(client2.RunUntilOffer(ctx, mock.MockSdpOfferAudioAndVideo)) + + sub := mcu.GetSubscriber(hello1.Hello.SessionId, sfu.StreamTypeVideo) + require.NotNil(sub) + + assert.Equal(sub, session2.GetSubscriber(hello1.Hello.SessionId, sfu.StreamTypeVideo)) + session2.OnIceCandidate(sub, "test-candidate") + + if message, ok := client2.RunUntilMessage(ctx); ok { + assert.Equal("message", message.Type) + if msg := message.Message; assert.NotNil(msg) { + if sender := msg.Sender; assert.NotNil(sender) { + assert.Equal("session", sender.Type) + assert.Equal(hello1.Hello.SessionId, sender.SessionId) + } + var ao api.AnswerOfferMessage + if assert.NoError(json.Unmarshal(msg.Data, &ao)) { + assert.Equal(hello1.Hello.SessionId, ao.From) + assert.Equal(hello2.Hello.SessionId, ao.To) + assert.Equal("candidate", ao.Type) + assert.EqualValues(sfu.StreamTypeVideo, ao.RoomType) + assert.Equal("test-candidate", ao.Payload["candidate"]) + } + } + } + + // No-op + session2.OnIceCompleted(sub) + + session2.OnUpdateOffer(sub, api.StringMap{ + "type": "offer", + "sdp": mock.MockSdpOfferAudioOnly, + }) + + require.True(client2.RunUntilOffer(ctx, mock.MockSdpOfferAudioOnly)) + + session2.SubscriberClosed(sub) + assert.Nil(session2.GetSubscriber(hello1.Hello.SessionId, sfu.StreamTypeVideo)) +} diff --git a/federation.go b/server/federation.go similarity index 58% rename from federation.go rename to server/federation.go index 9b609c8..0c3dbb7 100644 --- a/federation.go +++ b/server/federation.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" @@ -27,7 +27,6 @@ import ( "encoding/json" "errors" "fmt" - "log" "net" "strconv" "strings" @@ -36,16 +35,35 @@ import ( "time" "github.com/gorilla/websocket" - easyjson "github.com/mailru/easyjson" + "github.com/mailru/easyjson" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) const ( + // Time allowed to write a message to the peer. + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 60 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 + + // Maximum message size allowed from peer. + maxMessageSize = 64 * 1024 + initialFederationReconnectInterval = 100 * time.Millisecond maxFederationReconnectInterval = 8 * time.Second ) var ( - ErrFederationNotSupported = NewError("federation_unsupported", "The target server does not support federation.") + ErrNotConnected = errors.New("not connected") + ErrFederationNotSupported = api.NewError("federation_unsupported", "The target server does not support federation.") + + federationWriteBufferPool = &sync.Pool{} ) func isClosedError(err error) bool { @@ -55,54 +73,69 @@ func isClosedError(err error) bool { strings.Contains(err.Error(), net.ErrClosed.Error()) } -func getCloudUrl(s string) string { - if strings.HasPrefix(s, "https://") { - s = s[8:] - } else { - s = strings.TrimPrefix(s, "http://") - } +func getCloudUrlWithoutPath(s string) string { if pos := strings.Index(s, "/ocs/v"); pos != -1 { s = s[:pos] + } else { + s = strings.TrimSuffix(s, "/") } return s } +func getCloudUrl(s string) string { + var found bool + if s, found = strings.CutPrefix(s, "https://"); !found { + s = strings.TrimPrefix(s, "http://") + } + return getCloudUrlWithoutPath(s) +} + type FederationClient struct { + logger log.Logger hub *Hub session *ClientSession - message atomic.Pointer[ClientMessage] + message atomic.Pointer[api.ClientMessage] roomId atomic.Value remoteRoomId atomic.Value changeRoomId atomic.Bool - federation atomic.Pointer[RoomFederationMessage] + federation atomic.Pointer[api.RoomFederationMessage] - mu sync.Mutex - dialer *websocket.Dialer - url string - conn *websocket.Conn - closer *Closer + mu sync.Mutex + dialer *websocket.Dialer + url string + // +checklocks:mu + conn *websocket.Conn + closer *internal.Closer + // +checklocks:mu reconnectDelay time.Duration - reconnecting bool - reconnectFunc *time.Timer + reconnecting atomic.Bool + // +checklocks:mu + reconnectFunc *time.Timer - helloMu sync.Mutex + helloMu sync.Mutex + // +checklocks:helloMu helloMsgId string - helloAuth *FederationAuthParams - resumeId string - hello atomic.Pointer[HelloServerMessage] + // +checklocks:helloMu + helloAuth *api.FederationAuthParams + // +checklocks:helloMu + resumeId api.PrivateSessionId + hello atomic.Pointer[api.HelloServerMessage] - pendingMessages []*ClientMessage + // +checklocks:helloMu + pendingMessages []*api.ClientMessage closeOnLeave atomic.Bool } -func NewFederationClient(ctx context.Context, hub *Hub, session *ClientSession, message *ClientMessage) (*FederationClient, error) { +func NewFederationClient(ctx context.Context, hub *Hub, session *ClientSession, message *api.ClientMessage) (*FederationClient, error) { if message.Type != "room" || message.Room == nil || message.Room.Federation == nil { return nil, fmt.Errorf("expected federation room message, got %+v", message) } - var dialer websocket.Dialer + dialer := &websocket.Dialer{ + WriteBufferPool: federationWriteBufferPool, + } if hub.skipFederationVerify { dialer.TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, @@ -110,7 +143,7 @@ func NewFederationClient(ctx context.Context, hub *Hub, session *ClientSession, } room := message.Room - u := *room.Federation.parsedSignalingUrl + u := *room.Federation.ParsedSignalingUrl switch u.Scheme { case "http": u.Scheme = "ws" @@ -125,14 +158,15 @@ func NewFederationClient(ctx context.Context, hub *Hub, session *ClientSession, } result := &FederationClient{ + logger: hub.logger, hub: hub, session: session, reconnectDelay: initialFederationReconnectInterval, - dialer: &dialer, + dialer: dialer, url: url, - closer: NewCloser(), + closer: internal.NewCloser(), } result.roomId.Store(room.RoomId) result.remoteRoomId.Store(remoteRoomId) @@ -154,8 +188,19 @@ func NewFederationClient(ctx context.Context, hub *Hub, session *ClientSession, return result, nil } +func (c *FederationClient) LocalAddr() net.Addr { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn == nil { + return nil + } + + return c.conn.LocalAddr() +} + func (c *FederationClient) URL() string { - return c.federation.Load().parsedSignalingUrl.String() + return c.federation.Load().ParsedSignalingUrl.String() } func (c *FederationClient) RoomId() string { @@ -166,14 +211,14 @@ func (c *FederationClient) RemoteRoomId() string { return c.remoteRoomId.Load().(string) } -func (c *FederationClient) CanReuse(federation *RoomFederationMessage) bool { +func (c *FederationClient) CanReuse(federation *api.RoomFederationMessage) bool { fed := c.federation.Load() return fed.NextcloudUrl == federation.NextcloudUrl && fed.SignalingUrl == federation.SignalingUrl } func (c *FederationClient) connect(ctx context.Context) error { - log.Printf("Creating federation connection to %s for %s", c.URL(), c.session.PublicId()) + c.logger.Printf("Creating federation connection to %s for %s", c.URL(), c.session.PublicId()) conn, response, err := c.dialer.DialContext(ctx, c.url, nil) if err != nil { return err @@ -183,20 +228,20 @@ func (c *FederationClient) connect(ctx context.Context) error { supportsFederation := false for _, f := range features { f = strings.TrimSpace(f) - if f == ServerFeatureFederation { + if f == api.ServerFeatureFederation { supportsFederation = true break } } if !supportsFederation { if err := conn.Close(); err != nil { - log.Printf("Error closing federation connection to %s: %s", c.URL(), err) + c.logger.Printf("Error closing federation connection to %s: %s", c.URL(), err) } return ErrFederationNotSupported } - log.Printf("Federation connection established to %s for %s", c.URL(), c.session.PublicId()) + c.logger.Printf("Federation connection established to %s for %s", c.URL(), c.session.PublicId()) c.mu.Lock() defer c.mu.Unlock() @@ -218,7 +263,7 @@ func (c *FederationClient) connect(ctx context.Context) error { return nil } -func (c *FederationClient) ChangeRoom(message *ClientMessage) error { +func (c *FederationClient) ChangeRoom(message *api.ClientMessage) error { if message.Room == nil || message.Room.Federation == nil { return fmt.Errorf("expected federation room message, got %+v", message) } else if !c.CanReuse(message.Room.Federation) { @@ -229,29 +274,33 @@ func (c *FederationClient) ChangeRoom(message *ClientMessage) error { return c.joinRoom() } -func (c *FederationClient) Leave(message *ClientMessage) error { +func (c *FederationClient) Leave(message *api.ClientMessage) error { c.mu.Lock() defer c.mu.Unlock() if message == nil { - message = &ClientMessage{ + message = &api.ClientMessage{ Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: "", }, } } - if err := c.sendMessageLocked(message); err != nil && !errors.Is(err, websocket.ErrCloseSent) { - return err + c.closeOnLeave.Store(true) + if err := c.sendMessageLocked(message); err != nil { + c.closeOnLeave.Store(false) + if !errors.Is(err, websocket.ErrCloseSent) { + return err + } } - c.closeOnLeave.Store(true) return nil } func (c *FederationClient) Close() { c.closer.Close() + c.hub.removeFederationClient(c) c.mu.Lock() defer c.mu.Unlock() @@ -259,27 +308,28 @@ func (c *FederationClient) Close() { c.closeConnection(true) } +// +checklocks:c.mu func (c *FederationClient) closeConnection(withBye bool) { if c.conn == nil { return } if withBye { - if err := c.sendMessageLocked(&ClientMessage{ + if err := c.sendMessageLocked(&api.ClientMessage{ Type: "bye", }); err != nil && !isClosedError(err) { - log.Printf("Error sending bye on federation connection to %s: %s", c.URL(), err) + c.logger.Printf("Error sending bye on federation connection to %s: %s", c.URL(), err) } } closeMessage := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") deadline := time.Now().Add(writeWait) if err := c.conn.WriteControl(websocket.CloseMessage, closeMessage, deadline); err != nil && !isClosedError(err) { - log.Printf("Error sending close message on federation connection to %s: %s", c.URL(), err) + c.logger.Printf("Error sending close message on federation connection to %s: %s", c.URL(), err) } if err := c.conn.Close(); err != nil && !isClosedError(err) { - log.Printf("Error closing federation connection to %s: %s", c.URL(), err) + c.logger.Printf("Error closing federation connection to %s: %s", c.URL(), err) } c.conn = nil @@ -298,12 +348,13 @@ func (c *FederationClient) scheduleReconnect() { c.scheduleReconnectLocked() } +// +checklocks:c.mu func (c *FederationClient) scheduleReconnectLocked() { - c.reconnecting = true + c.reconnecting.Store(true) if c.hello.Swap(nil) != nil { - c.session.SendMessage(&ServerMessage{ + c.session.SendMessage(&api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "room", Type: "federation_interrupted", }, @@ -326,11 +377,12 @@ func (c *FederationClient) reconnect() { return } - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(c.hub.federationTimeout)) + ctx := log.NewLoggerContext(context.Background(), c.logger) + ctx, cancel := context.WithTimeout(ctx, time.Duration(c.hub.federationTimeout)) defer cancel() if err := c.connect(ctx); err != nil { - log.Printf("Error connecting to federation server %s for %s: %s", c.URL(), c.session.PublicId(), err) + c.logger.Printf("Error connecting to federation server %s for %s: %s", c.URL(), c.session.PublicId(), err) c.scheduleReconnect() return } @@ -354,7 +406,7 @@ func (c *FederationClient) readPump(conn *websocket.Conn) { } if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { - log.Printf("Error reading from %s for %s: %s", c.URL(), c.session.PublicId(), err) + c.logger.Printf("Error reading from %s for %s: %s", c.URL(), c.session.PublicId(), err) } c.scheduleReconnect() @@ -365,9 +417,9 @@ func (c *FederationClient) readPump(conn *websocket.Conn) { continue } - var msg ServerMessage + var msg api.ServerMessage if err := json.Unmarshal(data, &msg); err != nil { - log.Printf("Error unmarshalling %s from %s: %s", string(data), c.URL(), err) + c.logger.Printf("Error unmarshalling %s from %s: %s", string(data), c.URL(), err) continue } @@ -396,7 +448,7 @@ func (c *FederationClient) sendPing() { msg := strconv.FormatInt(now, 10) c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint if err := c.conn.WriteMessage(websocket.PingMessage, []byte(msg)); err != nil { - log.Printf("Could not send ping to federated client %s for %s: %v", c.URL(), c.session.PublicId(), err) + c.logger.Printf("Could not send ping to federated client %s for %s: %v", c.URL(), c.session.PublicId(), err) c.scheduleReconnectLocked() } } @@ -417,9 +469,9 @@ func (c *FederationClient) writePump() { func (c *FederationClient) closeWithError(err error) { c.Close() - var e *Error - if !errors.As(err, &e) { - e = NewError("federation_error", err.Error()) + e, ok := internal.AsErrorType[*api.Error](err) + if !ok { + e = api.NewError("federation_error", err.Error()) } var id string @@ -427,22 +479,23 @@ func (c *FederationClient) closeWithError(err error) { id = message.Id } - c.session.SendMessage(&ServerMessage{ + c.session.SendMessage(&api.ServerMessage{ Id: id, Type: "error", Error: e, }) } -func (c *FederationClient) sendHello(auth *FederationAuthParams) error { +func (c *FederationClient) sendHello(auth *api.FederationAuthParams) error { c.helloMu.Lock() defer c.helloMu.Unlock() return c.sendHelloLocked(auth) } -func (c *FederationClient) sendHelloLocked(auth *FederationAuthParams) error { - c.helloMsgId = newRandomString(8) +// +checklocks:c.helloMu +func (c *FederationClient) sendHelloLocked(auth *api.FederationAuthParams) error { + c.helloMsgId = internal.RandomString(8) authData, err := json.Marshal(auth) if err != nil { @@ -450,19 +503,19 @@ func (c *FederationClient) sendHelloLocked(auth *FederationAuthParams) error { } c.helloAuth = auth - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: c.helloMsgId, Type: "hello", - Hello: &HelloClientMessage{ - Version: HelloVersionV2, + Hello: &api.HelloClientMessage{ + Version: api.HelloVersionV2, Features: c.session.GetFeatures(), }, } if resumeId := c.resumeId; resumeId != "" { msg.Hello.ResumeId = resumeId } else { - msg.Hello.Auth = &HelloClientMessageAuth{ - Type: HelloClientTypeFederation, + msg.Hello.Auth = &api.HelloClientMessageAuth{ + Type: api.HelloClientTypeFederation, Url: c.federation.Load().NextcloudUrl, Params: authData, } @@ -470,29 +523,29 @@ func (c *FederationClient) sendHelloLocked(auth *FederationAuthParams) error { return c.SendMessage(msg) } -func (c *FederationClient) processWelcome(msg *ServerMessage) { - if !msg.Welcome.HasFeature(ServerFeatureFederation) { +func (c *FederationClient) processWelcome(msg *api.ServerMessage) { + if !msg.Welcome.HasFeature(api.ServerFeatureFederation) { c.closeWithError(ErrFederationNotSupported) return } - federationParams := &FederationAuthParams{ + federationParams := &api.FederationAuthParams{ Token: c.federation.Load().Token, } if err := c.sendHello(federationParams); err != nil { - log.Printf("Error sending hello message to %s for %s: %s", c.URL(), c.session.PublicId(), err) + c.logger.Printf("Error sending hello message to %s for %s: %s", c.URL(), c.session.PublicId(), err) c.closeWithError(err) } } -func (c *FederationClient) processHello(msg *ServerMessage) { +func (c *FederationClient) processHello(msg *api.ServerMessage) { c.resetReconnect() c.helloMu.Lock() defer c.helloMu.Unlock() if msg.Id != c.helloMsgId { - log.Printf("Received hello response %+v for unknown request, expected %s", msg, c.helloMsgId) + c.logger.Printf("Received hello response %+v for unknown request, expected %s", msg, c.helloMsgId) if err := c.sendHelloLocked(c.helloAuth); err != nil { c.closeWithError(err) } @@ -511,12 +564,12 @@ func (c *FederationClient) processHello(msg *ServerMessage) { c.closeWithError(err) } default: - log.Printf("Received hello error from federated client for %s to %s: %+v", c.session.PublicId(), c.URL(), msg) + c.logger.Printf("Received hello error from federated client for %s to %s: %+v", c.session.PublicId(), c.URL(), msg) c.closeWithError(msg.Error) } return } else if msg.Type != "hello" { - log.Printf("Received unknown hello response from federated client for %s to %s: %+v", c.session.PublicId(), c.URL(), msg) + c.logger.Printf("Received unknown hello response from federated client for %s to %s: %+v", c.session.PublicId(), c.URL(), msg) if err := c.sendHelloLocked(c.helloAuth); err != nil { c.closeWithError(err) } @@ -526,13 +579,13 @@ func (c *FederationClient) processHello(msg *ServerMessage) { c.hello.Store(msg.Hello) if c.resumeId == "" { c.resumeId = msg.Hello.ResumeId - if c.reconnecting { - c.session.SendMessage(&ServerMessage{ + if c.reconnecting.Load() { + c.session.SendMessage(&api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "room", Type: "federation_resumed", - Resumed: makePtr(false), + Resumed: internal.MakePtr(false), }, }) // Setting the federation client will reset any information on previously @@ -544,12 +597,12 @@ func (c *FederationClient) processHello(msg *ServerMessage) { c.closeWithError(err) } } else { - c.session.SendMessage(&ServerMessage{ + c.session.SendMessage(&api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "room", Type: "federation_resumed", - Resumed: makePtr(true), + Resumed: internal.MakePtr(true), }, }) @@ -557,7 +610,7 @@ func (c *FederationClient) processHello(msg *ServerMessage) { messages := c.pendingMessages c.pendingMessages = nil - log.Printf("Sending %d pending messages to %s for %s", count, c.URL(), c.session.PublicId()) + c.logger.Printf("Sending %d pending messages to %s for %s", count, c.URL(), c.session.PublicId()) c.helloMu.Unlock() defer c.helloMu.Lock() @@ -566,7 +619,7 @@ func (c *FederationClient) processHello(msg *ServerMessage) { defer c.mu.Unlock() for _, msg := range messages { if err := c.sendMessageLocked(msg); err != nil { - log.Printf("Error sending pending message %+v on federation connection to %s: %s", msg, c.URL(), err) + c.logger.Printf("Error sending pending message %+v on federation connection to %s: %s", msg, c.URL(), err) break } } @@ -587,43 +640,105 @@ func (c *FederationClient) joinRoom() error { remoteRoomId = room.RoomId } - return c.SendMessage(&ClientMessage{ + return c.SendMessage(&api.ClientMessage{ Id: message.Id, Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: remoteRoomId, SessionId: room.SessionId, }, }) } -func (c *FederationClient) updateEventUsers(users []map[string]interface{}, localSessionId string, remoteSessionId string) { - localCloudUrl := "@" + getCloudUrl(c.session.BackendUrl()) - localCloudUrlLen := len(localCloudUrl) - remoteCloudUrl := "@" + getCloudUrl(c.federation.Load().NextcloudUrl) - checkSessionId := true - for _, u := range users { - if actorType, found := getStringMapEntry[string](u, "actorType"); found { - if actorId, found := getStringMapEntry[string](u, "actorId"); found { - switch actorType { - case ActorTypeFederatedUsers: - if strings.HasSuffix(actorId, localCloudUrl) { - u["actorId"] = actorId[:len(actorId)-localCloudUrlLen] - u["actorType"] = ActorTypeUsers +func (c *FederationClient) updateActor(u api.StringMap, actorIdKey, actorTypeKey string, localCloudUrl string, remoteCloudUrl string) (changed bool) { + if actorType, found := api.GetStringMapEntry[string](u, actorTypeKey); found { + if actorId, found := api.GetStringMapEntry[string](u, actorIdKey); found { + switch actorType { + case api.ActorTypeFederatedUsers: + if strings.HasSuffix(actorId, localCloudUrl) { + u[actorIdKey] = actorId[:len(actorId)-len(localCloudUrl)] + u[actorTypeKey] = api.ActorTypeUsers + changed = true + } + case api.ActorTypeUsers: + u[actorIdKey] = actorId + remoteCloudUrl + u[actorTypeKey] = api.ActorTypeFederatedUsers + changed = true + } + } + } + return +} + +func (c *FederationClient) updateComment(comment api.StringMap, localCloudUrl string, remoteCloudUrl string) bool { + changed := c.updateActor(comment, "actorId", "actorType", localCloudUrl, remoteCloudUrl) + if c.updateActor(comment, "lastEditActorId", "lastEditActorType", localCloudUrl, remoteCloudUrl) { + changed = true + } + + if token, found := api.GetStringMapString[string](comment, "token"); found && c.changeRoomId.Load() && token == c.RemoteRoomId() { + comment["token"] = c.RoomId() + changed = true + } + + if params, found := api.GetStringMapEntry[map[string]any](comment, "messageParameters"); found { + localUrl := getCloudUrlWithoutPath(c.session.BackendUrl()) + remoteUrl := getCloudUrlWithoutPath(c.federation.Load().NextcloudUrl) + for key, paramOb := range params { + if !strings.HasPrefix(key, "mention-") { + // Only need to process mentions. + continue + } + + param, ok := api.ConvertStringMap(paramOb) + if !ok { + continue + } + + if ptype, found := api.GetStringMapString[string](param, "type"); found { + switch ptype { + case "user": + if server, found := api.GetStringMapString[string](param, "server"); found && server == localUrl { + delete(param, "server") + params[key] = param + changed = true + continue + } + + if _, found := api.GetStringMapString[string](param, "mention-id"); !found { + param["mention-id"] = param["id"] + param["server"] = remoteUrl + params[key] = param + changed = true + continue + } + case "call": + roomId := c.RoomId() + remoteRoomId := c.RemoteRoomId() + // TODO: Should we also rewrite the room avatar url in "icon-url"? + if c.changeRoomId.Load() && param["id"] == remoteRoomId { + param["id"] = roomId } - case ActorTypeUsers: - u["actorId"] = actorId + remoteCloudUrl - u["actorType"] = ActorTypeFederatedUsers } } } + } + return changed +} + +func (c *FederationClient) updateEventUsers(users []api.StringMap, localSessionId api.PublicSessionId, remoteSessionId api.PublicSessionId) { + localCloudUrl := "@" + getCloudUrl(c.session.BackendUrl()) + remoteCloudUrl := "@" + getCloudUrl(c.federation.Load().NextcloudUrl) + checkSessionId := true + for _, u := range users { + c.updateActor(u, "actorId", "actorType", localCloudUrl, remoteCloudUrl) if checkSessionId { key := "sessionId" - sid, found := getStringMapEntry[string](u, key) + sid, found := api.GetStringMapString[api.PublicSessionId](u, key) if !found { key := "sessionid" - sid, found = getStringMapEntry[string](u, key) + sid, found = api.GetStringMapString[api.PublicSessionId](u, key) } if found && sid == remoteSessionId { u[key] = localSessionId @@ -633,21 +748,21 @@ func (c *FederationClient) updateEventUsers(users []map[string]interface{}, loca } } -func (c *FederationClient) updateSessionRecipient(recipient *MessageClientMessageRecipient, localSessionId string, remoteSessionId string) { - if recipient != nil && recipient.Type == RecipientTypeSession && remoteSessionId != "" && recipient.SessionId == remoteSessionId { +func (c *FederationClient) updateSessionRecipient(recipient *api.MessageClientMessageRecipient, localSessionId api.PublicSessionId, remoteSessionId api.PublicSessionId) { + if recipient != nil && recipient.Type == api.RecipientTypeSession && remoteSessionId != "" && recipient.SessionId == remoteSessionId { recipient.SessionId = localSessionId } } -func (c *FederationClient) updateSessionSender(sender *MessageServerMessageSender, localSessionId string, remoteSessionId string) { - if sender != nil && sender.Type == RecipientTypeSession && remoteSessionId != "" && sender.SessionId == remoteSessionId { +func (c *FederationClient) updateSessionSender(sender *api.MessageServerMessageSender, localSessionId api.PublicSessionId, remoteSessionId api.PublicSessionId) { + if sender != nil && sender.Type == api.RecipientTypeSession && remoteSessionId != "" && sender.SessionId == remoteSessionId { sender.SessionId = localSessionId } } -func (c *FederationClient) processMessage(msg *ServerMessage) { +func (c *FederationClient) processMessage(msg *api.ServerMessage) { localSessionId := c.session.PublicId() - var remoteSessionId string + var remoteSessionId api.PublicSessionId if hello := c.hello.Load(); hello != nil { remoteSessionId = hello.SessionId } @@ -662,10 +777,10 @@ func (c *FederationClient) processMessage(msg *ServerMessage) { c.updateSessionSender(msg.Control.Sender, localSessionId, remoteSessionId) // Special handling for "forceMute" event. if len(msg.Control.Data) > 0 && msg.Control.Data[0] == '{' { - var data map[string]interface{} + var data api.StringMap if err := json.Unmarshal(msg.Control.Data, &data); err == nil { if action, found := data["action"]; found && action == "forceMute" { - if peerId, found := data["peerId"]; found && peerId == remoteSessionId { + if peerId, found := api.GetStringMapString[api.PublicSessionId](data, "peerId"); found && peerId == remoteSessionId { data["peerId"] = localSessionId if d, err := json.Marshal(data); err == nil { msg.Control.Data = d @@ -702,9 +817,10 @@ func (c *FederationClient) processMessage(msg *ServerMessage) { switch msg.Event.Type { case "join": if remoteSessionId != "" { - for _, j := range msg.Event.Join { + for idx, j := range msg.Event.Join { if j.SessionId == remoteSessionId { j.SessionId = localSessionId + msg.Event.Join[idx] = j break } } @@ -725,6 +841,32 @@ func (c *FederationClient) processMessage(msg *ServerMessage) { if c.changeRoomId.Load() && msg.Event.Message.RoomId == remoteRoomId { msg.Event.Message.RoomId = roomId } + if msg.Event.Type == "message" && msg.Event.Message != nil { + if data, err := msg.Event.Message.GetData(); err == nil { + if data.Type == "chat" && data.Chat != nil && len(data.Chat.Comment) > 0 { + var comment api.StringMap + if err := json.Unmarshal(data.Chat.Comment, &comment); err == nil { + localCloudUrl := "@" + getCloudUrl(c.session.BackendUrl()) + remoteCloudUrl := "@" + getCloudUrl(c.federation.Load().NextcloudUrl) + changed := c.updateComment(comment, localCloudUrl, remoteCloudUrl) + if parent, found := comment.GetStringMap("parent"); found { + if c.updateComment(parent, localCloudUrl, remoteCloudUrl) { + comment["parent"] = parent + changed = true + } + } + if changed { + if encoded, err := json.Marshal(comment); err == nil { + data.Chat.Comment = encoded + if encoded, err = json.Marshal(data); err == nil { + msg.Event.Message.Data = encoded + } + } + } + } + } + } + } } case "roomlist": switch msg.Event.Type { @@ -745,7 +887,7 @@ func (c *FederationClient) processMessage(msg *ServerMessage) { case "error": if c.changeRoomId.Load() && msg.Error.Code == "already_joined" { if len(msg.Error.Details) > 0 { - var details RoomErrorDetails + var details api.RoomErrorDetails if err := json.Unmarshal(msg.Error.Details, &details); err == nil && details.Room != nil { if details.Room.RoomId == remoteRoomId { details.Room.RoomId = roomId @@ -787,7 +929,7 @@ func (c *FederationClient) processMessage(msg *ServerMessage) { c.updateSessionRecipient(msg.Message.Recipient, localSessionId, remoteSessionId) c.updateSessionSender(msg.Message.Sender, localSessionId, remoteSessionId) if remoteSessionId != "" && len(msg.Message.Data) > 0 { - var ao AnswerOfferMessage + var ao api.AnswerOfferMessage if json.Unmarshal(msg.Message.Data, &ao) == nil && (ao.Type == "offer" || ao.Type == "answer") { changed := false if ao.From == remoteSessionId { @@ -806,6 +948,10 @@ func (c *FederationClient) processMessage(msg *ServerMessage) { } } } + case "transient": + if remoteSessionId != "" && msg.TransientData != nil && msg.TransientData.Key == api.TransientSessionDataPrefix+string(remoteSessionId) { + msg.TransientData.Key = api.TransientSessionDataPrefix + string(localSessionId) + } } c.session.SendMessage(msg) @@ -814,25 +960,31 @@ func (c *FederationClient) processMessage(msg *ServerMessage) { } } -func (c *FederationClient) ProxyMessage(message *ClientMessage) error { +func (c *FederationClient) ProxyMessage(message *api.ClientMessage) error { switch message.Type { case "message": if hello := c.hello.Load(); hello != nil { c.updateSessionRecipient(&message.Message.Recipient, hello.SessionId, c.session.PublicId()) } + case "transient": + if hello := c.hello.Load(); hello != nil { + if message.TransientData != nil && message.TransientData.Key == api.TransientSessionDataPrefix+string(c.session.PublicId()) { + message.TransientData.Key = api.TransientSessionDataPrefix + string(hello.SessionId) + } + } } return c.SendMessage(message) } -func (c *FederationClient) SendMessage(message *ClientMessage) error { +func (c *FederationClient) SendMessage(message *api.ClientMessage) error { c.mu.Lock() defer c.mu.Unlock() return c.sendMessageLocked(message) } -func (c *FederationClient) deferMessage(message *ClientMessage) { +func (c *FederationClient) deferMessage(message *api.ClientMessage) { c.helloMu.Lock() defer c.helloMu.Unlock() if c.resumeId == "" { @@ -841,11 +993,12 @@ func (c *FederationClient) deferMessage(message *ClientMessage) { c.pendingMessages = append(c.pendingMessages, message) if len(c.pendingMessages) >= warnPendingMessagesCount { - log.Printf("Session %s has %d pending federated messages", c.session.PublicId(), len(c.pendingMessages)) + c.logger.Printf("Session %s has %d pending federated messages", c.session.PublicId(), len(c.pendingMessages)) } } -func (c *FederationClient) sendMessageLocked(message *ClientMessage) error { +// +checklocks:c.mu +func (c *FederationClient) sendMessageLocked(message *api.ClientMessage) error { if c.conn == nil { if message.Type != "room" { // Join requests will be automatically sent after the hello response has @@ -858,7 +1011,7 @@ func (c *FederationClient) sendMessageLocked(message *ClientMessage) error { c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint writer, err := c.conn.NextWriter(websocket.TextMessage) if err == nil { - if m, ok := (interface{}(message)).(easyjson.Marshaler); ok { + if m, ok := (any(message)).(easyjson.Marshaler); ok { _, err = easyjson.MarshalToWriter(m, writer) } else { err = json.NewEncoder(writer).Encode(message) @@ -873,7 +1026,7 @@ func (c *FederationClient) sendMessageLocked(message *ClientMessage) error { return err } - log.Printf("Could not send message %+v for %s to federated client %s: %v", message, c.session.PublicId(), c.URL(), err) + c.logger.Printf("Could not send message %+v for %s to federated client %s: %v", message, c.session.PublicId(), c.URL(), err) c.deferMessage(message) c.scheduleReconnectLocked() } diff --git a/federation_test.go b/server/federation_test.go similarity index 57% rename from federation_test.go rename to server/federation_test.go index c185b5c..5c92364 100644 --- a/federation_test.go +++ b/server/federation_test.go @@ -19,22 +19,26 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" "encoding/json" + "fmt" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/mock" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/test" ) func Test_FederationInvalidToken(t *testing.T) { - CatchLogForTest(t) - + t.Parallel() assert := assert.New(t) require := require.New(t) @@ -47,16 +51,15 @@ func Test_FederationInvalidToken(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - _, err := client.RunUntilHello(ctx) - require.NoError(err) + MustSucceed1(t, client.RunUntilHello, ctx) - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: "join-room-fed", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: "test-room", SessionId: "room-session-id", - Federation: &RoomFederationMessage{ + Federation: &api.RoomFederationMessage{ SignalingUrl: server1.URL, NextcloudUrl: server1.URL, Token: "invalid-token", @@ -65,7 +68,7 @@ func Test_FederationInvalidToken(t *testing.T) { } require.NoError(client.WriteJSON(msg)) - if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client.RunUntilMessage(ctx); ok { assert.Equal(msg.Id, message.Id) require.Equal("error", message.Type) require.Equal("invalid_token", message.Error.Code) @@ -73,8 +76,7 @@ func Test_FederationInvalidToken(t *testing.T) { } func Test_Federation(t *testing.T) { - CatchLogForTest(t) - + t.Parallel() assert := assert.New(t) require := require.New(t) @@ -93,25 +95,21 @@ func Test_Federation(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) roomId := "test-room" federatedRoomId := roomId + "@federated" - room1, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + room1 := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, room1.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) room := hub1.getRoom(roomId) require.NotNil(room) now := time.Now() - userdata := map[string]interface{}{ + userdata := api.StringMap{ "displayname": "Federated user", "actorType": "federated_users", "actorId": "the-federated-user-id", @@ -119,13 +117,13 @@ func Test_Federation(t *testing.T) { token, err := client1.CreateHelloV2TokenWithUserdata(testDefaultUserId+"2", now, now.Add(time.Minute), userdata) require.NoError(err) - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: "join-room-fed", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: federatedRoomId, - SessionId: federatedRoomId + "-" + hello2.Hello.SessionId, - Federation: &RoomFederationMessage{ + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ SignalingUrl: server1.URL, NextcloudUrl: server1.URL, RoomId: roomId, @@ -135,16 +133,16 @@ func Test_Federation(t *testing.T) { } require.NoError(client2.WriteJSON(msg)) - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal(msg.Id, message.Id) require.Equal("room", message.Type) require.Equal(federatedRoomId, message.Room.RoomId) } // The client1 will see the remote session id for client2. - var remoteSessionId string - if message, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkSingleMessageJoined(message)) + var remoteSessionId api.PublicSessionId + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) evt := message.Event.Join[0] remoteSessionId = evt.SessionId assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) @@ -154,7 +152,7 @@ func Test_Federation(t *testing.T) { } // The client2 will see its own session id, not the one from the remote server. - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) tmpRoom1 := hub2.getRoom(roomId) assert.Nil(tmpRoom1) @@ -172,23 +170,24 @@ func Test_Federation(t *testing.T) { request1 := getPingRequests(t) clearPingRequests(t) - assert.Len(request1, 1) - if ping := request1[0].Ping; assert.NotNil(ping) { - assert.Equal(roomId, ping.RoomId) - assert.Equal("1.0", ping.Version) - assert.Len(ping.Entries, 2) - // The order of entries is not defined - if ping.Entries[0].SessionId == federatedRoomId+"-"+hello2.Hello.SessionId { - assert.Equal(hello2.Hello.UserId, ping.Entries[0].UserId) + if assert.Len(request1, 1) { + if ping := request1[0].Ping; assert.NotNil(ping) { + assert.Equal(roomId, ping.RoomId) + assert.Equal("1.0", ping.Version) + assert.Len(ping.Entries, 2) + // The order of entries is not defined + if ping.Entries[0].SessionId == api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId)) { + assert.Equal(hello2.Hello.UserId, ping.Entries[0].UserId) - assert.Equal(roomId+"-"+hello1.Hello.SessionId, ping.Entries[1].SessionId) - assert.Equal(hello1.Hello.UserId, ping.Entries[1].UserId) - } else { - assert.Equal(roomId+"-"+hello1.Hello.SessionId, ping.Entries[0].SessionId) - assert.Equal(hello1.Hello.UserId, ping.Entries[0].UserId) + assert.EqualValues(fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), ping.Entries[1].SessionId) + assert.Equal(hello1.Hello.UserId, ping.Entries[1].UserId) + } else { + assert.EqualValues(fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), ping.Entries[0].SessionId) + assert.Equal(hello1.Hello.UserId, ping.Entries[0].UserId) - assert.Equal(federatedRoomId+"-"+hello2.Hello.SessionId, ping.Entries[1].SessionId) - assert.Equal(hello2.Hello.UserId, ping.Entries[1].UserId) + assert.EqualValues(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId), ping.Entries[1].SessionId) + assert.Equal(hello2.Hello.UserId, ping.Entries[1].UserId) + } } } @@ -199,41 +198,42 @@ func Test_Federation(t *testing.T) { request2 := getPingRequests(t) clearPingRequests(t) - assert.Len(request2, 1) - if ping := request2[0].Ping; assert.NotNil(ping) { - assert.Equal(federatedRoomId, ping.RoomId) - assert.Equal("1.0", ping.Version) - assert.Len(ping.Entries, 1) - assert.Equal(federatedRoomId+"-"+hello2.Hello.SessionId, ping.Entries[0].SessionId) - assert.Equal(hello2.Hello.UserId, ping.Entries[0].UserId) + if assert.Len(request2, 1) { + if ping := request2[0].Ping; assert.NotNil(ping) { + assert.Equal(federatedRoomId, ping.RoomId) + assert.Equal("1.0", ping.Version) + assert.Len(ping.Entries, 1) + assert.EqualValues(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId), ping.Entries[0].SessionId) + assert.Equal(hello2.Hello.UserId, ping.Entries[0].UserId) + } } // Leaving and re-joining a room as "direct" session will trigger correct events. - if room, err := client1.JoinRoom(ctx, ""); assert.NoError(err) { - assert.Equal("", room.Room.RoomId) + if room, ok := client1.JoinRoom(ctx, ""); ok { + assert.Empty(room.Room.RoomId) } - assert.NoError(client2.RunUntilLeft(ctx, hello1.Hello)) + client2.RunUntilLeft(ctx, hello1.Hello) - if room, err := client1.JoinRoom(ctx, roomId); assert.NoError(err) { + if room, ok := client1.JoinRoom(ctx, roomId); ok { assert.Equal(roomId, room.Room.RoomId) } - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello, &HelloServerMessage{ + client1.RunUntilJoined(ctx, hello1.Hello, &api.HelloServerMessage{ SessionId: remoteSessionId, UserId: hello2.Hello.UserId, - })) - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello)) + }) + client2.RunUntilJoined(ctx, hello1.Hello) // Leaving and re-joining a room as "federated" session will trigger correct events. - if room, err := client2.JoinRoom(ctx, ""); assert.NoError(err) { - assert.Equal("", room.Room.RoomId) + if room, ok := client2.JoinRoom(ctx, ""); ok { + assert.Empty(room.Room.RoomId) } - assert.NoError(client1.RunUntilLeft(ctx, &HelloServerMessage{ + client1.RunUntilLeft(ctx, &api.HelloServerMessage{ SessionId: remoteSessionId, UserId: hello2.Hello.UserId, - })) + }) // The federated session has left the room, so no more pings. count3, wg3 := hub2.publishFederatedSessions() @@ -241,123 +241,117 @@ func Test_Federation(t *testing.T) { assert.Equal(0, count3) require.NoError(client2.WriteJSON(msg)) - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal(msg.Id, message.Id) require.Equal("room", message.Type) require.Equal(federatedRoomId, message.Room.RoomId) } // Client1 will receive the updated "remoteSessionId" - if message, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkSingleMessageJoined(message)) - evt := message.Event.Join[0] - remoteSessionId = evt.SessionId - assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) - assert.Equal(testDefaultUserId+"2", evt.UserId) - assert.True(evt.Federated) - assert.Equal(features2, evt.Features) + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) + if assert.Len(message.Event.Join, 1, "invalid message received: %+v", message) { + evt := message.Event.Join[0] + remoteSessionId = evt.SessionId + assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) + assert.Equal(testDefaultUserId+"2", evt.UserId) + assert.True(evt.Federated) + assert.Equal(features2, evt.Features) + } } - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) // Test sending messages between sessions. data1 := "from-1-to-2" data2 := "from-2-to-1" - if assert.NoError(client1.SendMessage(MessageClientMessageRecipient{ + if assert.NoError(client1.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: remoteSessionId, }, data1)) { var payload string - if assert.NoError(checkReceiveClientMessage(ctx, client2, "session", hello1.Hello, &payload)) { + if checkReceiveClientMessage(ctx, t, client2, "session", hello1.Hello, &payload) { assert.Equal(data1, payload) } } - if assert.NoError(client1.SendControl(MessageClientMessageRecipient{ + if assert.NoError(client1.SendControl(api.MessageClientMessageRecipient{ Type: "session", SessionId: remoteSessionId, }, data1)) { var payload string - if assert.NoError(checkReceiveClientControl(ctx, client2, "session", hello1.Hello, &payload)) { + if checkReceiveClientControl(ctx, t, client2, "session", hello1.Hello, &payload) { assert.Equal(data1, payload) } } - if assert.NoError(client2.SendMessage(MessageClientMessageRecipient{ + if assert.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, }, data2)) { var payload string - if assert.NoError(checkReceiveClientMessage(ctx, client1, "session", &HelloServerMessage{ + if checkReceiveClientMessage(ctx, t, client1, "session", &api.HelloServerMessage{ SessionId: remoteSessionId, UserId: testDefaultUserId + "2", - }, &payload)) { + }, &payload) { assert.Equal(data2, payload) } } - if assert.NoError(client2.SendControl(MessageClientMessageRecipient{ + if assert.NoError(client2.SendControl(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, }, data2)) { var payload string - if assert.NoError(checkReceiveClientControl(ctx, client1, "session", &HelloServerMessage{ + if checkReceiveClientControl(ctx, t, client1, "session", &api.HelloServerMessage{ SessionId: remoteSessionId, UserId: testDefaultUserId + "2", - }, &payload)) { + }, &payload) { assert.Equal(data2, payload) } } // Special handling for the "forceMute" control event. - forceMute := map[string]any{ + forceMute := api.StringMap{ "action": "forceMute", "peerId": remoteSessionId, } - if assert.NoError(client1.SendControl(MessageClientMessageRecipient{ + if assert.NoError(client1.SendControl(api.MessageClientMessageRecipient{ Type: "session", SessionId: remoteSessionId, }, forceMute)) { - var payload map[string]any - if assert.NoError(checkReceiveClientControl(ctx, client2, "session", hello1.Hello, &payload)) { + var payload api.StringMap + if checkReceiveClientControl(ctx, t, client2, "session", hello1.Hello, &payload) { // The sessionId in "peerId" will be replaced with the local one. - forceMute["peerId"] = hello2.Hello.SessionId + forceMute["peerId"] = string(hello2.Hello.SessionId) assert.Equal(forceMute, payload) } } data3 := "from-2-to-2" // Clients can't send to their own (local) session id. - if assert.NoError(client2.SendMessage(MessageClientMessageRecipient{ + if assert.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, }, data3)) { ctx2, cancel2 := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel2() - if message, err := client2.RunUntilMessage(ctx2); err == nil { - assert.Fail("expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client2.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) } // Clients can't send to their own (remote) session id. - if assert.NoError(client2.SendMessage(MessageClientMessageRecipient{ + if assert.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: remoteSessionId, }, data3)) { ctx2, cancel2 := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel2() - if message, err := client2.RunUntilMessage(ctx2); err == nil { - assert.Fail("expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client2.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) } // Simulate request from the backend that a federated user joined the call. - users := []map[string]interface{}{ + users := []api.StringMap{ { "sessionId": remoteSessionId, "inCall": 1, @@ -366,22 +360,24 @@ func Test_Federation(t *testing.T) { }, } room.PublishUsersInCallChanged(users, users) - var event *EventServerMessage + var event *api.EventServerMessage // For the local user, it's a federated user on server 2 that joined. - assert.NoError(checkReceiveClientEvent(ctx, client1, "update", &event)) - assert.Equal(remoteSessionId, event.Update.Users[0]["sessionId"]) - assert.Equal("remoteUser@"+strings.TrimPrefix(server2.URL, "http://"), event.Update.Users[0]["actorId"]) - assert.Equal("federated_users", event.Update.Users[0]["actorType"]) - assert.Equal(roomId, event.Update.RoomId) + if checkReceiveClientEvent(ctx, t, client1, "update", &event) { + assert.EqualValues(remoteSessionId, event.Update.Users[0]["sessionId"]) + assert.Equal("remoteUser@"+strings.TrimPrefix(server2.URL, "http://"), event.Update.Users[0]["actorId"]) + assert.Equal("federated_users", event.Update.Users[0]["actorType"]) + assert.Equal(roomId, event.Update.RoomId) + } // For the federated user, it's a local user that joined. - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", &event)) - assert.Equal(hello2.Hello.SessionId, event.Update.Users[0]["sessionId"]) - assert.Equal("remoteUser", event.Update.Users[0]["actorId"]) - assert.Equal("users", event.Update.Users[0]["actorType"]) - assert.Equal(federatedRoomId, event.Update.RoomId) + if checkReceiveClientEvent(ctx, t, client2, "update", &event) { + assert.EqualValues(hello2.Hello.SessionId, event.Update.Users[0]["sessionId"]) + assert.Equal("remoteUser", event.Update.Users[0]["actorId"]) + assert.Equal("users", event.Update.Users[0]["actorType"]) + assert.Equal(federatedRoomId, event.Update.RoomId) + } // Simulate request from the backend that a local user joined the call. - users = []map[string]interface{}{ + users = []api.StringMap{ { "sessionId": hello1.Hello.SessionId, "inCall": 1, @@ -391,17 +387,19 @@ func Test_Federation(t *testing.T) { } room.PublishUsersInCallChanged(users, users) // For the local user, it's a local user that joined. - assert.NoError(checkReceiveClientEvent(ctx, client1, "update", &event)) - assert.Equal(hello1.Hello.SessionId, event.Update.Users[0]["sessionId"]) - assert.Equal("localUser", event.Update.Users[0]["actorId"]) - assert.Equal("users", event.Update.Users[0]["actorType"]) - assert.Equal(roomId, event.Update.RoomId) + if checkReceiveClientEvent(ctx, t, client1, "update", &event) { + assert.EqualValues(hello1.Hello.SessionId, event.Update.Users[0]["sessionId"]) + assert.Equal("localUser", event.Update.Users[0]["actorId"]) + assert.Equal("users", event.Update.Users[0]["actorType"]) + assert.Equal(roomId, event.Update.RoomId) + } // For the federated user, it's a federated user on server 1 that joined. - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", &event)) - assert.Equal(hello1.Hello.SessionId, event.Update.Users[0]["sessionId"]) - assert.Equal("localUser@"+strings.TrimPrefix(server1.URL, "http://"), event.Update.Users[0]["actorId"]) - assert.Equal("federated_users", event.Update.Users[0]["actorType"]) - assert.Equal(federatedRoomId, event.Update.RoomId) + if checkReceiveClientEvent(ctx, t, client2, "update", &event) { + assert.EqualValues(hello1.Hello.SessionId, event.Update.Users[0]["sessionId"]) + assert.Equal("localUser@"+strings.TrimPrefix(server1.URL, "http://"), event.Update.Users[0]["actorId"]) + assert.Equal("federated_users", event.Update.Users[0]["actorType"]) + assert.Equal(federatedRoomId, event.Update.RoomId) + } // Joining another "direct" session will trigger correct events. @@ -409,20 +407,19 @@ func Test_Federation(t *testing.T) { defer client3.CloseWithBye() require.NoError(client3.SendHelloV2(testDefaultUserId + "3")) - hello3, err := client3.RunUntilHello(ctx) - require.NoError(err) + hello3 := MustSucceed1(t, client3.RunUntilHello, ctx) - if room, err := client3.JoinRoom(ctx, roomId); assert.NoError(err) { + if room, ok := client3.JoinRoom(ctx, roomId); ok { require.Equal(roomId, room.Room.RoomId) } - assert.NoError(client1.RunUntilJoined(ctx, hello3.Hello)) - assert.NoError(client2.RunUntilJoined(ctx, hello3.Hello)) + client1.RunUntilJoined(ctx, hello3.Hello) + client2.RunUntilJoined(ctx, hello3.Hello) - assert.NoError(client3.RunUntilJoined(ctx, hello1.Hello, &HelloServerMessage{ + client3.RunUntilJoined(ctx, hello1.Hello, &api.HelloServerMessage{ SessionId: remoteSessionId, UserId: hello2.Hello.UserId, - }, hello3.Hello)) + }, hello3.Hello) // Joining another "federated" session will trigger correct events. @@ -430,10 +427,9 @@ func Test_Federation(t *testing.T) { defer client4.CloseWithBye() require.NoError(client4.SendHelloV2WithFeatures(testDefaultUserId+"4", features2)) - hello4, err := client4.RunUntilHello(ctx) - require.NoError(err) + hello4 := MustSucceed1(t, client4.RunUntilHello, ctx) - userdata = map[string]interface{}{ + userdata = api.StringMap{ "displayname": "Federated user 2", "actorType": "federated_users", "actorId": "the-other-federated-user-id", @@ -441,13 +437,13 @@ func Test_Federation(t *testing.T) { token, err = client1.CreateHelloV2TokenWithUserdata(testDefaultUserId+"4", now, now.Add(time.Minute), userdata) require.NoError(err) - msg = &ClientMessage{ + msg = &api.ClientMessage{ Id: "join-room-fed", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: federatedRoomId, - SessionId: federatedRoomId + "-" + hello4.Hello.SessionId, - Federation: &RoomFederationMessage{ + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello4.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ SignalingUrl: server1.URL, NextcloudUrl: server1.URL, RoomId: roomId, @@ -457,16 +453,16 @@ func Test_Federation(t *testing.T) { } require.NoError(client4.WriteJSON(msg)) - if message, err := client4.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client4.RunUntilMessage(ctx); ok { assert.Equal(msg.Id, message.Id) require.Equal("room", message.Type) require.Equal(federatedRoomId, message.Room.RoomId) } // The client1 will see the remote session id for client4. - var remoteSessionId4 string - if message, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkSingleMessageJoined(message)) + var remoteSessionId4 api.PublicSessionId + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) evt := message.Event.Join[0] remoteSessionId4 = evt.SessionId assert.NotEqual(hello4.Hello.SessionId, remoteSessionId) @@ -475,30 +471,28 @@ func Test_Federation(t *testing.T) { assert.Equal(features2, evt.Features) } - assert.NoError(client2.RunUntilJoined(ctx, &HelloServerMessage{ + client2.RunUntilJoined(ctx, &api.HelloServerMessage{ SessionId: remoteSessionId4, UserId: hello4.Hello.UserId, - })) + }) - assert.NoError(client3.RunUntilJoined(ctx, &HelloServerMessage{ + client3.RunUntilJoined(ctx, &api.HelloServerMessage{ SessionId: remoteSessionId4, UserId: hello4.Hello.UserId, - })) + }) - assert.NoError(client4.RunUntilJoined(ctx, hello1.Hello, &HelloServerMessage{ + client4.RunUntilJoined(ctx, hello1.Hello, &api.HelloServerMessage{ SessionId: remoteSessionId, UserId: hello2.Hello.UserId, - }, hello3.Hello, hello4.Hello)) + }, hello3.Hello, hello4.Hello) - room3, err := client2.JoinRoom(ctx, "") - if assert.NoError(err) { - assert.Equal("", room3.Room.RoomId) + if room3, ok := client2.JoinRoom(ctx, ""); ok { + assert.Empty(room3.Room.RoomId) } } func Test_FederationJoinRoomTwice(t *testing.T) { - CatchLogForTest(t) - + t.Parallel() assert := assert.New(t) require := require.New(t) @@ -515,22 +509,18 @@ func Test_FederationJoinRoomTwice(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) roomId := "test-room" federatedRoomId := roomId + "@federated" - room1, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + room1 := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, room1.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) now := time.Now() - userdata := map[string]interface{}{ + userdata := api.StringMap{ "displayname": "Federated user", "actorType": "federated_users", "actorId": "the-federated-user-id", @@ -538,13 +528,13 @@ func Test_FederationJoinRoomTwice(t *testing.T) { token, err := client1.CreateHelloV2TokenWithUserdata(testDefaultUserId+"2", now, now.Add(time.Minute), userdata) require.NoError(err) - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: "join-room-fed", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: federatedRoomId, - SessionId: federatedRoomId + "-" + hello2.Hello.SessionId, - Federation: &RoomFederationMessage{ + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ SignalingUrl: server1.URL, NextcloudUrl: server1.URL, RoomId: roomId, @@ -554,16 +544,16 @@ func Test_FederationJoinRoomTwice(t *testing.T) { } require.NoError(client2.WriteJSON(msg)) - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal(msg.Id, message.Id) require.Equal("room", message.Type) require.Equal(federatedRoomId, message.Room.RoomId) } // The client1 will see the remote session id for client2. - var remoteSessionId string - if message, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkSingleMessageJoined(message)) + var remoteSessionId api.PublicSessionId + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) evt := message.Event.Join[0] remoteSessionId = evt.SessionId assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) @@ -572,15 +562,15 @@ func Test_FederationJoinRoomTwice(t *testing.T) { } // The client2 will see its own session id, not the one from the remote server. - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) - msg2 := &ClientMessage{ + msg2 := &api.ClientMessage{ Id: "join-room-fed-2", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: federatedRoomId, - SessionId: federatedRoomId + "-" + hello2.Hello.SessionId, - Federation: &RoomFederationMessage{ + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ SignalingUrl: server1.URL, NextcloudUrl: server1.URL, RoomId: roomId, @@ -590,13 +580,13 @@ func Test_FederationJoinRoomTwice(t *testing.T) { } require.NoError(client2.WriteJSON(msg2)) - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal(msg2.Id, message.Id) if assert.Equal("error", message.Type) { assert.Equal("already_joined", message.Error.Code) } if assert.NotNil(message.Error.Details) { - var roomMsg RoomErrorDetails + var roomMsg api.RoomErrorDetails if assert.NoError(json.Unmarshal(message.Error.Details, &roomMsg)) { if assert.NotNil(roomMsg.Room) { assert.Equal(federatedRoomId, roomMsg.Room.RoomId) @@ -608,8 +598,7 @@ func Test_FederationJoinRoomTwice(t *testing.T) { } func Test_FederationChangeRoom(t *testing.T) { - CatchLogForTest(t) - + t.Parallel() assert := assert.New(t) require := require.New(t) @@ -626,22 +615,18 @@ func Test_FederationChangeRoom(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) roomId := "test-room" federatedRoomId := roomId + "@federated" - room1, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + room1 := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, room1.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) now := time.Now() - userdata := map[string]interface{}{ + userdata := api.StringMap{ "displayname": "Federated user", "actorType": "federated_users", "actorId": "the-federated-user-id", @@ -649,13 +634,13 @@ func Test_FederationChangeRoom(t *testing.T) { token, err := client1.CreateHelloV2TokenWithUserdata(testDefaultUserId+"2", now, now.Add(time.Minute), userdata) require.NoError(err) - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: "join-room-fed", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: federatedRoomId, - SessionId: federatedRoomId + "-" + hello2.Hello.SessionId, - Federation: &RoomFederationMessage{ + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ SignalingUrl: server1.URL, NextcloudUrl: server1.URL, RoomId: roomId, @@ -665,7 +650,7 @@ func Test_FederationChangeRoom(t *testing.T) { } require.NoError(client2.WriteJSON(msg)) - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal(msg.Id, message.Id) require.Equal("room", message.Type) require.Equal(federatedRoomId, message.Room.RoomId) @@ -674,12 +659,12 @@ func Test_FederationChangeRoom(t *testing.T) { session2 := hub2.GetSessionByPublicId(hello2.Hello.SessionId).(*ClientSession) fed := session2.GetFederationClient() require.NotNil(fed) - localAddr := fed.conn.LocalAddr() + localAddr := fed.LocalAddr() // The client1 will see the remote session id for client2. - var remoteSessionId string - if message, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkSingleMessageJoined(message)) + var remoteSessionId api.PublicSessionId + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) evt := message.Event.Join[0] remoteSessionId = evt.SessionId assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) @@ -688,17 +673,17 @@ func Test_FederationChangeRoom(t *testing.T) { } // The client2 will see its own session id, not the one from the remote server. - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) roomId2 := roomId + "-2" federatedRoomId2 := roomId2 + "@federated" - msg2 := &ClientMessage{ + msg2 := &api.ClientMessage{ Id: "join-room-fed-2", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: federatedRoomId2, - SessionId: federatedRoomId2 + "-" + hello2.Hello.SessionId, - Federation: &RoomFederationMessage{ + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId2, hello2.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ SignalingUrl: server1.URL, NextcloudUrl: server1.URL, RoomId: roomId2, @@ -708,7 +693,7 @@ func Test_FederationChangeRoom(t *testing.T) { } require.NoError(client2.WriteJSON(msg2)) - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal(msg2.Id, message.Id) require.Equal("room", message.Type) require.Equal(federatedRoomId2, message.Room.RoomId) @@ -716,13 +701,12 @@ func Test_FederationChangeRoom(t *testing.T) { fed2 := session2.GetFederationClient() require.NotNil(fed2) - localAddr2 := fed2.conn.LocalAddr() + localAddr2 := fed2.LocalAddr() assert.Equal(localAddr, localAddr2) } func Test_FederationMedia(t *testing.T) { - CatchLogForTest(t) - + t.Parallel() assert := assert.New(t) require := require.New(t) @@ -731,15 +715,13 @@ func Test_FederationMedia(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - mcu1, err := NewTestMCU() - require.NoError(err) + mcu1 := test.NewSFU(t) require.NoError(mcu1.Start(ctx)) defer mcu1.Stop() hub1.SetMcu(mcu1) - mcu2, err := NewTestMCU() - require.NoError(err) + mcu2 := test.NewSFU(t) require.NoError(mcu2.Start(ctx)) defer mcu2.Stop() @@ -753,22 +735,18 @@ func Test_FederationMedia(t *testing.T) { defer client2.CloseWithBye() require.NoError(client2.SendHelloV2(testDefaultUserId + "2")) - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) roomId := "test-room" - federatedRooId := roomId + "@federated" - room1, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + federatedRoomId := roomId + "@federated" + room1 := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, room1.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) now := time.Now() - userdata := map[string]interface{}{ + userdata := api.StringMap{ "displayname": "Federated user", "actorType": "federated_users", "actorId": "the-federated-user-id", @@ -776,13 +754,13 @@ func Test_FederationMedia(t *testing.T) { token, err := client1.CreateHelloV2TokenWithUserdata(testDefaultUserId+"2", now, now.Add(time.Minute), userdata) require.NoError(err) - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: "join-room-fed", Type: "room", - Room: &RoomClientMessage{ - RoomId: federatedRooId, - SessionId: federatedRooId + "-" + hello2.Hello.SessionId, - Federation: &RoomFederationMessage{ + Room: &api.RoomClientMessage{ + RoomId: federatedRoomId, + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ SignalingUrl: server1.URL, NextcloudUrl: server1.URL, RoomId: roomId, @@ -792,16 +770,16 @@ func Test_FederationMedia(t *testing.T) { } require.NoError(client2.WriteJSON(msg)) - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal(msg.Id, message.Id) require.Equal("room", message.Type) - require.Equal(federatedRooId, message.Room.RoomId) + require.Equal(federatedRoomId, message.Room.RoomId) } // The client1 will see the remote session id for client2. - var remoteSessionId string - if message, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkSingleMessageJoined(message)) + var remoteSessionId api.PublicSessionId + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) evt := message.Event.Join[0] remoteSessionId = evt.SessionId assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) @@ -810,30 +788,29 @@ func Test_FederationMedia(t *testing.T) { } // The client2 will see its own session id, not the one from the remote server. - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) - require.NoError(client2.SendMessage(MessageClientMessageRecipient{ + require.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "offer", Sid: "12345", RoomType: "screen", - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioAndVideo, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, }, })) - require.NoError(client2.RunUntilAnswerFromSender(ctx, MockSdpAnswerAudioAndVideo, &MessageServerMessageSender{ + client2.RunUntilAnswerFromSender(ctx, mock.MockSdpAnswerAudioAndVideo, &api.MessageServerMessageSender{ Type: "session", SessionId: hello2.Hello.SessionId, UserId: hello2.Hello.UserId, - })) + }) } func Test_FederationResume(t *testing.T) { - CatchLogForTest(t) - + t.Parallel() assert := assert.New(t) require := require.New(t) @@ -850,22 +827,18 @@ func Test_FederationResume(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) roomId := "test-room" federatedRoomId := roomId + "@federated" - room1, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + room1 := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, room1.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) now := time.Now() - userdata := map[string]interface{}{ + userdata := api.StringMap{ "displayname": "Federated user", "actorType": "federated_users", "actorId": "the-federated-user-id", @@ -873,13 +846,13 @@ func Test_FederationResume(t *testing.T) { token, err := client1.CreateHelloV2TokenWithUserdata(testDefaultUserId+"2", now, now.Add(time.Minute), userdata) require.NoError(err) - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: "join-room-fed", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: federatedRoomId, - SessionId: federatedRoomId + "-" + hello2.Hello.SessionId, - Federation: &RoomFederationMessage{ + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ SignalingUrl: server1.URL, NextcloudUrl: server1.URL, RoomId: roomId, @@ -889,16 +862,16 @@ func Test_FederationResume(t *testing.T) { } require.NoError(client2.WriteJSON(msg)) - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal(msg.Id, message.Id) require.Equal("room", message.Type) require.Equal(federatedRoomId, message.Room.RoomId) } // The client1 will see the remote session id for client2. - var remoteSessionId string - if message, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkSingleMessageJoined(message)) + var remoteSessionId api.PublicSessionId + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) evt := message.Event.Join[0] remoteSessionId = evt.SessionId assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) @@ -907,7 +880,7 @@ func Test_FederationResume(t *testing.T) { } // The client2 will see its own session id, not the one from the remote server. - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) session2 := hub2.GetSessionByPublicId(hello2.Hello.SessionId).(*ClientSession) fed2 := session2.GetFederationClient() @@ -916,20 +889,20 @@ func Test_FederationResume(t *testing.T) { err = fed2.conn.Close() data2 := "from-2-to-1" - assert.NoError(client2.SendMessage(MessageClientMessageRecipient{ + assert.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, }, data2)) fed2.mu.Unlock() assert.NoError(err) - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal("event", message.Type) assert.Equal("room", message.Event.Target) assert.Equal("federation_interrupted", message.Event.Type) } - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal("event", message.Type) assert.Equal("room", message.Event.Target) assert.Equal("federation_resumed", message.Event.Type) @@ -941,32 +914,23 @@ func Test_FederationResume(t *testing.T) { defer cancel1() var payload string - if assert.NoError(checkReceiveClientMessage(ctx, client1, "session", &HelloServerMessage{ + if checkReceiveClientMessage(ctx, t, client1, "session", &api.HelloServerMessage{ SessionId: remoteSessionId, UserId: testDefaultUserId + "2", - }, &payload)) { + }, &payload) { assert.Equal(data2, payload) } - if message, err := client1.RunUntilMessage(ctx1); err != nil && err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } else { - assert.Nil(message) - } + client1.RunUntilErrorIs(ctx1, ErrNoMessageReceived, context.DeadlineExceeded) ctx2, cancel2 := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel2() - if message, err := client2.RunUntilMessage(ctx2); err != nil && err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } else { - assert.Nil(message) - } + client2.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) } func Test_FederationResumeNewSession(t *testing.T) { - CatchLogForTest(t) - + t.Parallel() assert := assert.New(t) require := require.New(t) @@ -983,22 +947,18 @@ func Test_FederationResumeNewSession(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) roomId := "test-room" federatedRoomId := roomId + "@federated" - room1, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + room1 := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, room1.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) now := time.Now() - userdata := map[string]interface{}{ + userdata := api.StringMap{ "displayname": "Federated user", "actorType": "federated_users", "actorId": "the-federated-user-id", @@ -1006,13 +966,13 @@ func Test_FederationResumeNewSession(t *testing.T) { token, err := client1.CreateHelloV2TokenWithUserdata(testDefaultUserId+"2", now, now.Add(time.Minute), userdata) require.NoError(err) - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: "join-room-fed", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: federatedRoomId, - SessionId: federatedRoomId + "-" + hello2.Hello.SessionId, - Federation: &RoomFederationMessage{ + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ SignalingUrl: server1.URL, NextcloudUrl: server1.URL, RoomId: roomId, @@ -1022,16 +982,16 @@ func Test_FederationResumeNewSession(t *testing.T) { } require.NoError(client2.WriteJSON(msg)) - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal(msg.Id, message.Id) require.Equal("room", message.Type) require.Equal(federatedRoomId, message.Room.RoomId) } // The client1 will see the remote session id for client2. - var remoteSessionId string - if message, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkSingleMessageJoined(message)) + var remoteSessionId api.PublicSessionId + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) evt := message.Event.Join[0] remoteSessionId = evt.SessionId assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) @@ -1040,7 +1000,7 @@ func Test_FederationResumeNewSession(t *testing.T) { } // The client2 will see its own session id, not the one from the remote server. - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) remoteSession2 := hub1.GetSessionByPublicId(remoteSessionId).(*ClientSession) // Simulate disconnected federated client with an expired session. @@ -1050,13 +1010,13 @@ func Test_FederationResumeNewSession(t *testing.T) { } remoteSession2.Close() - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal("event", message.Type) assert.Equal("room", message.Event.Target) assert.Equal("federation_interrupted", message.Event.Type) } - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if message, ok := client2.RunUntilMessage(ctx); ok { assert.Equal("event", message.Type) assert.Equal("room", message.Event.Target) assert.Equal("federation_resumed", message.Event.Type) @@ -1066,12 +1026,12 @@ func Test_FederationResumeNewSession(t *testing.T) { // Client1 will get a "leave" for the expired session and a "join" with the // new remote session id. - assert.NoError(client1.RunUntilLeft(ctx, &HelloServerMessage{ + client1.RunUntilLeft(ctx, &api.HelloServerMessage{ SessionId: remoteSessionId, UserId: hello2.Hello.UserId, - })) - if message, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkSingleMessageJoined(message)) + }) + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) evt := message.Event.Join[0] assert.NotEqual(remoteSessionId, evt.SessionId) assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) @@ -1084,10 +1044,120 @@ func Test_FederationResumeNewSession(t *testing.T) { // client2 will join the room again after the reconnect with the new // session and get "joined" events for all sessions in the room (including // its own). - if message, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - assert.Equal("", message.Id) + if message, ok := client2.RunUntilMessage(ctx); ok { + assert.Empty(message.Id) require.Equal("room", message.Type) require.Equal(federatedRoomId, message.Room.RoomId) } - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) +} + +func Test_FederationTransientData(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + hub1, hub2, server1, server2 := CreateClusteredHubsForTest(t) + + client1 := NewTestClient(t, server1, hub1) + defer client1.CloseWithBye() + require.NoError(client1.SendHelloV2(testDefaultUserId + "1")) + + client2 := NewTestClient(t, server2, hub2) + defer client2.CloseWithBye() + require.NoError(client2.SendHelloV2(testDefaultUserId + "2")) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) + + roomId := "test-room" + federatedRoomId := roomId + "@federated" + room1 := MustSucceed2(t, client1.JoinRoom, ctx, roomId) + require.Equal(roomId, room1.Room.RoomId) + + client1.RunUntilJoined(ctx, hello1.Hello) + + now := time.Now() + userdata := api.StringMap{ + "displayname": "Federated user", + "actorType": "federated_users", + "actorId": "the-federated-user-id", + } + token, err := client1.CreateHelloV2TokenWithUserdata(testDefaultUserId+"2", now, now.Add(time.Minute), userdata) + require.NoError(err) + + msg := &api.ClientMessage{ + Id: "join-room-fed", + Type: "room", + Room: &api.RoomClientMessage{ + RoomId: federatedRoomId, + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", federatedRoomId, hello2.Hello.SessionId)), + Federation: &api.RoomFederationMessage{ + SignalingUrl: server1.URL, + NextcloudUrl: server1.URL, + RoomId: roomId, + Token: token, + }, + }, + } + require.NoError(client2.WriteJSON(msg)) + + if message, ok := client2.RunUntilMessage(ctx); ok { + assert.Equal(msg.Id, message.Id) + require.Equal("room", message.Type) + require.Equal(federatedRoomId, message.Room.RoomId) + } + + // The client1 will see the remote session id for client2. + var remoteSessionId api.PublicSessionId + if message, ok := client1.RunUntilMessage(ctx); ok { + client1.checkSingleMessageJoined(message) + evt := message.Event.Join[0] + remoteSessionId = evt.SessionId + assert.NotEqual(hello2.Hello.SessionId, remoteSessionId) + assert.Equal(hello2.Hello.UserId, evt.UserId) + assert.True(evt.Federated) + } + + // The client2 will see its own session id, not the one from the remote server. + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) + + // Regular transient data. + require.NoError(client1.SetTransientData("foo", "bar", 0)) + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, "foo", "bar", nil) + } + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, "foo", "bar", nil) + } + + require.NoError(client2.SetTransientData("bar", "baz", 0)) + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, "bar", "baz", nil) + } + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, "bar", "baz", nil) + } + + // Transient session data + sessionKey1 := "sd:" + string(hello1.Hello.SessionId) + require.NoError(client1.SetTransientData(sessionKey1, "12345", 0)) + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, sessionKey1, "12345", nil) + } + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, sessionKey1, "12345", nil) + } + + sessionKey2 := "sd:" + string(hello2.Hello.SessionId) + require.NoError(client2.SetTransientData(sessionKey2, "54321", 0)) + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, "sd:"+string(remoteSessionId), "54321", nil) + } + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, sessionKey2, "54321", nil) + } } diff --git a/grpc_remote_client.go b/server/grpc_remote_client.go similarity index 68% rename from grpc_remote_client.go rename to server/grpc_remote_client.go index 8940fde..a81fdd2 100644 --- a/grpc_remote_client.go +++ b/server/grpc_remote_client.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" @@ -27,12 +27,17 @@ import ( "errors" "fmt" "io" - "log" "sync/atomic" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/client" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) const ( @@ -49,22 +54,23 @@ func getMD(md metadata.MD, key string) string { // remoteGrpcClient is a remote client connecting from a GRPC proxy to a Hub. type remoteGrpcClient struct { + logger log.Logger hub *Hub - client RpcSessions_ProxySessionServer + client grpc.RpcSessions_ProxySessionServer - sessionId string + sessionId api.PublicSessionId remoteAddr string - country string + country geoip.Country userAgent string closeCtx context.Context closeFunc context.CancelCauseFunc session atomic.Pointer[Session] - messages chan WritableClientMessage + messages chan client.WritableClientMessage } -func newRemoteGrpcClient(hub *Hub, request RpcSessions_ProxySessionServer) (*remoteGrpcClient, error) { +func newRemoteGrpcClient(hub *Hub, request grpc.RpcSessions_ProxySessionServer) (*remoteGrpcClient, error) { md, found := metadata.FromIncomingContext(request.Context()) if !found { return nil, errors.New("no metadata provided") @@ -73,18 +79,19 @@ func newRemoteGrpcClient(hub *Hub, request RpcSessions_ProxySessionServer) (*rem closeCtx, closeFunc := context.WithCancelCause(context.Background()) result := &remoteGrpcClient{ + logger: hub.logger, hub: hub, client: request, - sessionId: getMD(md, "sessionId"), + sessionId: api.PublicSessionId(getMD(md, "sessionId")), remoteAddr: getMD(md, "remoteAddr"), - country: getMD(md, "country"), + country: geoip.Country(getMD(md, "country")), userAgent: getMD(md, "userAgent"), closeCtx: closeCtx, closeFunc: closeFunc, - messages: make(chan WritableClientMessage, grpcRemoteClientMessageQueue), + messages: make(chan client.WritableClientMessage, grpcRemoteClientMessageQueue), } return result, nil } @@ -93,7 +100,7 @@ func (c *remoteGrpcClient) readPump() { var closeError error defer func() { c.closeFunc(closeError) - c.hub.OnClosed(c) + c.hub.processUnregister(c) }() for { @@ -105,13 +112,13 @@ func (c *remoteGrpcClient) readPump() { } if status.Code(err) != codes.Canceled { - log.Printf("Error reading from remote client for session %s: %s", c.sessionId, err) + c.logger.Printf("Error reading from remote client for session %s: %s", c.sessionId, err) closeError = err } break } - c.hub.OnMessageReceived(c, msg.Message) + c.hub.processMessage(c, msg.Message) } } @@ -127,7 +134,7 @@ func (c *remoteGrpcClient) UserAgent() string { return c.userAgent } -func (c *remoteGrpcClient) Country() string { +func (c *remoteGrpcClient) Country() geoip.Country { return c.country } @@ -139,6 +146,10 @@ func (c *remoteGrpcClient) IsAuthenticated() bool { return c.GetSession() != nil } +func (c *remoteGrpcClient) GetSessionId() api.PublicSessionId { + return c.sessionId +} + func (c *remoteGrpcClient) GetSession() Session { session := c.session.Load() if session == nil { @@ -156,20 +167,20 @@ func (c *remoteGrpcClient) SetSession(session Session) { } } -func (c *remoteGrpcClient) SendError(e *Error) bool { - message := &ServerMessage{ +func (c *remoteGrpcClient) SendError(e *api.Error) bool { + message := &api.ServerMessage{ Type: "error", Error: e, } return c.SendMessage(message) } -func (c *remoteGrpcClient) SendByeResponse(message *ClientMessage) bool { +func (c *remoteGrpcClient) SendByeResponse(message *api.ClientMessage) bool { return c.SendByeResponseWithReason(message, "") } -func (c *remoteGrpcClient) SendByeResponseWithReason(message *ClientMessage, reason string) bool { - response := &ServerMessage{ +func (c *remoteGrpcClient) SendByeResponseWithReason(message *api.ClientMessage, reason string) bool { + response := &api.ServerMessage{ Type: "bye", } if message != nil { @@ -177,14 +188,14 @@ func (c *remoteGrpcClient) SendByeResponseWithReason(message *ClientMessage, rea } if reason != "" { if response.Bye == nil { - response.Bye = &ByeServerMessage{} + response.Bye = &api.ByeServerMessage{} } response.Bye.Reason = reason } return c.SendMessage(response) } -func (c *remoteGrpcClient) SendMessage(message WritableClientMessage) bool { +func (c *remoteGrpcClient) SendMessage(message client.WritableClientMessage) bool { if c.closeCtx.Err() != nil { return false } @@ -193,7 +204,7 @@ func (c *remoteGrpcClient) SendMessage(message WritableClientMessage) bool { case c.messages <- message: return true default: - log.Printf("Message queue for remote client of session %s is full, not sending %+v", c.sessionId, message) + c.logger.Printf("Message queue for remote client of session %s is full, not sending %+v", c.sessionId, message) return false } } @@ -215,11 +226,11 @@ func (c *remoteGrpcClient) run() error { case msg := <-c.messages: data, err := json.Marshal(msg) if err != nil { - log.Printf("Error marshalling %+v for remote client for session %s: %s", msg, c.sessionId, err) + c.logger.Printf("Error marshalling %+v for remote client for session %s: %s", msg, c.sessionId, err) continue } - if err := c.client.Send(&ServerSessionMessage{ + if err := c.client.Send(&grpc.ServerSessionMessage{ Message: data, }); err != nil { return fmt.Errorf("error sending %+v to remote client for session %s: %w", msg, c.sessionId, err) diff --git a/hub.go b/server/hub.go similarity index 51% rename from hub.go rename to server/hub.go index 747b435..b97546e 100644 --- a/hub.go +++ b/server/hub.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "bytes" @@ -36,7 +36,6 @@ import ( "errors" "fmt" "hash/fnv" - "log" "net" "net/http" "net/url" @@ -45,27 +44,58 @@ import ( "sync" "sync/atomic" "time" + unsafe "unsafe" "github.com/dlintw/goconf" "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" "github.com/gorilla/websocket" - "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + "github.com/strukturag/nextcloud-spreed-signaling/v2/client" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/container" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/session" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) var ( - DuplicateClient = NewError("duplicate_client", "Client already registered.") - HelloExpected = NewError("hello_expected", "Expected Hello request.") - InvalidHelloVersion = NewError("invalid_hello_version", "The hello version is not supported.") - UserAuthFailed = NewError("auth_failed", "The user could not be authenticated.") - RoomJoinFailed = NewError("room_join_failed", "Could not join the room.") - InvalidClientType = NewError("invalid_client_type", "The client type is not supported.") - InvalidBackendUrl = NewError("invalid_backend", "The backend URL is not supported.") - InvalidToken = NewError("invalid_token", "The passed token is invalid.") - NoSuchSession = NewError("no_such_session", "The session to resume does not exist.") - TokenNotValidYet = NewError("token_not_valid_yet", "The token is not valid yet.") - TokenExpired = NewError("token_expired", "The token is expired.") - TooManyRequests = NewError("too_many_requests", "Too many requests.") + // HelloExpected is returned if a client sends a message before the "hello" request. + HelloExpected = api.NewError("hello_expected", "Expected Hello request.") + // UserAuthFailed is returned if the Talk response to a v1 hello is not an error but also not a valid auth response. + UserAuthFailed = api.NewError("auth_failed", "The user could not be authenticated.") + // RoomJoinFailed is returned if the Talk response to a room join request is not an error and not a valid join response. + RoomJoinFailed = api.NewError("room_join_failed", "Could not join the room.") + // InvalidClientType is returned if the client type in the "hello" request is not supported. + InvalidClientType = api.NewError("invalid_client_type", "The client type is not supported.") + // InvalidBackendUrl is returned if no backend is configured for URL in the "hello" request. + InvalidBackendUrl = api.NewError("invalid_backend", "The backend URL is not supported.") + // InvalidToken is returned if the token in a "hello" request could not be validated. + InvalidToken = api.NewError("invalid_token", "The passed token is invalid.") + // NoSuchSession is returned if the session to be resumed is unknown or expired. + NoSuchSession = api.NewError("no_such_session", "The session to resume does not exist.") + // TokenNotValidYet is returned if the token in a "hello" request could be authenticated but is not valid yet. + // This hints to a mismatch in the time between the server running Talk and the server running the signaling server. + TokenNotValidYet = api.NewError("token_not_valid_yet", "The token is not valid yet.") + // TokenExpired is returned if the token in a "hello" request could be authenticated but is expired. + // This hints to a mismatch in the time between the server running Talk and the server running the signaling server, + // but could also be a client trying to connect with an old token. + TokenExpired = api.NewError("token_expired", "The token is expired.") + // TooManyRequests is returned if brute force detection reports too many failed "hello" requests. + TooManyRequests = api.NewError("too_many_requests", "Too many requests.") + + ErrNoProxyTokenSupported = errors.New("proxy token generation not supported") // Maximum number of concurrent requests to a backend. defaultMaxConcurrentRequestsPerHost = 8 @@ -80,13 +110,13 @@ var ( defaultFederationTimeoutSeconds = 10 // New connections have to send a "Hello" request after 2 seconds. - initialHelloTimeout = 2 * time.Second + initialHelloTimeout = 2 * time.Second // +checklocksignore: Global readonly variable. // Anonymous clients have to join a room after 10 seconds. - anonmyousJoinRoomTimeout = 10 * time.Second + anonmyousJoinRoomTimeout = 10 * time.Second // +checklocksignore: Global readonly variable. // Sessions expire 30 seconds after the connection closed. - sessionExpireDuration = 30 * time.Second + sessionExpireDuration = 30 * time.Second // +checklocksignore: Global readonly variable. // Run housekeeping jobs once per second housekeepingInterval = time.Second @@ -105,6 +135,8 @@ var ( websocketReadBufferSize = 4096 websocketWriteBufferSize = 4096 + websocketWriteBufferPool = &sync.Pool{} + // Delay after which a screen publisher should be cleaned up. cleanupScreenPublisherDelay = time.Second @@ -113,89 +145,113 @@ var ( // Allow time differences of up to one minute between server and proxy. tokenLeeway = time.Minute - - DefaultTrustedProxies = DefaultPrivateIps() ) func init() { RegisterHubStats() } +type ClientWithSession interface { + client.HandlerClient + + IsAuthenticated() bool + GetSession() Session + SetSession(session Session) +} + type Hub struct { version string - events AsyncEvents + logger log.Logger + events events.AsyncEvents upgrader websocket.Upgrader - cookie *SessionIdCodec - info *WelcomeServerMessage - infoInternal *WelcomeServerMessage - welcome atomic.Value // *ServerMessage + sessionIds *session.SessionIdCodec + info *api.WelcomeServerMessage + infoInternal *api.WelcomeServerMessage + welcome atomic.Value // *api.ServerMessage - closer *Closer + closer *internal.Closer readPumpActive atomic.Int32 writePumpActive atomic.Int32 - shutdown *Closer + shutdown *internal.Closer shutdownScheduled atomic.Bool - roomUpdated chan *BackendServerRoomRequest - roomDeleted chan *BackendServerRoomRequest - roomInCall chan *BackendServerRoomRequest - roomParticipants chan *BackendServerRoomRequest + roomUpdated chan *talk.BackendServerRoomRequest + roomDeleted chan *talk.BackendServerRoomRequest + roomInCall chan *talk.BackendServerRoomRequest + roomParticipants chan *talk.BackendServerRoomRequest mu sync.RWMutex ru sync.RWMutex - sid atomic.Uint64 - clients map[uint64]HandlerClient + sid atomic.Uint64 + // +checklocks:mu + clients map[uint64]ClientWithSession + // +checklocks:mu sessions map[uint64]Session - rooms map[string]*Room + // +checklocks:ru + rooms map[string]*Room - roomSessions RoomSessions - roomPing *RoomPing - virtualSessions map[string]uint64 + roomSessions RoomSessions + roomPing *RoomPing + // +checklocks:mu + virtualSessions map[api.PublicSessionId]uint64 - decodeCaches []*LruCache + decodeCaches []*container.LruCache[*session.SessionIdData] - mcu Mcu + mcu sfu.SFU mcuTimeout time.Duration internalClientsSecret []byte allowSubscribeAnyStream bool - expiredSessions map[Session]time.Time - anonymousSessions map[*ClientSession]time.Time - expectHelloClients map[HandlerClient]time.Time - dialoutSessions map[*ClientSession]bool - remoteSessions map[*RemoteSession]bool - federatedSessions map[*ClientSession]bool + // +checklocks:mu + expiredSessions map[Session]time.Time + // +checklocks:mu + anonymousSessions map[*ClientSession]time.Time + // +checklocks:mu + expectHelloClients map[ClientWithSession]time.Time + // +checklocks:mu + dialoutSessions map[*ClientSession]bool + // +checklocks:mu + remoteSessions map[*RemoteSession]bool + // +checklocks:mu + federatedSessions map[*ClientSession]bool + // +checklocks:mu + federationClients map[*FederationClient]bool backendTimeout time.Duration - backend *BackendClient + backend *talk.BackendClient - trustedProxies atomic.Pointer[AllowedIps] - geoip *GeoLookup - geoipOverrides atomic.Pointer[map[*net.IPNet]string] + trustedProxies atomic.Pointer[container.IPList] + geoip *geoip.Lookup + geoipOverrides geoip.AtomicOverrides geoipUpdating atomic.Bool - rpcServer *GrpcServer - rpcClients *GrpcClients + etcdClient etcd.Client + rpcServer *grpc.Server + rpcClients *grpc.Clients - throttler Throttler + throttler async.Throttler skipFederationVerify bool federationTimeout time.Duration + + allowedCandidates atomic.Pointer[container.IPList] + blockedCandidates atomic.Pointer[container.IPList] } -func NewHub(config *goconf.ConfigFile, events AsyncEvents, rpcServer *GrpcServer, rpcClients *GrpcClients, etcdClient *EtcdClient, r *mux.Router, version string) (*Hub, error) { - hashKey, _ := config.GetString("sessions", "hashkey") +func NewHub(ctx context.Context, cfg *goconf.ConfigFile, events events.AsyncEvents, rpcServer *grpc.Server, rpcClients *grpc.Clients, etcdClient etcd.Client, r *mux.Router, version string) (*Hub, error) { + logger := log.LoggerFromContext(ctx) + hashKey, _ := config.GetStringOptionWithEnv(cfg, "sessions", "hashkey") switch len(hashKey) { case 32: case 64: default: - log.Printf("WARNING: The sessions hash key should be 32 or 64 bytes but is %d bytes", len(hashKey)) + logger.Printf("WARNING: The sessions hash key should be 32 or 64 bytes but is %d bytes", len(hashKey)) } - blockKey, _ := config.GetString("sessions", "blockkey") + blockKey, _ := config.GetStringOptionWithEnv(cfg, "sessions", "blockkey") blockBytes := []byte(blockKey) switch len(blockKey) { case 0: @@ -207,66 +263,71 @@ func NewHub(config *goconf.ConfigFile, events AsyncEvents, rpcServer *GrpcServer return nil, fmt.Errorf("the sessions block key must be 16, 24 or 32 bytes but is %d bytes", len(blockKey)) } - internalClientsSecret, _ := config.GetString("clients", "internalsecret") - if internalClientsSecret == "" { - log.Println("WARNING: No shared secret has been set for internal clients.") + sessionIds, err := session.NewSessionIdCodec([]byte(hashKey), blockBytes) + if err != nil { + return nil, fmt.Errorf("error creating session id codec: %w", err) } - maxConcurrentRequestsPerHost, _ := config.GetInt("backend", "connectionsperhost") + internalClientsSecret, _ := config.GetStringOptionWithEnv(cfg, "clients", "internalsecret") + if internalClientsSecret == "" { + logger.Println("WARNING: No shared secret has been set for internal clients.") + } + + maxConcurrentRequestsPerHost, _ := cfg.GetInt("backend", "connectionsperhost") if maxConcurrentRequestsPerHost <= 0 { maxConcurrentRequestsPerHost = defaultMaxConcurrentRequestsPerHost } - backend, err := NewBackendClient(config, maxConcurrentRequestsPerHost, version, etcdClient) + backend, err := talk.NewBackendClient(ctx, cfg, maxConcurrentRequestsPerHost, version, etcdClient) if err != nil { return nil, err } - log.Printf("Using a maximum of %d concurrent backend connections per host", maxConcurrentRequestsPerHost) + logger.Printf("Using a maximum of %d concurrent backend connections per host", maxConcurrentRequestsPerHost) - backendTimeoutSeconds, _ := config.GetInt("backend", "timeout") + backendTimeoutSeconds, _ := cfg.GetInt("backend", "timeout") if backendTimeoutSeconds <= 0 { backendTimeoutSeconds = defaultBackendTimeoutSeconds } backendTimeout := time.Duration(backendTimeoutSeconds) * time.Second - log.Printf("Using a timeout of %s for backend connections", backendTimeout) + logger.Printf("Using a timeout of %s for backend connections", backendTimeout) - mcuTimeoutSeconds, _ := config.GetInt("mcu", "timeout") + mcuTimeoutSeconds, _ := cfg.GetInt("mcu", "timeout") if mcuTimeoutSeconds <= 0 { mcuTimeoutSeconds = defaultMcuTimeoutSeconds } mcuTimeout := time.Duration(mcuTimeoutSeconds) * time.Second - allowSubscribeAnyStream, _ := config.GetBool("app", "allowsubscribeany") + allowSubscribeAnyStream, _ := cfg.GetBool("app", "allowsubscribeany") if allowSubscribeAnyStream { - log.Printf("WARNING: Allow subscribing any streams, this is insecure and should only be enabled for testing") + logger.Printf("WARNING: Allow subscribing any streams, this is insecure and should only be enabled for testing") } - trustedProxies, _ := config.GetString("app", "trustedproxies") - trustedProxiesIps, err := ParseAllowedIps(trustedProxies) + trustedProxies, _ := cfg.GetString("app", "trustedproxies") + trustedProxiesIps, err := container.ParseIPList(trustedProxies) if err != nil { return nil, err } - skipFederationVerify, _ := config.GetBool("federation", "skipverify") + skipFederationVerify, _ := cfg.GetBool("federation", "skipverify") if skipFederationVerify { - log.Println("WARNING: Federation target verification is disabled!") + logger.Println("WARNING: Federation target verification is disabled!") } - federationTimeoutSeconds, _ := config.GetInt("federation", "timeout") + federationTimeoutSeconds, _ := cfg.GetInt("federation", "timeout") if federationTimeoutSeconds <= 0 { federationTimeoutSeconds = defaultFederationTimeoutSeconds } federationTimeout := time.Duration(federationTimeoutSeconds) * time.Second if !trustedProxiesIps.Empty() { - log.Printf("Trusted proxies: %s", trustedProxiesIps) + logger.Printf("Trusted proxies: %s", trustedProxiesIps) } else { - trustedProxiesIps = DefaultTrustedProxies - log.Printf("No trusted proxies configured, only allowing for %s", trustedProxiesIps) + trustedProxiesIps = client.DefaultTrustedProxies + logger.Printf("No trusted proxies configured, only allowing for %s", trustedProxiesIps) } - decodeCaches := make([]*LruCache, 0, numDecodeCaches) - for i := 0; i < numDecodeCaches; i++ { - decodeCaches = append(decodeCaches, NewLruCache(decodeCacheSize)) + decodeCaches := make([]*container.LruCache[*session.SessionIdData], 0, numDecodeCaches) + for range numDecodeCaches { + decodeCaches = append(decodeCaches, container.NewLruCache[*session.SessionIdData](decodeCacheSize)) } roomSessions, err := NewBuiltinRoomSessions(rpcClients) @@ -274,74 +335,78 @@ func NewHub(config *goconf.ConfigFile, events AsyncEvents, rpcServer *GrpcServer return nil, err } - roomPing, err := NewRoomPing(backend, backend.capabilities) + roomPing, err := NewRoomPing(backend) if err != nil { return nil, err } - geoipUrl, _ := config.GetString("geoip", "url") + geoipUrl, _ := cfg.GetString("geoip", "url") if geoipUrl == "default" || geoipUrl == "none" { geoipUrl = "" } if geoipUrl == "" { - if geoipLicense, _ := config.GetString("geoip", "license"); geoipLicense != "" { - geoipUrl = GetGeoIpDownloadUrl(geoipLicense) + if geoipLicense, _ := cfg.GetString("geoip", "license"); geoipLicense != "" { + geoipUrl = geoip.GetMaxMindDownloadUrl(geoipLicense) } } - var geoip *GeoLookup + var geoipLookup *geoip.Lookup if geoipUrl != "" { - if strings.HasPrefix(geoipUrl, "file://") { - geoipUrl = geoipUrl[7:] - log.Printf("Using GeoIP database from %s", geoipUrl) - geoip, err = NewGeoLookupFromFile(geoipUrl) + if geoipUrl, found := strings.CutPrefix(geoipUrl, "file://"); found { + logger.Printf("Using GeoIP database from %s", geoipUrl) + geoipLookup, err = geoip.NewLookupFromFile(logger, geoipUrl) } else { - log.Printf("Downloading GeoIP database from %s", geoipUrl) - geoip, err = NewGeoLookupFromUrl(geoipUrl) + logger.Printf("Downloading GeoIP database from %s", geoipUrl) + geoipLookup, err = geoip.NewLookupFromUrl(logger, geoipUrl) } if err != nil { return nil, err } } else { - log.Printf("Not using GeoIP database") + logger.Printf("Not using GeoIP database") } - geoipOverrides, err := LoadGeoIPOverrides(config, false) + geoipOverrides, err := geoip.LoadOverrides(ctx, cfg, false) if err != nil { return nil, err } - throttler, err := NewMemoryThrottler() + throttler, err := async.NewMemoryThrottler() if err != nil { return nil, err } hub := &Hub{ version: version, + logger: logger, events: events, upgrader: websocket.Upgrader{ ReadBufferSize: websocketReadBufferSize, WriteBufferSize: websocketWriteBufferSize, + WriteBufferPool: websocketWriteBufferPool, + Subprotocols: []string{ + janus.EventsSubprotocol, + }, }, - cookie: NewSessionIdCodec([]byte(hashKey), blockBytes), - info: NewWelcomeServerMessage(version, DefaultFeatures...), - infoInternal: NewWelcomeServerMessage(version, DefaultFeaturesInternal...), + sessionIds: sessionIds, + info: api.NewWelcomeServerMessage(version, api.DefaultFeatures...), + infoInternal: api.NewWelcomeServerMessage(version, api.DefaultFeaturesInternal...), - closer: NewCloser(), - shutdown: NewCloser(), + closer: internal.NewCloser(), + shutdown: internal.NewCloser(), - roomUpdated: make(chan *BackendServerRoomRequest), - roomDeleted: make(chan *BackendServerRoomRequest), - roomInCall: make(chan *BackendServerRoomRequest), - roomParticipants: make(chan *BackendServerRoomRequest), + roomUpdated: make(chan *talk.BackendServerRoomRequest), + roomDeleted: make(chan *talk.BackendServerRoomRequest), + roomInCall: make(chan *talk.BackendServerRoomRequest), + roomParticipants: make(chan *talk.BackendServerRoomRequest), - clients: make(map[uint64]HandlerClient), + clients: make(map[uint64]ClientWithSession), sessions: make(map[uint64]Session), rooms: make(map[string]*Room), roomSessions: roomSessions, roomPing: roomPing, - virtualSessions: make(map[string]uint64), + virtualSessions: make(map[api.PublicSessionId]uint64), decodeCaches: decodeCaches, @@ -352,16 +417,18 @@ func NewHub(config *goconf.ConfigFile, events AsyncEvents, rpcServer *GrpcServer expiredSessions: make(map[Session]time.Time), anonymousSessions: make(map[*ClientSession]time.Time), - expectHelloClients: make(map[HandlerClient]time.Time), + expectHelloClients: make(map[ClientWithSession]time.Time), dialoutSessions: make(map[*ClientSession]bool), remoteSessions: make(map[*RemoteSession]bool), federatedSessions: make(map[*ClientSession]bool), + federationClients: make(map[*FederationClient]bool), backendTimeout: backendTimeout, backend: backend, - geoip: geoip, + geoip: geoipLookup, + etcdClient: etcdClient, rpcServer: rpcServer, rpcClients: rpcClients, @@ -370,17 +437,42 @@ func NewHub(config *goconf.ConfigFile, events AsyncEvents, rpcServer *GrpcServer skipFederationVerify: skipFederationVerify, federationTimeout: federationTimeout, } - hub.trustedProxies.Store(trustedProxiesIps) - if len(geoipOverrides) > 0 { - hub.geoipOverrides.Store(&geoipOverrides) + if value, _ := cfg.GetString("mcu", "allowedcandidates"); value != "" { + allowed, err := container.ParseIPList(value) + if err != nil { + return nil, fmt.Errorf("invalid allowedcandidates: %w", err) + } + + logger.Printf("Candidates allowlist: %s", allowed) + hub.allowedCandidates.Store(allowed) + } else { + logger.Printf("No candidates allowlist") } - hub.setWelcomeMessage(&ServerMessage{ + if value, _ := cfg.GetString("mcu", "blockedcandidates"); value != "" { + blocked, err := container.ParseIPList(value) + if err != nil { + return nil, fmt.Errorf("invalid blockedcandidates: %w", err) + } + + logger.Printf("Candidates blocklist: %s", blocked) + hub.blockedCandidates.Store(blocked) + } else { + logger.Printf("No candidates blocklist") + } + + hub.trustedProxies.Store(trustedProxiesIps) + hub.geoipOverrides.Store(geoipOverrides) + + hub.setWelcomeMessage(&api.ServerMessage{ Type: "welcome", - Welcome: NewWelcomeServerMessage(version, DefaultWelcomeFeatures...), + Welcome: api.NewWelcomeServerMessage(version, api.DefaultWelcomeFeatures...), }) - backend.hub = hub + backend.SetFeaturesFunc(func() []string { + return hub.info.Features + }) + roomPing.hub = hub if rpcServer != nil { - rpcServer.hub = hub + rpcServer.SetHub(hub) } hub.upgrader.CheckOrigin = hub.checkOrigin r.HandleFunc("/spreed", func(w http.ResponseWriter, r *http.Request) { @@ -390,29 +482,29 @@ func NewHub(config *goconf.ConfigFile, events AsyncEvents, rpcServer *GrpcServer return hub, nil } -func (h *Hub) setWelcomeMessage(msg *ServerMessage) { +func (h *Hub) setWelcomeMessage(msg *api.ServerMessage) { h.welcome.Store(msg) } -func (h *Hub) getWelcomeMessage() *ServerMessage { - return h.welcome.Load().(*ServerMessage) +func (h *Hub) getWelcomeMessage() *api.ServerMessage { + return h.welcome.Load().(*api.ServerMessage) } -func (h *Hub) SetMcu(mcu Mcu) { +func (h *Hub) SetMcu(mcu sfu.SFU) { h.mcu = mcu // Create copy of message so it can be updated concurrently. welcome := *h.getWelcomeMessage() if mcu == nil { - h.info.RemoveFeature(ServerFeatureMcu, ServerFeatureSimulcast, ServerFeatureUpdateSdp) - h.infoInternal.RemoveFeature(ServerFeatureMcu, ServerFeatureSimulcast, ServerFeatureUpdateSdp) + h.info.RemoveFeature(api.ServerFeatureMcu, api.ServerFeatureSimulcast, api.ServerFeatureUpdateSdp) + h.infoInternal.RemoveFeature(api.ServerFeatureMcu, api.ServerFeatureSimulcast, api.ServerFeatureUpdateSdp) - welcome.Welcome.RemoveFeature(ServerFeatureMcu, ServerFeatureSimulcast, ServerFeatureUpdateSdp) + welcome.Welcome.RemoveFeature(api.ServerFeatureMcu, api.ServerFeatureSimulcast, api.ServerFeatureUpdateSdp) } else { - log.Printf("Using a timeout of %s for MCU requests", h.mcuTimeout) - h.info.AddFeature(ServerFeatureMcu, ServerFeatureSimulcast, ServerFeatureUpdateSdp) - h.infoInternal.AddFeature(ServerFeatureMcu, ServerFeatureSimulcast, ServerFeatureUpdateSdp) + h.logger.Printf("Using a timeout of %s for MCU requests", h.mcuTimeout) + h.info.AddFeature(api.ServerFeatureMcu, api.ServerFeatureSimulcast, api.ServerFeatureUpdateSdp) + h.infoInternal.AddFeature(api.ServerFeatureMcu, api.ServerFeatureSimulcast, api.ServerFeatureUpdateSdp) - welcome.Welcome.AddFeature(ServerFeatureMcu, ServerFeatureSimulcast, ServerFeatureUpdateSdp) + welcome.Welcome.AddFeature(api.ServerFeatureMcu, api.ServerFeatureSimulcast, api.ServerFeatureUpdateSdp) } h.setWelcomeMessage(&welcome) } @@ -422,8 +514,8 @@ func (h *Hub) checkOrigin(r *http.Request) bool { return true } -func (h *Hub) GetServerInfo(session Session) *WelcomeServerMessage { - if session.ClientType() == HelloClientTypeInternal { +func (h *Hub) GetServerInfo(session Session) *api.WelcomeServerMessage { + if session.ClientType() == api.HelloClientTypeInternal { return h.infoInternal } @@ -441,9 +533,9 @@ func (h *Hub) updateGeoDatabase() { } defer h.geoipUpdating.Store(false) - backoff, err := NewExponentialBackoff(time.Second, 5*time.Minute) + backoff, err := async.NewExponentialBackoff(time.Second, 5*time.Minute) if err != nil { - log.Printf("Could not create exponential backoff: %s", err) + h.logger.Printf("Could not create exponential backoff: %s", err) return } @@ -453,7 +545,7 @@ func (h *Hub) updateGeoDatabase() { break } - log.Printf("Could not update GeoIP database, will retry in %s (%s)", backoff.NextWait(), err) + h.logger.Printf("Could not update GeoIP database, will retry in %s (%s)", backoff.NextWait(), err) backoff.Wait(context.Background()) } } @@ -501,25 +593,44 @@ func (h *Hub) Stop() { h.throttler.Close() } -func (h *Hub) Reload(config *goconf.ConfigFile) { +func (h *Hub) Reload(ctx context.Context, config *goconf.ConfigFile) { trustedProxies, _ := config.GetString("app", "trustedproxies") - if trustedProxiesIps, err := ParseAllowedIps(trustedProxies); err == nil { + if trustedProxiesIps, err := container.ParseIPList(trustedProxies); err == nil { if !trustedProxiesIps.Empty() { - log.Printf("Trusted proxies: %s", trustedProxiesIps) + h.logger.Printf("Trusted proxies: %s", trustedProxiesIps) } else { - trustedProxiesIps = DefaultTrustedProxies - log.Printf("No trusted proxies configured, only allowing for %s", trustedProxiesIps) + trustedProxiesIps = client.DefaultTrustedProxies + h.logger.Printf("No trusted proxies configured, only allowing for %s", trustedProxiesIps) } h.trustedProxies.Store(trustedProxiesIps) } else { - log.Printf("Error parsing trusted proxies from \"%s\": %s", trustedProxies, err) + h.logger.Printf("Error parsing trusted proxies from \"%s\": %s", trustedProxies, err) } - geoipOverrides, _ := LoadGeoIPOverrides(config, true) - if len(geoipOverrides) > 0 { - h.geoipOverrides.Store(&geoipOverrides) + geoipOverrides, _ := geoip.LoadOverrides(ctx, config, true) + h.geoipOverrides.Store(geoipOverrides) + + if value, _ := config.GetString("mcu", "allowedcandidates"); value != "" { + if allowed, err := container.ParseIPList(value); err != nil { + h.logger.Printf("invalid allowedcandidates: %s", err) + } else { + h.logger.Printf("Candidates allowlist: %s", allowed) + h.allowedCandidates.Store(allowed) + } } else { - h.geoipOverrides.Store(nil) + h.logger.Printf("No candidates allowlist") + h.allowedCandidates.Store(nil) + } + if value, _ := config.GetString("mcu", "blockedcandidates"); value != "" { + if blocked, err := container.ParseIPList(value); err != nil { + h.logger.Printf("invalid blockedcandidates: %s", err) + } else { + h.logger.Printf("Candidates blocklist: %s", blocked) + h.blockedCandidates.Store(blocked) + } + } else { + h.logger.Printf("No candidates blocklist") + h.blockedCandidates.Store(nil) } if h.mcu != nil { @@ -529,45 +640,60 @@ func (h *Hub) Reload(config *goconf.ConfigFile) { h.rpcClients.Reload(config) } -func (h *Hub) getDecodeCache(cache_key string) *LruCache { +func (h *Hub) getDecodeCache(cache_key string) *container.LruCache[*session.SessionIdData] { hash := fnv.New32a() - hash.Write([]byte(cache_key)) // nolint + // Make sure we don't have a temporary allocation for the string -> []byte conversion. + hash.Write(unsafe.Slice(unsafe.StringData(cache_key), len(cache_key))) // nolint idx := hash.Sum32() % uint32(len(h.decodeCaches)) return h.decodeCaches[idx] } -func (h *Hub) invalidateSessionId(id string, sessionType string) { +func (h *Hub) invalidatePublicSessionId(id api.PublicSessionId) { + h.invalidateSessionId(string(id)) +} + +func (h *Hub) invalidatePrivateSessionId(id api.PrivateSessionId) { + h.invalidateSessionId(string(id)) +} + +func (h *Hub) invalidateSessionId(id string) { if len(id) == 0 { return } - cache_key := id + "|" + sessionType - cache := h.getDecodeCache(cache_key) - cache.Remove(cache_key) + cache := h.getDecodeCache(id) + cache.Remove(id) } -func (h *Hub) setDecodedSessionId(id string, sessionType string, data *SessionIdData) { +func (h *Hub) setDecodedPublicSessionId(id api.PublicSessionId, data *session.SessionIdData) { + h.setDecodedSessionId(string(id), data) +} + +func (h *Hub) setDecodedPrivateSessionId(id api.PrivateSessionId, data *session.SessionIdData) { + h.setDecodedSessionId(string(id), data) +} + +func (h *Hub) setDecodedSessionId(id string, data *session.SessionIdData) { if len(id) == 0 { return } - cache_key := id + "|" + sessionType - cache := h.getDecodeCache(cache_key) - cache.Set(cache_key, data) + cache := h.getDecodeCache(id) + cache.Set(id, data) } -func (h *Hub) decodePrivateSessionId(id string) *SessionIdData { +func (h *Hub) decodePrivateSessionId(id api.PrivateSessionId) *session.SessionIdData { if len(id) == 0 { return nil } - cache_key := id + "|" + privateSessionName + cache_key := string(id) cache := h.getDecodeCache(cache_key) if result := cache.Get(cache_key); result != nil { - return result.(*SessionIdData) + return result } - data, err := h.cookie.DecodePrivate(id) + data, err := h.sessionIds.DecodePrivate(id) if err != nil { return nil } @@ -576,18 +702,18 @@ func (h *Hub) decodePrivateSessionId(id string) *SessionIdData { return data } -func (h *Hub) decodePublicSessionId(id string) *SessionIdData { +func (h *Hub) decodePublicSessionId(id api.PublicSessionId) *session.SessionIdData { if len(id) == 0 { return nil } - cache_key := id + "|" + publicSessionName + cache_key := string(id) cache := h.getDecodeCache(cache_key) if result := cache.Get(cache_key); result != nil { - return result.(*SessionIdData) + return result } - data, err := h.cookie.DecodePublic(id) + data, err := h.sessionIds.DecodePublic(id) if err != nil { return nil } @@ -596,7 +722,7 @@ func (h *Hub) decodePublicSessionId(id string) *SessionIdData { return data } -func (h *Hub) GetSessionByPublicId(sessionId string) Session { +func (h *Hub) GetSessionByPublicId(sessionId api.PublicSessionId) Session { data := h.decodePublicSessionId(sessionId) if data == nil { return nil @@ -612,7 +738,7 @@ func (h *Hub) GetSessionByPublicId(sessionId string) Session { return session } -func (h *Hub) GetSessionByResumeId(resumeId string) Session { +func (h *Hub) GetSessionByResumeId(resumeId api.PrivateSessionId) Session { data := h.decodePrivateSessionId(resumeId) if data == nil { return nil @@ -628,40 +754,110 @@ func (h *Hub) GetSessionByResumeId(resumeId string) Session { return session } -func (h *Hub) GetSessionIdByRoomSessionId(roomSessionId string) (string, error) { +func (h *Hub) GetSessionIdByResumeId(resumeId api.PrivateSessionId) api.PublicSessionId { + session := h.GetSessionByResumeId(resumeId) + if session == nil { + return "" + } + + return session.PublicId() +} + +func (h *Hub) GetSessionIdByRoomSessionId(roomSessionId api.RoomSessionId) (api.PublicSessionId, error) { return h.roomSessions.GetSessionId(roomSessionId) } -func (h *Hub) GetDialoutSession(roomId string, backend *Backend) *ClientSession { - url := backend.Url() +func (h *Hub) IsSessionIdInCall(sessionId api.PublicSessionId, roomId string, backendUrl string) (bool, bool) { + session := h.GetSessionByPublicId(sessionId) + if session == nil { + return false, false + } + inCall := true + room := session.GetRoom() + if room == nil || room.Id() != roomId || !room.Backend().HasUrl(backendUrl) || + (session.ClientType() != api.HelloClientTypeInternal && !room.IsSessionInCall(session)) { + // Recipient is not in a room, a different room or not in the call. + inCall = false + } + + return inCall, true +} + +func (h *Hub) GetPublisherIdForSessionId(ctx context.Context, sessionId api.PublicSessionId, streamType sfu.StreamType) (*grpc.GetPublisherIdReply, error) { + session := h.GetSessionByPublicId(sessionId) + if session == nil { + return nil, status.Error(codes.NotFound, "no such session") + } + + clientSession, ok := session.(*ClientSession) + if !ok { + return nil, status.Error(codes.NotFound, "no such session") + } + + publisher := clientSession.GetOrWaitForPublisher(ctx, streamType) + if publisher, ok := publisher.(sfu.PublisherWithConnectionUrlAndIP); ok { + connUrl, ip := publisher.GetConnectionURL() + reply := &grpc.GetPublisherIdReply{ + PublisherId: publisher.Id(), + ProxyUrl: connUrl, + } + if len(ip) > 0 { + reply.Ip = ip.String() + } + var err error + if reply.ConnectToken, err = h.CreateProxyToken(""); err != nil && !errors.Is(err, ErrNoProxyTokenSupported) { + h.logger.Printf("Error creating proxy token for connection: %s", err) + return nil, status.Error(codes.Internal, "error creating proxy connect token") + } + if reply.PublisherToken, err = h.CreateProxyToken(publisher.Id()); err != nil && !errors.Is(err, ErrNoProxyTokenSupported) { + h.logger.Printf("Error creating proxy token for publisher %s: %s", publisher.Id(), err) + return nil, status.Error(codes.Internal, "error creating proxy publisher token") + } + return reply, nil + } + + return nil, status.Error(codes.NotFound, "no such publisher") +} + +func (h *Hub) GetDialoutSessions(roomId string, backend *talk.Backend) (result []*ClientSession) { h.mu.RLock() defer h.mu.RUnlock() for session := range h.dialoutSessions { - if session.backend.Url() != url { + if !backend.HasUrl(session.BackendUrl()) { continue } if session.GetClient() != nil { - return session + result = append(result, session) } } - return nil + return } -func (h *Hub) GetBackend(u *url.URL) *Backend { +func (h *Hub) GetBackend(u *url.URL) *talk.Backend { if u == nil { return h.backend.GetCompatBackend() } return h.backend.GetBackend(u) } +func (h *Hub) CreateProxyToken(publisherId string) (string, error) { + withToken, ok := h.mcu.(sfu.WithToken) + if !ok { + return "", ErrNoProxyTokenSupported + } + + return withToken.CreateToken(publisherId) +} + +// +checklocks:h.mu func (h *Hub) checkExpiredSessions(now time.Time) { for session, expires := range h.expiredSessions { if now.After(expires) { h.mu.Unlock() - log.Printf("Closing expired session %s (private=%s)", session.PublicId(), session.PrivateId()) + h.logger.Printf("Closing expired session %s (private=%s)", session.PublicId(), session.PrivateId()) session.Close() h.mu.Lock() // Should already be deleted by the close code, but better be sure. @@ -670,6 +866,7 @@ func (h *Hub) checkExpiredSessions(now time.Time) { } } +// +checklocks:h.mu func (h *Hub) checkAnonymousSessions(now time.Time) { for session, timeout := range h.anonymousSessions { if now.After(timeout) { @@ -684,6 +881,7 @@ func (h *Hub) checkAnonymousSessions(now time.Time) { } } +// +checklocks:h.mu func (h *Hub) checkInitialHello(now time.Time) { for client, timeout := range h.expectHelloClients { if now.After(timeout) { @@ -697,28 +895,30 @@ func (h *Hub) checkInitialHello(now time.Time) { func (h *Hub) performHousekeeping(now time.Time) { h.mu.Lock() + defer h.mu.Unlock() + h.checkExpiredSessions(now) h.checkAnonymousSessions(now) h.checkInitialHello(now) - h.mu.Unlock() } func (h *Hub) removeSession(session Session) (removed bool) { session.LeaveRoom(true) - h.invalidateSessionId(session.PrivateId(), privateSessionName) - h.invalidateSessionId(session.PublicId(), publicSessionName) + h.invalidatePrivateSessionId(session.PrivateId()) + h.invalidatePublicSessionId(session.PublicId()) h.mu.Lock() if data := session.Data(); data != nil && data.Sid > 0 { delete(h.clients, data.Sid) if _, found := h.sessions[data.Sid]; found { delete(h.sessions, data.Sid) - statsHubSessionsCurrent.WithLabelValues(session.Backend().Id(), session.ClientType()).Dec() + statsHubSessionsCurrent.WithLabelValues(session.Backend().Id(), string(session.ClientType())).Dec() removed = true } } delete(h.expiredSessions, session) if session, ok := session.(*ClientSession); ok { + delete(h.federatedSessions, session) delete(h.anonymousSessions, session) delete(h.dialoutSessions, session) } @@ -729,13 +929,21 @@ func (h *Hub) removeSession(session Session) (removed bool) { return } +func (h *Hub) removeFederationClient(client *FederationClient) { + h.mu.Lock() + defer h.mu.Unlock() + + delete(h.federationClients, client) +} + +// +checklocksread:h.mu func (h *Hub) hasSessionsLocked(withInternal bool) bool { if withInternal { return len(h.sessions) > 0 } for _, s := range h.sessions { - if s.ClientType() != HelloClientTypeInternal { + if s.ClientType() != api.HelloClientTypeInternal { return true } } @@ -750,8 +958,9 @@ func (h *Hub) startWaitAnonymousSessionRoom(session *ClientSession) { h.startWaitAnonymousSessionRoomLocked(session) } +// +checklocks:h.mu func (h *Hub) startWaitAnonymousSessionRoomLocked(session *ClientSession) { - if session.ClientType() == HelloClientTypeInternal { + if session.ClientType() == api.HelloClientTypeInternal { // Internal clients don't need to join a room. return } @@ -762,7 +971,7 @@ func (h *Hub) startWaitAnonymousSessionRoomLocked(session *ClientSession) { h.anonymousSessions[session] = now.Add(anonmyousJoinRoomTimeout) } -func (h *Hub) startExpectHello(client HandlerClient) { +func (h *Hub) startExpectHello(client ClientWithSession) { h.mu.Lock() defer h.mu.Unlock() if !client.IsConnected() { @@ -778,16 +987,16 @@ func (h *Hub) startExpectHello(client HandlerClient) { h.expectHelloClients[client] = now.Add(initialHelloTimeout) } -func (h *Hub) processNewClient(client HandlerClient) { +func (h *Hub) processNewClient(client ClientWithSession) { h.startExpectHello(client) h.sendWelcome(client) } -func (h *Hub) sendWelcome(client HandlerClient) { +func (h *Hub) sendWelcome(client ClientWithSession) { client.SendMessage(h.getWelcomeMessage()) } -func (h *Hub) registerClient(client HandlerClient) uint64 { +func (h *Hub) registerClient(client ClientWithSession) uint64 { sid := h.sid.Add(1) for sid == 0 { sid = h.sid.Add(1) @@ -811,47 +1020,40 @@ func (h *Hub) unregisterRemoteSession(session *RemoteSession) { delete(h.remoteSessions, session) } -func (h *Hub) newSessionIdData(backend *Backend) *SessionIdData { +func (h *Hub) newSessionIdData(backend *talk.Backend) *session.SessionIdData { sid := h.sid.Add(1) for sid == 0 { sid = h.sid.Add(1) } - sessionIdData := &SessionIdData{ + sessionIdData := &session.SessionIdData{ Sid: sid, - Created: timestamppb.Now(), + Created: time.Now().UnixMicro(), BackendId: backend.Id(), } return sessionIdData } -func (h *Hub) processRegister(c HandlerClient, message *ClientMessage, backend *Backend, auth *BackendClientResponse) { - if !c.IsConnected() { +func (h *Hub) processRegister(client ClientWithSession, message *api.ClientMessage, backend *talk.Backend, auth *talk.BackendClientResponse) { + if !client.IsConnected() { // Client disconnected while waiting for "hello" response. return } if auth.Type == "error" { - c.SendMessage(message.NewErrorServerMessage(auth.Error)) + client.SendMessage(message.NewErrorServerMessage(auth.Error)) return } else if auth.Type != "auth" { - c.SendMessage(message.NewErrorServerMessage(UserAuthFailed)) - return - } - - client, ok := c.(*Client) - if !ok { - log.Printf("Can't register non-client %T", c) - client.SendMessage(message.NewWrappedErrorServerMessage(errors.New("can't register non-client"))) + client.SendMessage(message.NewErrorServerMessage(UserAuthFailed)) return } sessionIdData := h.newSessionIdData(backend) - privateSessionId, err := h.cookie.EncodePrivate(sessionIdData) + privateSessionId, err := h.sessionIds.EncodePrivate(sessionIdData) if err != nil { client.SendMessage(message.NewWrappedErrorServerMessage(err)) return } - publicSessionId, err := h.cookie.EncodePublic(sessionIdData) + publicSessionId, err := h.sessionIds.EncodePublic(sessionIdData) if err != nil { client.SendMessage(message.NewWrappedErrorServerMessage(err)) return @@ -859,11 +1061,11 @@ func (h *Hub) processRegister(c HandlerClient, message *ClientMessage, backend * userId := auth.Auth.UserId if userId != "" { - log.Printf("Register user %s@%s from %s in %s (%s) %s (private=%s)", userId, backend.Id(), client.RemoteAddr(), client.Country(), client.UserAgent(), publicSessionId, privateSessionId) - } else if message.Hello.Auth.Type != HelloClientTypeClient { - log.Printf("Register %s@%s from %s in %s (%s) %s (private=%s)", message.Hello.Auth.Type, backend.Id(), client.RemoteAddr(), client.Country(), client.UserAgent(), publicSessionId, privateSessionId) + h.logger.Printf("Register user %s@%s from %s in %s (%s) %s (private=%s)", userId, backend.Id(), client.RemoteAddr(), client.Country(), client.UserAgent(), publicSessionId, privateSessionId) + } else if message.Hello.Auth.Type != api.HelloClientTypeClient { + h.logger.Printf("Register %s@%s from %s in %s (%s) %s (private=%s)", message.Hello.Auth.Type, backend.Id(), client.RemoteAddr(), client.Country(), client.UserAgent(), publicSessionId, privateSessionId) } else { - log.Printf("Register anonymous@%s from %s in %s (%s) %s (private=%s)", backend.Id(), client.RemoteAddr(), client.Country(), client.UserAgent(), publicSessionId, privateSessionId) + h.logger.Printf("Register anonymous@%s from %s in %s (%s) %s (private=%s)", backend.Id(), client.RemoteAddr(), client.Country(), client.UserAgent(), publicSessionId, privateSessionId) } session, err := NewClientSession(h, privateSessionId, publicSessionId, sessionIdData, backend, message.Hello, auth.Auth) @@ -873,7 +1075,7 @@ func (h *Hub) processRegister(c HandlerClient, message *ClientMessage, backend * } if err := backend.AddSession(session); err != nil { - log.Printf("Error adding session %s to backend %s: %s", session.PublicId(), backend.Id(), err) + h.logger.Printf("Error adding session %s to backend %s: %s", session.PublicId(), backend.Id(), err) session.Close() client.SendMessage(message.NewWrappedErrorServerMessage(err)) return @@ -885,29 +1087,26 @@ func (h *Hub) processRegister(c HandlerClient, message *ClientMessage, backend * var wg sync.WaitGroup ctx, cancel := context.WithTimeout(client.Context(), time.Second) defer cancel() - for _, client := range h.rpcClients.GetClients() { - wg.Add(1) - go func(c *GrpcClient) { - defer wg.Done() - - count, err := c.GetSessionCount(ctx, backend.ParsedUrl()) + for _, grpcClient := range h.rpcClients.GetClients() { + wg.Go(func() { + count, err := grpcClient.GetSessionCount(ctx, session.BackendUrl()) if err != nil { - log.Printf("Received error while getting session count for %s from %s: %s", backend.Url(), c.Target(), err) + h.logger.Printf("Received error while getting session count for %s from %s: %s", session.BackendUrl(), grpcClient.Target(), err) return } if count > 0 { - log.Printf("%d sessions connected for %s on %s", count, backend.Url(), c.Target()) + h.logger.Printf("%d sessions connected for %s on %s", count, session.BackendUrl(), grpcClient.Target()) totalCount.Add(count) } - }(client) + }) } wg.Wait() if totalCount.Load() > limit { backend.RemoveSession(session) - log.Printf("Error adding session %s to backend %s: %s", session.PublicId(), backend.Id(), SessionLimitExceeded) + h.logger.Printf("Error adding session %s to backend %s: %s", session.PublicId(), backend.Id(), talk.SessionLimitExceeded) session.Close() - client.SendMessage(message.NewWrappedErrorServerMessage(SessionLimitExceeded)) + client.SendMessage(message.NewWrappedErrorServerMessage(talk.SessionLimitExceeded)) return } } @@ -925,27 +1124,27 @@ func (h *Hub) processRegister(c HandlerClient, message *ClientMessage, backend * h.sessions[sessionIdData.Sid] = session h.clients[sessionIdData.Sid] = client delete(h.expectHelloClients, client) - if userId == "" && session.ClientType() != HelloClientTypeInternal { + if userId == "" && session.ClientType() != api.HelloClientTypeInternal { h.startWaitAnonymousSessionRoomLocked(session) - } else if session.ClientType() == HelloClientTypeInternal && session.HasFeature(ClientFeatureStartDialout) { + } else if session.ClientType() == api.HelloClientTypeInternal && session.HasFeature(api.ClientFeatureStartDialout) { // TODO: There is a small race condition for sessions that take some time // between connecting and joining a room. h.dialoutSessions[session] = true } h.mu.Unlock() - if country := client.Country(); IsValidCountry(country) { - statsClientCountries.WithLabelValues(country).Inc() + if country := client.Country(); geoip.IsValidCountry(country) { + statsClientCountries.WithLabelValues(string(country)).Inc() } - statsHubSessionsCurrent.WithLabelValues(backend.Id(), session.ClientType()).Inc() - statsHubSessionsTotal.WithLabelValues(backend.Id(), session.ClientType()).Inc() + statsHubSessionsCurrent.WithLabelValues(backend.Id(), string(session.ClientType())).Inc() + statsHubSessionsTotal.WithLabelValues(backend.Id(), string(session.ClientType())).Inc() - h.setDecodedSessionId(privateSessionId, privateSessionName, sessionIdData) - h.setDecodedSessionId(publicSessionId, publicSessionName, sessionIdData) + h.setDecodedPrivateSessionId(privateSessionId, sessionIdData) + h.setDecodedPublicSessionId(publicSessionId, sessionIdData) h.sendHelloResponse(session, message) } -func (h *Hub) processUnregister(client HandlerClient) Session { +func (h *Hub) processUnregister(client ClientWithSession) Session { session := client.GetSession() h.mu.Lock() @@ -957,11 +1156,9 @@ func (h *Hub) processUnregister(client HandlerClient) Session { } h.mu.Unlock() if session != nil { - log.Printf("Unregister %s (private=%s)", session.PublicId(), session.PrivateId()) - if c, ok := client.(*Client); ok { - if cs, ok := session.(*ClientSession); ok { - cs.ClearClient(c) - } + h.logger.Printf("Unregister %s (private=%s)", session.PublicId(), session.PrivateId()) + if cs, ok := session.(*ClientSession); ok { + cs.ClearClient(client) } } @@ -969,14 +1166,14 @@ func (h *Hub) processUnregister(client HandlerClient) Session { return session } -func (h *Hub) processMessage(client HandlerClient, data []byte) { - var message ClientMessage +func (h *Hub) processMessage(client ClientWithSession, data []byte) { + var message api.ClientMessage if err := message.UnmarshalJSON(data); err != nil { if session := client.GetSession(); session != nil { - log.Printf("Error decoding message from client %s: %v", session.PublicId(), err) + h.logger.Printf("Error decoding message from client %s: %v", session.PublicId(), err) session.SendError(InvalidFormat) } else { - log.Printf("Error decoding message from %s: %v", client.RemoteAddr(), err) + h.logger.Printf("Error decoding message from %s: %v", client.RemoteAddr(), err) client.SendError(InvalidFormat) } return @@ -984,15 +1181,15 @@ func (h *Hub) processMessage(client HandlerClient, data []byte) { if err := message.CheckValid(); err != nil { if session := client.GetSession(); session != nil { - log.Printf("Invalid message %+v from client %s: %v", message, session.PublicId(), err) - if err, ok := err.(*Error); ok { + h.logger.Printf("Invalid message %+v from client %s: %v", message, session.PublicId(), err) + if err, ok := err.(*api.Error); ok { session.SendMessage(message.NewErrorServerMessage(err)) } else { session.SendMessage(message.NewErrorServerMessage(InvalidFormat)) } } else { - log.Printf("Invalid message %+v from %s: %v", message, client.RemoteAddr(), err) - if err, ok := err.(*Error); ok { + h.logger.Printf("Invalid message %+v from %s: %v", message, client.RemoteAddr(), err) + if err, ok := err.(*api.Error); ok { client.SendMessage(message.NewErrorServerMessage(err)) } else { client.SendMessage(message.NewErrorServerMessage(InvalidFormat)) @@ -1040,17 +1237,17 @@ func (h *Hub) processMessage(client HandlerClient, data []byte) { case "bye": h.processByeMsg(client, &message) case "hello": - log.Printf("Ignore hello %+v for already authenticated connection %s", message.Hello, session.PublicId()) + h.logger.Printf("Ignore hello %+v for already authenticated connection %s", message.Hello, session.PublicId()) default: - log.Printf("Ignore unknown message %+v from %s", message, session.PublicId()) + h.logger.Printf("Ignore unknown message %+v from %s", message, session.PublicId()) } } -func (h *Hub) sendHelloResponse(session *ClientSession, message *ClientMessage) bool { - response := &ServerMessage{ +func (h *Hub) sendHelloResponse(session *ClientSession, message *api.ClientMessage) bool { + response := &api.ServerMessage{ Id: message.Id, Type: "hello", - Hello: &HelloServerMessage{ + Hello: &api.HelloServerMessage{ Version: message.Hello.Version, SessionId: session.PublicId(), ResumeId: session.PrivateId(), @@ -1062,17 +1259,17 @@ func (h *Hub) sendHelloResponse(session *ClientSession, message *ClientMessage) } type remoteClientInfo struct { - client *GrpcClient - response *LookupResumeIdReply + client *grpc.Client + response *grpc.LookupResumeIdReply } -func (h *Hub) tryProxyResume(c HandlerClient, resumeId string, message *ClientMessage) bool { - client, ok := c.(*Client) +func (h *Hub) tryProxyResume(c ClientWithSession, resumeId api.PrivateSessionId, message *api.ClientMessage) bool { + client, ok := c.(*HubClient) if !ok { return false } - var clients []*GrpcClient + var clients []*grpc.Client if h.rpcClients != nil { clients = h.rpcClients.GetClients() } @@ -1080,7 +1277,7 @@ func (h *Hub) tryProxyResume(c HandlerClient, resumeId string, message *ClientMe return false } - rpcCtx, rpcCancel := context.WithTimeout(c.Context(), 5*time.Second) + rpcCtx, rpcCancel := context.WithTimeout(client.Context(), 5*time.Second) defer rpcCancel() var wg sync.WaitGroup @@ -1088,27 +1285,24 @@ func (h *Hub) tryProxyResume(c HandlerClient, resumeId string, message *ClientMe defer cancel() var remoteClient atomic.Pointer[remoteClientInfo] - for _, c := range clients { - wg.Add(1) - go func(client *GrpcClient) { - defer wg.Done() - - if client.IsSelf() { + for _, grpcClient := range clients { + wg.Go(func() { + if grpcClient.IsSelf() { return } - response, err := client.LookupResumeId(ctx, resumeId) + response, err := grpcClient.LookupResumeId(ctx, resumeId) if err != nil { - log.Printf("Could not lookup resume id %s on %s: %s", resumeId, client.Target(), err) + h.logger.Printf("Could not lookup resume id %s on %s: %s", resumeId, grpcClient.Target(), err) return } cancel() remoteClient.CompareAndSwap(nil, &remoteClientInfo{ - client: client, + client: grpcClient, response: response, }) - }(c) + }) } wg.Wait() @@ -1122,19 +1316,19 @@ func (h *Hub) tryProxyResume(c HandlerClient, resumeId string, message *ClientMe return false } - rs, err := NewRemoteSession(h, client, info.client, info.response.SessionId) + rs, err := NewRemoteSession(h, client, info.client, api.PublicSessionId(info.response.SessionId)) if err != nil { - log.Printf("Could not create remote session %s on %s: %s", info.response.SessionId, info.client.Target(), err) + h.logger.Printf("Could not create remote session %s on %s: %s", info.response.SessionId, info.client.Target(), err) return false } if err := rs.Start(message); err != nil { rs.Close() - log.Printf("Could not start remote session %s on %s: %s", info.response.SessionId, info.client.Target(), err) + h.logger.Printf("Could not start remote session %s on %s: %s", info.response.SessionId, info.client.Target(), err) return false } - log.Printf("Proxy session %s to %s", info.response.SessionId, info.client.Target()) + h.logger.Printf("Proxy session %s to %s", info.response.SessionId, info.client.Target()) h.mu.Lock() defer h.mu.Unlock() h.remoteSessions[rs] = true @@ -1142,16 +1336,16 @@ func (h *Hub) tryProxyResume(c HandlerClient, resumeId string, message *ClientMe return true } -func (h *Hub) processHello(client HandlerClient, message *ClientMessage) { - ctx := context.TODO() +func (h *Hub) processHello(client ClientWithSession, message *api.ClientMessage) { + ctx := log.NewLoggerContext(client.Context(), h.logger) resumeId := message.Hello.ResumeId if resumeId != "" { throttle, err := h.throttler.CheckBruteforce(ctx, client.RemoteAddr(), "HelloResume") - if err == ErrBruteforceDetected { + if err == async.ErrBruteforceDetected { client.SendMessage(message.NewErrorServerMessage(TooManyRequests)) return } else if err != nil { - log.Printf("Error checking for bruteforce: %s", err) + h.logger.Printf("Error checking for bruteforce: %s", err) client.SendMessage(message.NewWrappedErrorServerMessage(err)) return } @@ -1186,7 +1380,7 @@ func (h *Hub) processHello(client HandlerClient, message *ClientMessage) { if !ok { // Should never happen as clients only can resume their own sessions. h.mu.Unlock() - log.Printf("Client resumed non-client session %s (private=%s)", session.PublicId(), session.PrivateId()) + h.logger.Printf("Client resumed non-client session %s (private=%s)", session.PublicId(), session.PrivateId()) statsHubSessionResumeFailed.Inc() client.SendMessage(message.NewErrorServerMessage(NoSuchSession)) return @@ -1199,7 +1393,7 @@ func (h *Hub) processHello(client HandlerClient, message *ClientMessage) { } if prev := clientSession.SetClient(client); prev != nil { - log.Printf("Closing previous client from %s for session %s", prev.RemoteAddr(), session.PublicId()) + h.logger.Printf("Closing previous client from %s for session %s", prev.RemoteAddr(), session.PublicId()) prev.SendByeResponseWithReason(nil, "session_resumed") } @@ -1208,9 +1402,9 @@ func (h *Hub) processHello(client HandlerClient, message *ClientMessage) { delete(h.expectHelloClients, client) h.mu.Unlock() - log.Printf("Resume session from %s in %s (%s) %s (private=%s)", client.RemoteAddr(), client.Country(), client.UserAgent(), session.PublicId(), session.PrivateId()) + h.logger.Printf("Resume session from %s in %s (%s) %s (private=%s)", client.RemoteAddr(), client.Country(), client.UserAgent(), session.PublicId(), session.PrivateId()) - statsHubSessionsResumedTotal.WithLabelValues(clientSession.Backend().Id(), clientSession.ClientType()).Inc() + statsHubSessionsResumedTotal.WithLabelValues(clientSession.Backend().Id(), string(clientSession.ClientType())).Inc() h.sendHelloResponse(clientSession, message) clientSession.NotifySessionResumed(client) return @@ -1222,11 +1416,11 @@ func (h *Hub) processHello(client HandlerClient, message *ClientMessage) { h.mu.Unlock() switch message.Hello.Auth.Type { - case HelloClientTypeClient: + case api.HelloClientTypeClient: fallthrough - case HelloClientTypeFederation: + case api.HelloClientTypeFederation: h.processHelloClient(client, message) - case HelloClientTypeInternal: + case api.HelloClientTypeInternal: h.processHelloInternal(client, message) default: h.startExpectHello(client) @@ -1234,19 +1428,21 @@ func (h *Hub) processHello(client HandlerClient, message *ClientMessage) { } } -func (h *Hub) processHelloV1(ctx context.Context, client HandlerClient, message *ClientMessage) (*Backend, *BackendClientResponse, error) { - url := message.Hello.Auth.parsedUrl +func (h *Hub) processHelloV1(ctx context.Context, client ClientWithSession, message *api.ClientMessage) (*talk.Backend, *talk.BackendClientResponse, error) { + url := message.Hello.Auth.ParsedUrl backend := h.backend.GetBackend(url) if backend == nil { return nil, nil, InvalidBackendUrl } + url = url.JoinPath(PathToOcsSignalingBackend) + // Run in timeout context to prevent blocking too long. ctx, cancel := context.WithTimeout(ctx, h.backendTimeout) defer cancel() - var auth BackendClientResponse - request := NewBackendClientAuthRequest(message.Hello.Auth.Params) + var auth talk.BackendClientResponse + request := talk.NewBackendClientAuthRequest(message.Hello.Auth.Params) if err := h.backend.PerformJSONRequest(ctx, url, request, &auth); err != nil { return nil, nil, err } @@ -1256,8 +1452,8 @@ func (h *Hub) processHelloV1(ctx context.Context, client HandlerClient, message return backend, &auth, nil } -func (h *Hub) processHelloV2(ctx context.Context, client HandlerClient, message *ClientMessage) (*Backend, *BackendClientResponse, error) { - url := message.Hello.Auth.parsedUrl +func (h *Hub) processHelloV2(ctx context.Context, client ClientWithSession, message *api.ClientMessage) (*talk.Backend, *talk.BackendClientResponse, error) { + url := message.Hello.Auth.ParsedUrl backend := h.backend.GetBackend(url) if backend == nil { return nil, nil, InvalidBackendUrl @@ -1266,33 +1462,33 @@ func (h *Hub) processHelloV2(ctx context.Context, client HandlerClient, message var tokenString string var tokenClaims jwt.Claims switch message.Hello.Auth.Type { - case HelloClientTypeClient: - tokenString = message.Hello.Auth.helloV2Params.Token - tokenClaims = &HelloV2TokenClaims{} - case HelloClientTypeFederation: - if !h.backend.capabilities.HasCapabilityFeature(ctx, url, FeatureFederationV2) { + case api.HelloClientTypeClient: + tokenString = message.Hello.Auth.HelloV2Params.Token + tokenClaims = &api.HelloV2TokenClaims{} + case api.HelloClientTypeFederation: + if !h.backend.HasCapabilityFeature(ctx, url, talk.FeatureFederationV2) { return nil, nil, ErrFederationNotSupported } - tokenString = message.Hello.Auth.federationParams.Token - tokenClaims = &FederationTokenClaims{} + tokenString = message.Hello.Auth.FederationParams.Token + tokenClaims = &api.FederationTokenClaims{} default: return nil, nil, InvalidClientType } - token, err := jwt.ParseWithClaims(tokenString, tokenClaims, func(token *jwt.Token) (interface{}, error) { + token, err := jwt.ParseWithClaims(tokenString, tokenClaims, func(token *jwt.Token) (any, error) { // Only public-private-key algorithms are supported. - var loadKeyFunc func([]byte) (interface{}, error) + var loadKeyFunc func([]byte) (any, error) switch token.Method.(type) { case *jwt.SigningMethodRSA: - loadKeyFunc = func(data []byte) (interface{}, error) { + loadKeyFunc = func(data []byte) (any, error) { return jwt.ParseRSAPublicKeyFromPEM(data) } case *jwt.SigningMethodECDSA: - loadKeyFunc = func(data []byte) (interface{}, error) { + loadKeyFunc = func(data []byte) (any, error) { return jwt.ParseECPublicKeyFromPEM(data) } case *jwt.SigningMethodEd25519: - loadKeyFunc = func(data []byte) (interface{}, error) { + loadKeyFunc = func(data []byte) (any, error) { if !bytes.HasPrefix(data, []byte("-----BEGIN ")) { // Nextcloud sends the Ed25519 key as base64-encoded public key data. decoded, err := base64.StdEncoding.DecodeString(string(data)) @@ -1314,30 +1510,30 @@ func (h *Hub) processHelloV2(ctx context.Context, client HandlerClient, message return jwt.ParseEdPublicKeyFromPEM(data) } default: - log.Printf("Unexpected signing method: %v", token.Header["alg"]) - return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + h.logger.Printf("Unexpected signing method: %v", token.Header["alg"]) + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } // Run in timeout context to prevent blocking too long. backendCtx, cancel := context.WithTimeout(ctx, h.backendTimeout) defer cancel() - keyData, cached, found := h.backend.capabilities.GetStringConfig(backendCtx, url, ConfigGroupSignaling, ConfigKeyHelloV2TokenKey) + keyData, cached, found := h.backend.GetStringConfig(backendCtx, url, talk.ConfigGroupSignaling, talk.ConfigKeyHelloV2TokenKey) if !found { if cached { // The Nextcloud instance might just have enabled JWT but we probably use // the cached capabilities without the public key. Make sure to re-fetch. - h.backend.capabilities.InvalidateCapabilities(url) - keyData, _, found = h.backend.capabilities.GetStringConfig(backendCtx, url, ConfigGroupSignaling, ConfigKeyHelloV2TokenKey) + h.backend.InvalidateCapabilities(url) + keyData, _, found = h.backend.GetStringConfig(backendCtx, url, talk.ConfigGroupSignaling, talk.ConfigKeyHelloV2TokenKey) } if !found { - return nil, fmt.Errorf("No key found for issuer") + return nil, errors.New("no key found for issuer") } } key, err := loadKeyFunc([]byte(keyData)) if err != nil { - return nil, fmt.Errorf("Could not parse token key: %w", err) + return nil, fmt.Errorf("could not parse token key: %w", err) } return key, nil @@ -1360,16 +1556,16 @@ func (h *Hub) processHelloV2(ctx context.Context, client HandlerClient, message return nil, nil, InvalidToken } - var authTokenClaims AuthTokenClaims + var authTokenClaims api.AuthTokenClaims switch message.Hello.Auth.Type { - case HelloClientTypeClient: - claims, ok := token.Claims.(*HelloV2TokenClaims) + case api.HelloClientTypeClient: + claims, ok := token.Claims.(*api.HelloV2TokenClaims) if !ok || !token.Valid { return nil, nil, InvalidToken } authTokenClaims = claims - case HelloClientTypeFederation: - claims, ok := token.Claims.(*FederationTokenClaims) + case api.HelloClientTypeFederation: + claims, ok := token.Claims.(*api.FederationTokenClaims) if !ok || !token.Valid { return nil, nil, InvalidToken } @@ -1398,9 +1594,9 @@ func (h *Hub) processHelloV2(ctx context.Context, client HandlerClient, message return nil, nil, InvalidToken } - auth := &BackendClientResponse{ + auth := &talk.BackendClientResponse{ Type: "auth", - Auth: &BackendClientAuthResponse{ + Auth: &talk.BackendClientAuthResponse{ Version: message.Hello.Version, UserId: subject, User: authTokenClaims.GetUserData(), @@ -1409,27 +1605,27 @@ func (h *Hub) processHelloV2(ctx context.Context, client HandlerClient, message return backend, auth, nil } -func (h *Hub) processHelloClient(client HandlerClient, message *ClientMessage) { +func (h *Hub) processHelloClient(client ClientWithSession, message *api.ClientMessage) { // Make sure the client must send another "hello" in case of errors. defer h.startExpectHello(client) - var authFunc func(context.Context, HandlerClient, *ClientMessage) (*Backend, *BackendClientResponse, error) + var authFunc func(context.Context, ClientWithSession, *api.ClientMessage) (*talk.Backend, *talk.BackendClientResponse, error) switch message.Hello.Version { - case HelloVersionV1: + case api.HelloVersionV1: // Auth information contains a ticket that must be validated against the // Nextcloud instance. authFunc = h.processHelloV1 - case HelloVersionV2: + case api.HelloVersionV2: // Auth information contains a JWT that contains all information of the user. authFunc = h.processHelloV2 default: - client.SendMessage(message.NewErrorServerMessage(InvalidHelloVersion)) + client.SendMessage(message.NewErrorServerMessage(api.InvalidHelloVersion)) return } backend, auth, err := authFunc(client.Context(), client, message) if err != nil { - if e, ok := err.(*Error); ok { + if e, ok := err.(*api.Error); ok { client.SendMessage(message.NewErrorServerMessage(e)) } else { client.SendMessage(message.NewWrappedErrorServerMessage(err)) @@ -1440,55 +1636,55 @@ func (h *Hub) processHelloClient(client HandlerClient, message *ClientMessage) { h.processRegister(client, message, backend, auth) } -func (h *Hub) processHelloInternal(client HandlerClient, message *ClientMessage) { +func (h *Hub) processHelloInternal(client ClientWithSession, message *api.ClientMessage) { defer h.startExpectHello(client) if len(h.internalClientsSecret) == 0 { client.SendMessage(message.NewErrorServerMessage(InvalidClientType)) return } - ctx := context.TODO() + ctx := log.NewLoggerContext(client.Context(), h.logger) throttle, err := h.throttler.CheckBruteforce(ctx, client.RemoteAddr(), "HelloInternal") - if err == ErrBruteforceDetected { + if err == async.ErrBruteforceDetected { client.SendMessage(message.NewErrorServerMessage(TooManyRequests)) return } else if err != nil { - log.Printf("Error checking for bruteforce: %s", err) + h.logger.Printf("Error checking for bruteforce: %s", err) client.SendMessage(message.NewWrappedErrorServerMessage(err)) return } // Validate internal connection. - rnd := message.Hello.Auth.internalParams.Random + rnd := message.Hello.Auth.InternalParams.Random mac := hmac.New(sha256.New, h.internalClientsSecret) mac.Write([]byte(rnd)) // nolint check := hex.EncodeToString(mac.Sum(nil)) - if len(rnd) < minTokenRandomLength || check != message.Hello.Auth.internalParams.Token { + if len(rnd) < minTokenRandomLength || check != message.Hello.Auth.InternalParams.Token { throttle(ctx) client.SendMessage(message.NewErrorServerMessage(InvalidToken)) return } - backend := h.backend.GetBackend(message.Hello.Auth.internalParams.parsedBackend) + backend := h.backend.GetBackend(message.Hello.Auth.InternalParams.ParsedBackend) if backend == nil { throttle(ctx) client.SendMessage(message.NewErrorServerMessage(InvalidBackendUrl)) return } - auth := &BackendClientResponse{ + auth := &talk.BackendClientResponse{ Type: "auth", - Auth: &BackendClientAuthResponse{}, + Auth: &talk.BackendClientAuthResponse{}, } h.processRegister(client, message, backend, auth) } -func (h *Hub) disconnectByRoomSessionId(ctx context.Context, roomSessionId string, backend *Backend) { +func (h *Hub) disconnectByRoomSessionId(ctx context.Context, roomSessionId api.RoomSessionId, backend *talk.Backend) { sessionId, err := h.roomSessions.LookupSessionId(ctx, roomSessionId, "room_session_reconnected") if err == ErrNoSuchRoomSession { return } else if err != nil { - log.Printf("Could not get session id for room session %s: %s", roomSessionId, err) + h.logger.Printf("Could not get session id for room session %s: %s", roomSessionId, err) return } @@ -1496,53 +1692,104 @@ func (h *Hub) disconnectByRoomSessionId(ctx context.Context, roomSessionId strin if session == nil { // Session is located on a different server. Should already have been closed // but send "bye" again as additional safeguard. - msg := &AsyncMessage{ + msg := &events.AsyncMessage{ Type: "message", - Message: &ServerMessage{ + Message: &api.ServerMessage{ Type: "bye", - Bye: &ByeServerMessage{ + Bye: &api.ByeServerMessage{ Reason: "room_session_reconnected", }, }, } if err := h.events.PublishSessionMessage(sessionId, backend, msg); err != nil { - log.Printf("Could not send reconnect bye to session %s: %s", sessionId, err) + h.logger.Printf("Could not send reconnect bye to session %s: %s", sessionId, err) } return } - log.Printf("Closing session %s because same room session %s connected", session.PublicId(), roomSessionId) + h.logger.Printf("Closing session %s because same room session %s connected", session.PublicId(), roomSessionId) + h.disconnectSessionWithReason(session, "room_session_reconnected") +} + +func (h *Hub) DisconnectSessionByRoomSessionId(sessionId api.PublicSessionId, roomSessionId api.RoomSessionId, reason string) { + session := h.GetSessionByPublicId(sessionId) + if session == nil { + return + } + + h.logger.Printf("Closing session %s because same room session %s connected", session.PublicId(), roomSessionId) + h.disconnectSessionWithReason(session, reason) +} + +func (h *Hub) disconnectSessionWithReason(session Session, reason string) { session.LeaveRoom(false) switch sess := session.(type) { case *ClientSession: if client := sess.GetClient(); client != nil { - client.SendByeResponseWithReason(nil, "room_session_reconnected") + client.SendByeResponseWithReason(nil, reason) } } session.Close() } -func (h *Hub) sendRoom(session *ClientSession, message *ClientMessage, room *Room) bool { - response := &ServerMessage{ +func (h *Hub) sendRoom(session *ClientSession, message *api.ClientMessage, room *Room) bool { + response := &api.ServerMessage{ Type: "room", } if message != nil { response.Id = message.Id } if room == nil { - response.Room = &RoomServerMessage{ + response.Room = &api.RoomServerMessage{ RoomId: "", } } else { - response.Room = &RoomServerMessage{ + response.Room = &api.RoomServerMessage{ RoomId: room.id, - Properties: room.properties, + Properties: room.Properties(), + } + var mcuStreamBitrate api.Bandwidth + var mcuScreenBitrate api.Bandwidth + if mcu := h.mcu; mcu != nil { + mcuStreamBitrate, mcuScreenBitrate = mcu.GetBandwidthLimits() + } + + var backendStreamBitrate api.Bandwidth + var backendScreenBitrate api.Bandwidth + if backend := room.Backend(); backend != nil { + backendStreamBitrate = backend.MaxStreamBitrate() + backendScreenBitrate = backend.MaxScreenBitrate() + } + + var maxStreamBitrate api.Bandwidth + if mcuStreamBitrate != 0 && backendStreamBitrate != 0 { + maxStreamBitrate = min(mcuStreamBitrate, backendStreamBitrate) + } else if mcuStreamBitrate != 0 { + maxStreamBitrate = mcuStreamBitrate + } else { + maxStreamBitrate = backendStreamBitrate + } + + var maxScreenBitrate api.Bandwidth + if mcuScreenBitrate != 0 && backendScreenBitrate != 0 { + maxScreenBitrate = min(mcuScreenBitrate, backendScreenBitrate) + } else if mcuScreenBitrate != 0 { + maxScreenBitrate = mcuScreenBitrate + } else { + maxScreenBitrate = backendScreenBitrate + } + + if maxStreamBitrate != 0 || maxScreenBitrate != 0 { + response.Room.Bandwidth = &api.RoomBandwidth{ + MaxStreamBitrate: maxStreamBitrate, + MaxScreenBitrate: maxScreenBitrate, + } } } return session.SendMessage(response) } -func (h *Hub) processRoom(sess Session, message *ClientMessage) { +func (h *Hub) processRoom(sess Session, message *api.ClientMessage) { session, ok := sess.(*ClientSession) if !ok { return @@ -1552,11 +1799,11 @@ func (h *Hub) processRoom(sess Session, message *ClientMessage) { if roomId == "" { // We can handle leaving a room directly. if session.LeaveRoomWithMessage(true, message) != nil { - // User was in a room before, so need to notify about leaving it. - h.sendRoom(session, message, nil) - if session.UserId() == "" && session.ClientType() != HelloClientTypeInternal { + if session.UserId() == "" && session.ClientType() != api.HelloClientTypeInternal { h.startWaitAnonymousSessionRoom(session) } + // User was in a room before, so need to notify about leaving it. + h.sendRoom(session, message, nil) } return @@ -1591,45 +1838,37 @@ func (h *Hub) processRoom(sess Session, message *ClientMessage) { if session.UserId() == "" && client == nil { h.startWaitAnonymousSessionRoom(session) } - var ae *Error - if errors.As(err, &ae) { + if ae, ok := internal.AsErrorType[*api.Error](err); ok { session.SendMessage(message.NewErrorServerMessage(ae)) return } - var details interface{} - var ce *tls.CertificateVerificationError - if errors.As(err, &ce) { + var details any + if ce, ok := internal.AsErrorType[*tls.CertificateVerificationError](err); ok { details = map[string]string{ "code": "certificate_verification_error", "message": ce.Error(), } - } - var ne net.Error - if details == nil && errors.As(err, &ne) { + } else if ne, ok := internal.AsErrorType[net.Error](err); ok { details = map[string]string{ "code": "network_error", "message": ne.Error(), } - } - if details == nil { - var we websocket.HandshakeError - if errors.Is(err, websocket.ErrBadHandshake) { - details = map[string]string{ - "code": "network_error", - "message": err.Error(), - } - } else if errors.As(err, &we) { - details = map[string]string{ - "code": "network_error", - "message": we.Error(), - } + } else if errors.Is(err, websocket.ErrBadHandshake) { + details = map[string]string{ + "code": "network_error", + "message": err.Error(), + } + } else if we, ok := internal.AsErrorType[websocket.HandshakeError](err); ok { + details = map[string]string{ + "code": "network_error", + "message": we.Error(), } } - log.Printf("Error creating federation client to %s for %s to join room %s: %s", federation.SignalingUrl, session.PublicId(), roomId, err) + h.logger.Printf("Error creating federation client to %s for %s to join room %s: %s", federation.SignalingUrl, session.PublicId(), roomId, err) session.SendMessage(message.NewErrorServerMessage( - NewErrorDetail("federation_error", "Failed to create federation client.", details), + api.NewErrorDetail("federation_error", "Failed to create federation client.", details), )) return } @@ -1639,19 +1878,20 @@ func (h *Hub) processRoom(sess Session, message *ClientMessage) { roomSessionId := message.Room.SessionId if roomSessionId == "" { // TODO(jojo): Better make the session id required in the request. - log.Printf("User did not send a room session id, assuming session %s", session.PublicId()) - roomSessionId = session.PublicId() + h.logger.Printf("User did not send a room session id, assuming session %s", session.PublicId()) + roomSessionId = api.RoomSessionId(session.PublicId()) } // Prefix room session id to allow using the same signaling server for two Nextcloud instances during development. // Otherwise the same room session id will be detected and the other session will be kicked. - if err := session.UpdateRoomSessionId(FederatedRoomSessionIdPrefix + roomSessionId); err != nil { - log.Printf("Error updating room session id for session %s: %s", session.PublicId(), err) + if err := session.UpdateRoomSessionId(api.FederatedRoomSessionIdPrefix + roomSessionId); err != nil { + h.logger.Printf("Error updating room session id for session %s: %s", session.PublicId(), err) } h.mu.Lock() + defer h.mu.Unlock() h.federatedSessions[session] = true - h.mu.Unlock() + h.federationClients[client] = true return } @@ -1660,30 +1900,32 @@ func (h *Hub) processRoom(sess Session, message *ClientMessage) { roomSessionId := message.Room.SessionId if roomSessionId == "" { // TODO(jojo): Better make the session id required in the request. - log.Printf("User did not send a room session id, assuming session %s", session.PublicId()) - roomSessionId = session.PublicId() + h.logger.Printf("User did not send a room session id, assuming session %s", session.PublicId()) + roomSessionId = api.RoomSessionId(session.PublicId()) } if err := session.UpdateRoomSessionId(roomSessionId); err != nil { - log.Printf("Error updating room session id for session %s: %s", session.PublicId(), err) + h.logger.Printf("Error updating room session id for session %s: %s", session.PublicId(), err) } session.SendMessage(message.NewErrorServerMessage( - NewErrorDetail("already_joined", "Already joined this room.", &RoomErrorDetails{ - Room: &RoomServerMessage{ + api.NewErrorDetail("already_joined", "Already joined this room.", &api.RoomErrorDetails{ + Room: &api.RoomServerMessage{ RoomId: room.id, - Properties: room.properties, + Properties: room.Properties(), }, }), )) return } - var room BackendClientResponse - if session.ClientType() == HelloClientTypeInternal { + var room talk.BackendClientResponse + var joinRoomTime time.Time + if session.ClientType() == api.HelloClientTypeInternal { // Internal clients can join any room. - room = BackendClientResponse{ + joinRoomTime = time.Now() + room = talk.BackendClientResponse{ Type: "room", - Room: &BackendClientRoomResponse{ + Room: &talk.BackendClientRoomResponse{ RoomId: roomId, }, } @@ -1695,18 +1937,19 @@ func (h *Hub) processRoom(sess Session, message *ClientMessage) { sessionId := message.Room.SessionId if sessionId == "" { // TODO(jojo): Better make the session id required in the request. - log.Printf("User did not send a room session id, assuming session %s", session.PublicId()) - sessionId = session.PublicId() + h.logger.Printf("User did not send a room session id, assuming session %s", session.PublicId()) + sessionId = api.RoomSessionId(session.PublicId()) } - request := NewBackendClientRoomRequest(roomId, session.UserId(), sessionId) + request := talk.NewBackendClientRoomRequest(roomId, session.UserId(), sessionId) request.Room.UpdateFromSession(session) - if err := h.backend.PerformJSONRequest(ctx, session.ParsedBackendUrl(), request, &room); err != nil { + if err := h.backend.PerformJSONRequest(ctx, session.ParsedBackendOcsUrl(), request, &room); err != nil { session.SendMessage(message.NewWrappedErrorServerMessage(err)) return } // TODO(jojo): Validate response + joinRoomTime = time.Now() if message.Room.SessionId != "" { // There can only be one connection per Nextcloud Talk session, // disconnect any other connections without sending a "leave" event. @@ -1717,7 +1960,7 @@ func (h *Hub) processRoom(sess Session, message *ClientMessage) { } } - h.processJoinRoom(session, message, &room) + h.processJoinRoom(session, message, &room, joinRoomTime) } func (h *Hub) publishFederatedSessions() (int, *sync.WaitGroup) { @@ -1729,7 +1972,7 @@ func (h *Hub) publishFederatedSessions() (int, *sync.WaitGroup) { return 0, &wg } - rooms := make(map[string]map[string][]BackendPingEntry) + rooms := make(map[string]map[string][]talk.BackendPingEntry) urls := make(map[string]*url.URL) for session := range h.federatedSessions { u := session.BackendUrl() @@ -1742,25 +1985,24 @@ func (h *Hub) publishFederatedSessions() (int, *sync.WaitGroup) { continue } - var sid string + var sid api.RoomSessionId var uid string // Use Nextcloud session id and user id - sid = strings.TrimPrefix(session.RoomSessionId(), FederatedRoomSessionIdPrefix) - uid = session.AuthUserId() - if sid == "" { + if sid = session.RoomSessionId().WithoutFederation(); sid == "" { continue } + uid = session.AuthUserId() roomId := federation.RoomId() entries, found := rooms[roomId] if !found { - entries = make(map[string][]BackendPingEntry) + entries = make(map[string][]talk.BackendPingEntry) rooms[roomId] = entries } e, found := entries[u] if !found { - p := session.ParsedBackendUrl() + p := session.ParsedBackendOcsUrl() if p == nil { // Should not happen, invalid URLs should get rejected earlier. continue @@ -1768,7 +2010,7 @@ func (h *Hub) publishFederatedSessions() (int, *sync.WaitGroup) { urls[u] = p } - entries[u] = append(e, BackendPingEntry{ + entries[u] = append(e, talk.BackendPingEntry{ SessionId: sid, UserId: uid, }) @@ -1778,17 +2020,18 @@ func (h *Hub) publishFederatedSessions() (int, *sync.WaitGroup) { return 0, &wg } count := 0 + ctx := log.NewLoggerContext(context.Background(), h.logger) for roomId, entries := range rooms { for u, e := range entries { wg.Add(1) count += len(e) - go func(roomId string, url *url.URL, entries []BackendPingEntry) { + go func(roomId string, url *url.URL, entries []talk.BackendPingEntry) { defer wg.Done() - ctx, cancel := context.WithTimeout(context.Background(), h.backendTimeout) + sendCtx, cancel := context.WithTimeout(ctx, h.backendTimeout) defer cancel() - if err := h.roomPing.SendPings(ctx, roomId, url, entries); err != nil { - log.Printf("Error pinging room %s for active entries %+v: %s", roomId, entries, err) + if err := h.roomPing.SendPings(sendCtx, roomId, url, entries); err != nil { + h.logger.Printf("Error pinging room %s for active entries %+v: %s", roomId, entries, err) } }(roomId, urls[u], e) } @@ -1796,7 +2039,7 @@ func (h *Hub) publishFederatedSessions() (int, *sync.WaitGroup) { return count, &wg } -func (h *Hub) GetRoomForBackend(id string, backend *Backend) *Room { +func (h *Hub) GetRoomForBackend(id string, backend *talk.Backend) *Room { internalRoomId := getRoomIdForBackend(id, backend) h.ru.RLock() @@ -1804,6 +2047,45 @@ func (h *Hub) GetRoomForBackend(id string, backend *Backend) *Room { return h.rooms[internalRoomId] } +func (h *Hub) GetInternalSessions(roomId string, backend *talk.Backend) ([]*grpc.InternalSessionData, []*grpc.VirtualSessionData, bool) { + room := h.GetRoomForBackend(roomId, backend) + if room == nil { + return nil, nil, false + } + + room.mu.RLock() + defer room.mu.RUnlock() + + var internalSessions []*grpc.InternalSessionData + var virtualSessions []*grpc.VirtualSessionData + for session := range room.internalSessions { + internalSessions = append(internalSessions, &grpc.InternalSessionData{ + SessionId: string(session.PublicId()), + InCall: uint32(session.GetInCall()), + Features: session.GetFeatures(), + }) + } + + for session := range room.virtualSessions { + virtualSessions = append(virtualSessions, &grpc.VirtualSessionData{ + SessionId: string(session.PublicId()), + InCall: uint32(session.GetInCall()), + }) + } + + return internalSessions, virtualSessions, true +} + +func (h *Hub) GetTransientEntries(roomId string, backend *talk.Backend) (api.TransientDataEntries, bool) { + room := h.GetRoomForBackend(roomId, backend) + if room == nil { + return nil, false + } + + entries := room.transientData.GetEntries() + return entries, true +} + func (h *Hub) removeRoom(room *Room) { internalRoomId := getRoomIdForBackend(room.Id(), room.Backend()) h.ru.Lock() @@ -1815,7 +2097,15 @@ func (h *Hub) removeRoom(room *Room) { h.roomPing.DeleteRoom(room.Id()) } -func (h *Hub) createRoom(id string, properties json.RawMessage, backend *Backend) (*Room, error) { +func (h *Hub) CreateRoom(id string, properties json.RawMessage, backend *talk.Backend) (*Room, error) { + h.ru.Lock() + defer h.ru.Unlock() + + return h.createRoomLocked(id, properties, backend) +} + +// +checklocks:h.ru +func (h *Hub) createRoomLocked(id string, properties json.RawMessage, backend *talk.Backend) (*Room, error) { // Note the write lock must be held. room, err := NewRoom(id, properties, h, h.events, backend) if err != nil { @@ -1828,7 +2118,7 @@ func (h *Hub) createRoom(id string, properties json.RawMessage, backend *Backend return room, nil } -func (h *Hub) processJoinRoom(session *ClientSession, message *ClientMessage, room *BackendClientResponse) { +func (h *Hub) processJoinRoom(session *ClientSession, message *api.ClientMessage, room *talk.BackendClientResponse, joinTime time.Time) { if room.Type == "error" { session.SendMessage(message.NewErrorServerMessage(room.Error)) return @@ -1855,7 +2145,7 @@ func (h *Hub) processJoinRoom(session *ClientSession, message *ClientMessage, ro r, found := h.rooms[internalRoomId] if !found { var err error - if r, err = h.createRoom(roomId, room.Room.Properties, session.Backend()); err != nil { + if r, err = h.createRoomLocked(roomId, room.Room.Properties, session.Backend()); err != nil { h.ru.Unlock() session.SendMessage(message.NewWrappedErrorServerMessage(err)) // The session (implicitly) left the room due to an error. @@ -1869,12 +2159,12 @@ func (h *Hub) processJoinRoom(session *ClientSession, message *ClientMessage, ro h.mu.Lock() // The session now joined a room, don't expire if it is anonymous. delete(h.anonymousSessions, session) - if session.ClientType() == HelloClientTypeInternal && session.HasFeature(ClientFeatureStartDialout) { + if session.ClientType() == api.HelloClientTypeInternal && session.HasFeature(api.ClientFeatureStartDialout) { // An internal session in a room can not be used for dialout. delete(h.dialoutSessions, session) } h.mu.Unlock() - session.SetRoom(r) + session.SetRoom(r, joinTime) if room.Room.Permissions != nil { session.SetPermissions(*room.Room.Permissions) } @@ -1882,7 +2172,7 @@ func (h *Hub) processJoinRoom(session *ClientSession, message *ClientMessage, ro r.AddSession(session, room.Room.Session) } -func (h *Hub) processMessageMsg(sess Session, message *ClientMessage) { +func (h *Hub) processMessageMsg(sess Session, message *api.ClientMessage) { session, ok := sess.(*ClientSession) if !ok { // Client is not connected yet. @@ -1892,19 +2182,19 @@ func (h *Hub) processMessageMsg(sess Session, message *ClientMessage) { msg := message.Message var recipient *ClientSession var subject string - var clientData *MessageClientMessageData - var serverRecipient *MessageClientMessageRecipient - var recipientSessionId string + var clientData *api.MessageClientMessageData + var serverRecipient *api.MessageClientMessageRecipient + var recipientSessionId api.PublicSessionId var room *Room switch msg.Recipient.Type { - case RecipientTypeSession: + case api.RecipientTypeSession: if h.mcu != nil { // Maybe this is a message to be processed by the MCU. - var data MessageClientMessageData - if err := json.Unmarshal(msg.Data, &data); err == nil { + var data api.MessageClientMessageData + if err := data.UnmarshalJSON(msg.Data); err == nil { if err := data.CheckValid(); err != nil { - log.Printf("Invalid message %+v from client %s: %v", message, session.PublicId(), err) - if err, ok := err.(*Error); ok { + h.logger.Printf("Invalid message %+v from client %s: %v", message, session.PublicId(), err) + if err, ok := err.(*api.Error); ok { session.SendMessage(message.NewErrorServerMessage(err)) } else { session.SendMessage(message.NewErrorServerMessage(InvalidFormat)) @@ -1946,12 +2236,12 @@ func (h *Hub) processMessageMsg(sess Session, message *ClientMessage) { return } - publisher := session.GetPublisher(StreamTypeScreen) + publisher := session.GetPublisher(sfu.StreamTypeScreen) if publisher == nil { return } - log.Printf("Closing screen publisher for %s", session.PublicId()) + h.logger.Printf("Closing screen publisher for %s", session.PublicId()) ctx, cancel := context.WithTimeout(context.Background(), h.mcuTimeout) defer cancel() publisher.Close(ctx) @@ -1974,31 +2264,31 @@ func (h *Hub) processMessageMsg(sess Session, message *ClientMessage) { return } - subject = "session." + msg.Recipient.SessionId + subject = events.GetSubjectForSessionId(msg.Recipient.SessionId, sess.Backend()) recipientSessionId = msg.Recipient.SessionId if sess, ok := sess.(*ClientSession); ok { recipient = sess } // Send to client connection for virtual sessions. - if sess.ClientType() == HelloClientTypeVirtual { + if sess.ClientType() == api.HelloClientTypeVirtual { virtualSession := sess.(*VirtualSession) clientSession := virtualSession.Session() - subject = "session." + clientSession.PublicId() + subject = events.GetSubjectForSessionId(clientSession.PublicId(), sess.Backend()) recipientSessionId = clientSession.PublicId() recipient = clientSession // The client should see his session id as recipient. - serverRecipient = &MessageClientMessageRecipient{ + serverRecipient = &api.MessageClientMessageRecipient{ Type: "session", SessionId: virtualSession.SessionId(), } } } else { - subject = "session." + msg.Recipient.SessionId + subject = events.GetSubjectForSessionId(msg.Recipient.SessionId, nil) recipientSessionId = msg.Recipient.SessionId serverRecipient = &msg.Recipient } - case RecipientTypeUser: + case api.RecipientTypeUser: if msg.Recipient.UserId != "" { if msg.Recipient.UserId == session.UserId() { // Don't loop messages to the sender. @@ -2007,21 +2297,21 @@ func (h *Hub) processMessageMsg(sess Session, message *ClientMessage) { return } - subject = GetSubjectForUserId(msg.Recipient.UserId, session.Backend()) + subject = events.GetSubjectForUserId(msg.Recipient.UserId, session.Backend()) } - case RecipientTypeRoom: + case api.RecipientTypeRoom: fallthrough - case RecipientTypeCall: + case api.RecipientTypeCall: if session != nil { if room = session.GetRoom(); room != nil { - subject = GetSubjectForRoomId(room.Id(), room.Backend()) + subject = events.GetSubjectForRoomId(room.Id(), room.Backend()) if h.mcu != nil { - var data MessageClientMessageData - if err := json.Unmarshal(msg.Data, &data); err == nil { + var data api.MessageClientMessageData + if err := data.UnmarshalJSON(msg.Data); err == nil { if err := data.CheckValid(); err != nil { - log.Printf("Invalid message %+v from client %s: %v", message, session.PublicId(), err) - if err, ok := err.(*Error); ok { + h.logger.Printf("Invalid message %+v from client %s: %v", message, session.PublicId(), err) + if err, ok := err.(*api.Error); ok { session.SendMessage(message.NewErrorServerMessage(err)) } else { session.SendMessage(message.NewErrorServerMessage(InvalidFormat)) @@ -2036,14 +2326,14 @@ func (h *Hub) processMessageMsg(sess Session, message *ClientMessage) { } } if subject == "" { - log.Printf("Unknown recipient in message %+v from %s", msg, session.PublicId()) + h.logger.Printf("Unknown recipient in message %+v from %s", msg, session.PublicId()) return } - response := &ServerMessage{ + response := &api.ServerMessage{ Type: "message", - Message: &MessageServerMessage{ - Sender: &MessageServerMessageSender{ + Message: &api.MessageServerMessage{ + Sender: &api.MessageServerMessageSender{ Type: msg.Recipient.Type, SessionId: session.PublicId(), UserId: session.UserId(), @@ -2056,7 +2346,7 @@ func (h *Hub) processMessageMsg(sess Session, message *ClientMessage) { // The recipient is connected to this instance, no need to go through asynchronous events. if clientData != nil && clientData.Type == "sendoffer" { if err := session.IsAllowedToSend(clientData); err != nil { - log.Printf("Session %s is not allowed to send offer for %s, ignoring (%s)", session.PublicId(), clientData.RoomType, err) + h.logger.Printf("Session %s is not allowed to send offer for %s, ignoring (%s)", session.PublicId(), clientData.RoomType, err) sendNotAllowed(session, message, "Not allowed to send offer") return } @@ -2068,20 +2358,20 @@ func (h *Hub) processMessageMsg(sess Session, message *ClientMessage) { ctx, cancel := context.WithTimeout(session.Context(), h.mcuTimeout) defer cancel() - mc, err := recipient.GetOrCreateSubscriber(ctx, h.mcu, session.PublicId(), StreamType(clientData.RoomType)) + mc, err := recipient.GetOrCreateSubscriber(ctx, h.mcu, session.PublicId(), sfu.StreamType(clientData.RoomType)) if err != nil { - log.Printf("Could not create MCU subscriber for session %s to send %+v to %s: %s", session.PublicId(), clientData, recipient.PublicId(), err) + h.logger.Printf("Could not create MCU subscriber for session %s to send %+v to %s: %s", session.PublicId(), clientData, recipient.PublicId(), err) sendMcuClientNotFound(session, message) return } else if mc == nil { - log.Printf("No MCU subscriber found for session %s to send %+v to %s", session.PublicId(), clientData, recipient.PublicId()) + h.logger.Printf("No MCU subscriber found for session %s to send %+v to %s", session.PublicId(), clientData, recipient.PublicId()) sendMcuClientNotFound(session, message) return } - mc.SendMessage(session.Context(), msg, clientData, func(err error, response map[string]interface{}) { + mc.SendMessage(session.Context(), msg, clientData, func(err error, response api.StringMap) { if err != nil { - log.Printf("Could not send MCU message %+v for session %s to %s: %s", clientData, session.PublicId(), recipient.PublicId(), err) + h.logger.Printf("Could not send MCU message %+v for session %s to %s: %s", clientData, session.PublicId(), recipient.PublicId(), err) sendMcuProcessingFailed(session, message) return } else if response == nil { @@ -2102,56 +2392,56 @@ func (h *Hub) processMessageMsg(sess Session, message *ClientMessage) { } else { if clientData != nil && clientData.Type == "sendoffer" { if err := session.IsAllowedToSend(clientData); err != nil { - log.Printf("Session %s is not allowed to send offer for %s, ignoring (%s)", session.PublicId(), clientData.RoomType, err) + h.logger.Printf("Session %s is not allowed to send offer for %s, ignoring (%s)", session.PublicId(), clientData.RoomType, err) sendNotAllowed(session, message, "Not allowed to send offer") return } - async := &AsyncMessage{ + async := &events.AsyncMessage{ Type: "sendoffer", - SendOffer: &SendOfferMessage{ + SendOffer: &events.SendOfferMessage{ MessageId: message.Id, SessionId: session.PublicId(), Data: clientData, }, } if err := h.events.PublishSessionMessage(recipientSessionId, session.Backend(), async); err != nil { - log.Printf("Error publishing message to remote session: %s", err) + h.logger.Printf("Error publishing message to remote session: %s", err) } return } - async := &AsyncMessage{ + async := &events.AsyncMessage{ Type: "message", Message: response, } var err error switch msg.Recipient.Type { - case RecipientTypeSession: + case api.RecipientTypeSession: err = h.events.PublishSessionMessage(recipientSessionId, session.Backend(), async) - case RecipientTypeUser: + case api.RecipientTypeUser: err = h.events.PublishUserMessage(msg.Recipient.UserId, session.Backend(), async) - case RecipientTypeRoom: + case api.RecipientTypeRoom: fallthrough - case RecipientTypeCall: + case api.RecipientTypeCall: err = h.events.PublishRoomMessage(room.Id(), session.Backend(), async) default: err = fmt.Errorf("unsupported recipient type: %s", msg.Recipient.Type) } if err != nil { - log.Printf("Error publishing message to remote session: %s", err) + h.logger.Printf("Error publishing message to remote session: %s", err) } } } func isAllowedToControl(session Session) bool { - if session.ClientType() == HelloClientTypeInternal { + if session.ClientType() == api.HelloClientTypeInternal { // Internal clients are allowed to send any control message. return true } - if session.HasPermission(PERMISSION_MAY_CONTROL) { + if session.HasPermission(api.PERMISSION_MAY_CONTROL) { // Moderator clients are allowed to send any control message. return true } @@ -2159,20 +2449,20 @@ func isAllowedToControl(session Session) bool { return false } -func (h *Hub) processControlMsg(session Session, message *ClientMessage) { +func (h *Hub) processControlMsg(session Session, message *api.ClientMessage) { msg := message.Control if !isAllowedToControl(session) { - log.Printf("Ignore control message %+v from %s", msg, session.PublicId()) + h.logger.Printf("Ignore control message %+v from %s", msg, session.PublicId()) return } var recipient *ClientSession var subject string - var serverRecipient *MessageClientMessageRecipient - var recipientSessionId string + var serverRecipient *api.MessageClientMessageRecipient + var recipientSessionId api.PublicSessionId var room *Room switch msg.Recipient.Type { - case RecipientTypeSession: + case api.RecipientTypeSession: data := h.decodePublicSessionId(msg.Recipient.SessionId) if data != nil { if msg.Recipient.SessionId == session.PublicId() { @@ -2180,7 +2470,7 @@ func (h *Hub) processControlMsg(session Session, message *ClientMessage) { return } - subject = "session." + msg.Recipient.SessionId + subject = events.GetSubjectForSessionId(msg.Recipient.SessionId, nil) recipientSessionId = msg.Recipient.SessionId h.mu.RLock() sess, found := h.sessions[data.Sid] @@ -2190,14 +2480,14 @@ func (h *Hub) processControlMsg(session Session, message *ClientMessage) { } // Send to client connection for virtual sessions. - if sess.ClientType() == HelloClientTypeVirtual { + if sess.ClientType() == api.HelloClientTypeVirtual { virtualSession := sess.(*VirtualSession) clientSession := virtualSession.Session() - subject = "session." + clientSession.PublicId() + subject = events.GetSubjectForSessionId(clientSession.PublicId(), sess.Backend()) recipientSessionId = clientSession.PublicId() recipient = clientSession // The client should see his session id as recipient. - serverRecipient = &MessageClientMessageRecipient{ + serverRecipient = &api.MessageClientMessageRecipient{ Type: "session", SessionId: virtualSession.SessionId(), } @@ -2209,7 +2499,7 @@ func (h *Hub) processControlMsg(session Session, message *ClientMessage) { } else { serverRecipient = &msg.Recipient } - case RecipientTypeUser: + case api.RecipientTypeUser: if msg.Recipient.UserId != "" { if msg.Recipient.UserId == session.UserId() { // Don't loop messages to the sender. @@ -2218,26 +2508,26 @@ func (h *Hub) processControlMsg(session Session, message *ClientMessage) { return } - subject = GetSubjectForUserId(msg.Recipient.UserId, session.Backend()) + subject = events.GetSubjectForUserId(msg.Recipient.UserId, session.Backend()) } - case RecipientTypeRoom: + case api.RecipientTypeRoom: fallthrough - case RecipientTypeCall: + case api.RecipientTypeCall: if session != nil { if room = session.GetRoom(); room != nil { - subject = GetSubjectForRoomId(room.Id(), room.Backend()) + subject = events.GetSubjectForRoomId(room.Id(), room.Backend()) } } } if subject == "" { - log.Printf("Unknown recipient in message %+v from %s", msg, session.PublicId()) + h.logger.Printf("Unknown recipient in message %+v from %s", msg, session.PublicId()) return } - response := &ServerMessage{ + response := &api.ServerMessage{ Type: "control", - Control: &ControlServerMessage{ - Sender: &MessageServerMessageSender{ + Control: &api.ControlServerMessage{ + Sender: &api.MessageServerMessageSender{ Type: msg.Recipient.Type, SessionId: session.PublicId(), UserId: session.UserId(), @@ -2249,37 +2539,37 @@ func (h *Hub) processControlMsg(session Session, message *ClientMessage) { if recipient != nil { recipient.SendMessage(response) } else { - async := &AsyncMessage{ + async := &events.AsyncMessage{ Type: "message", Message: response, } var err error switch msg.Recipient.Type { - case RecipientTypeSession: + case api.RecipientTypeSession: err = h.events.PublishSessionMessage(recipientSessionId, session.Backend(), async) - case RecipientTypeUser: + case api.RecipientTypeUser: err = h.events.PublishUserMessage(msg.Recipient.UserId, session.Backend(), async) - case RecipientTypeRoom: + case api.RecipientTypeRoom: fallthrough - case RecipientTypeCall: + case api.RecipientTypeCall: err = h.events.PublishRoomMessage(room.Id(), room.Backend(), async) default: err = fmt.Errorf("unsupported recipient type: %s", msg.Recipient.Type) } if err != nil { - log.Printf("Error publishing message to remote session: %s", err) + h.logger.Printf("Error publishing message to remote session: %s", err) } } } -func (h *Hub) processInternalMsg(sess Session, message *ClientMessage) { +func (h *Hub) processInternalMsg(sess Session, message *api.ClientMessage) { msg := message.Internal session, ok := sess.(*ClientSession) if !ok { // Client is not connected yet. return - } else if session.ClientType() != HelloClientTypeInternal { - log.Printf("Ignore internal message %+v from %s", msg, session.PublicId()) + } else if session.ClientType() != api.HelloClientTypeInternal { + h.logger.Printf("Ignore internal message %+v from %s", msg, session.PublicId()) return } @@ -2292,19 +2582,19 @@ func (h *Hub) processInternalMsg(sess Session, message *ClientMessage) { msg := msg.AddSession room := h.GetRoomForBackend(msg.RoomId, session.Backend()) if room == nil { - log.Printf("Ignore add session message %+v for invalid room %s from %s", *msg, msg.RoomId, session.PublicId()) + h.logger.Printf("Ignore add session message %+v for invalid room %s from %s", *msg, msg.RoomId, session.PublicId()) return } sessionIdData := h.newSessionIdData(session.Backend()) - privateSessionId, err := h.cookie.EncodePrivate(sessionIdData) + privateSessionId, err := h.sessionIds.EncodePrivate(sessionIdData) if err != nil { - log.Printf("Could not encode private virtual session id: %s", err) + h.logger.Printf("Could not encode private virtual session id: %s", err) return } - publicSessionId, err := h.cookie.EncodePublic(sessionIdData) + publicSessionId, err := h.sessionIds.EncodePublic(sessionIdData) if err != nil { - log.Printf("Could not encode public virtual session id: %s", err) + h.logger.Printf("Could not encode public virtual session id: %s", err) return } @@ -2315,41 +2605,41 @@ func (h *Hub) processInternalMsg(sess Session, message *ClientMessage) { sess, err := NewVirtualSession(session, privateSessionId, publicSessionId, sessionIdData, msg) if err != nil { - log.Printf("Could not create virtual session %s: %s", virtualSessionId, err) - reply := message.NewErrorServerMessage(NewError("add_failed", "Could not create virtual session.")) + h.logger.Printf("Could not create virtual session %s: %s", virtualSessionId, err) + reply := message.NewErrorServerMessage(api.NewError("add_failed", "Could not create virtual session.")) session.SendMessage(reply) return } - if msg.Options != nil { - request := NewBackendClientRoomRequest(room.Id(), msg.UserId, publicSessionId) - request.Room.ActorId = msg.Options.ActorId - request.Room.ActorType = msg.Options.ActorType + if options := msg.Options; options != nil && options.ActorId != "" && options.ActorType != "" { + request := talk.NewBackendClientRoomRequest(room.Id(), msg.UserId, api.RoomSessionId(publicSessionId)) + request.Room.ActorId = options.ActorId + request.Room.ActorType = options.ActorType request.Room.InCall = sess.GetInCall() - var response BackendClientResponse - if err := h.backend.PerformJSONRequest(ctx, session.ParsedBackendUrl(), request, &response); err != nil { + var response talk.BackendClientResponse + if err := h.backend.PerformJSONRequest(ctx, session.ParsedBackendOcsUrl(), request, &response); err != nil { sess.Close() - log.Printf("Could not join virtual session %s at backend %s: %s", virtualSessionId, session.BackendUrl(), err) - reply := message.NewErrorServerMessage(NewError("add_failed", "Could not join virtual session.")) + h.logger.Printf("Could not join virtual session %s at backend %s: %s", virtualSessionId, session.BackendUrl(), err) + reply := message.NewErrorServerMessage(api.NewError("add_failed", "Could not join virtual session.")) session.SendMessage(reply) return } if response.Type == "error" { sess.Close() - log.Printf("Could not join virtual session %s at backend %s: %+v", virtualSessionId, session.BackendUrl(), response.Error) - reply := message.NewErrorServerMessage(NewError("add_failed", response.Error.Error())) + h.logger.Printf("Could not join virtual session %s at backend %s: %+v", virtualSessionId, session.BackendUrl(), response.Error) + reply := message.NewErrorServerMessage(api.NewError("add_failed", response.Error.Error())) session.SendMessage(reply) return } } else { - request := NewBackendClientSessionRequest(room.Id(), "add", publicSessionId, msg) - var response BackendClientSessionResponse - if err := h.backend.PerformJSONRequest(ctx, session.ParsedBackendUrl(), request, &response); err != nil { + request := talk.NewBackendClientSessionRequest(room.Id(), "add", publicSessionId, msg) + var response talk.BackendClientSessionResponse + if err := h.backend.PerformJSONRequest(ctx, session.ParsedBackendOcsUrl(), request, &response); err != nil { sess.Close() - log.Printf("Could not add virtual session %s at backend %s: %s", virtualSessionId, session.BackendUrl(), err) - reply := message.NewErrorServerMessage(NewError("add_failed", "Could not add virtual session.")) + h.logger.Printf("Could not add virtual session %s at backend %s: %s", virtualSessionId, session.BackendUrl(), err) + reply := message.NewErrorServerMessage(api.NewError("add_failed", "Could not add virtual session.")) session.SendMessage(reply) return } @@ -2359,17 +2649,17 @@ func (h *Hub) processInternalMsg(sess Session, message *ClientMessage) { h.sessions[sessionIdData.Sid] = sess h.virtualSessions[virtualSessionId] = sessionIdData.Sid h.mu.Unlock() - statsHubSessionsCurrent.WithLabelValues(session.Backend().Id(), sess.ClientType()).Inc() - statsHubSessionsTotal.WithLabelValues(session.Backend().Id(), sess.ClientType()).Inc() - log.Printf("Session %s added virtual session %s with initial flags %d", session.PublicId(), sess.PublicId(), sess.Flags()) + statsHubSessionsCurrent.WithLabelValues(session.Backend().Id(), string(sess.ClientType())).Inc() + statsHubSessionsTotal.WithLabelValues(session.Backend().Id(), string(sess.ClientType())).Inc() + h.logger.Printf("Session %s added virtual session %s with initial flags %d", session.PublicId(), sess.PublicId(), sess.Flags()) session.AddVirtualSession(sess) - sess.SetRoom(room) + sess.SetRoom(room, time.Now()) room.AddSession(sess, nil) case "updatesession": msg := msg.UpdateSession room := h.GetRoomForBackend(msg.RoomId, session.Backend()) if room == nil { - log.Printf("Ignore remove session message %+v for invalid room %s from %s", *msg, msg.RoomId, session.PublicId()) + h.logger.Printf("Ignore remove session message %+v for invalid room %s from %s", *msg, msg.RoomId, session.PublicId()) return } @@ -2397,7 +2687,7 @@ func (h *Hub) processInternalMsg(sess Session, message *ClientMessage) { } } } else { - log.Printf("Ignore update request for non-virtual session %s", sess.PublicId()) + h.logger.Printf("Ignore update request for non-virtual session %s", sess.PublicId()) } if changed != 0 { room.NotifySessionChanged(sess, changed) @@ -2407,7 +2697,7 @@ func (h *Hub) processInternalMsg(sess Session, message *ClientMessage) { msg := msg.RemoveSession room := h.GetRoomForBackend(msg.RoomId, session.Backend()) if room == nil { - log.Printf("Ignore remove session message %+v for invalid room %s from %s", *msg, msg.RoomId, session.PublicId()) + h.logger.Printf("Ignore remove session message %+v for invalid room %s from %s", *msg, msg.RoomId, session.PublicId()) return } @@ -2423,7 +2713,7 @@ func (h *Hub) processInternalMsg(sess Session, message *ClientMessage) { sess := h.sessions[sid] h.mu.Unlock() if sess != nil { - log.Printf("Session %s removed virtual session %s", session.PublicId(), sess.PublicId()) + h.logger.Printf("Session %s removed virtual session %s", session.PublicId(), sess.PublicId()) if vsess, ok := sess.(*VirtualSession); ok { // We should always have a VirtualSession here. vsess.CloseWithFeedback(session, message) @@ -2442,57 +2732,71 @@ func (h *Hub) processInternalMsg(sess Session, message *ClientMessage) { roomId := msg.Dialout.RoomId msg.Dialout.RoomId = "" // Don't send room id to recipients. if msg.Dialout.Type == "status" { - asyncMessage := &AsyncMessage{ + asyncMessage := &events.AsyncMessage{ Type: "room", - Room: &BackendServerRoomRequest{ + Room: &talk.BackendServerRoomRequest{ Type: "transient", - Transient: &BackendRoomTransientRequest{ - Action: TransientActionSet, + Transient: &talk.BackendRoomTransientRequest{ + Action: talk.TransientActionSet, Key: "callstatus_" + msg.Dialout.Status.CallId, Value: msg.Dialout.Status, }, }, } - if msg.Dialout.Status.Status == DialoutStatusCleared || msg.Dialout.Status.Status == DialoutStatusRejected { + if msg.Dialout.Status.Status == api.DialoutStatusCleared || msg.Dialout.Status.Status == api.DialoutStatusRejected { asyncMessage.Room.Transient.TTL = removeCallStatusTTL } if err := h.events.PublishBackendRoomMessage(roomId, session.Backend(), asyncMessage); err != nil { - log.Printf("Error publishing dialout message %+v to room %s", msg.Dialout, roomId) + h.logger.Printf("Error publishing dialout message %+v to room %s", msg.Dialout, roomId) } } else { - if err := h.events.PublishRoomMessage(roomId, session.Backend(), &AsyncMessage{ + if err := h.events.PublishRoomMessage(roomId, session.Backend(), &events.AsyncMessage{ Type: "message", - Message: &ServerMessage{ + Message: &api.ServerMessage{ Type: "dialout", Dialout: msg.Dialout, }, }); err != nil { - log.Printf("Error publishing dialout message %+v to room %s", msg.Dialout, roomId) + h.logger.Printf("Error publishing dialout message %+v to room %s", msg.Dialout, roomId) } } default: - log.Printf("Ignore unsupported internal message %+v from %s", msg, session.PublicId()) + h.logger.Printf("Ignore unsupported internal message %+v from %s", msg, session.PublicId()) return } } func isAllowedToUpdateTransientData(session Session) bool { - if session.ClientType() == HelloClientTypeInternal { + if session.ClientType() == api.HelloClientTypeInternal { // Internal clients are always allowed. return true } - if session.HasPermission(PERMISSION_TRANSIENT_DATA) { + if session.HasPermission(api.PERMISSION_TRANSIENT_DATA) { return true } return false } -func (h *Hub) processTransientMsg(session Session, message *ClientMessage) { +func isAllowedToUpdateTransientDataKey(session Session, key string) bool { + if session.ClientType() == api.HelloClientTypeInternal { + // Internal clients may update all transient keys. + return true + } + + if sid, found := strings.CutPrefix(key, api.TransientSessionDataPrefix); found { + // Session data may only be modified by the session itself. + return sid == string(session.PublicId()) + } + + return true +} + +func (h *Hub) processTransientMsg(session Session, message *api.ClientMessage) { room := session.GetRoom() if room == nil { - response := message.NewErrorServerMessage(NewError("not_in_room", "No room joined yet.")) + response := message.NewErrorServerMessage(api.NewError("not_in_room", "No room joined yet.")) session.SendMessage(response) return } @@ -2503,42 +2807,52 @@ func (h *Hub) processTransientMsg(session Session, message *ClientMessage) { if !isAllowedToUpdateTransientData(session) { sendNotAllowed(session, message, "Not allowed to update transient data.") return + } else if !isAllowedToUpdateTransientDataKey(session, msg.Key) { + sendNotAllowed(session, message, "Not allowed to update this transient data entry.") + return } - if msg.Value == nil { - room.SetTransientDataTTL(msg.Key, nil, msg.TTL) - } else { - room.SetTransientDataTTL(msg.Key, msg.Value, msg.TTL) + if err := room.SetTransientDataTTL(msg.Key, msg.Value, msg.TTL); err != nil { + response := message.NewWrappedErrorServerMessage(err) + session.SendMessage(response) + return } case "remove": if !isAllowedToUpdateTransientData(session) { sendNotAllowed(session, message, "Not allowed to update transient data.") return + } else if !isAllowedToUpdateTransientDataKey(session, msg.Key) { + sendNotAllowed(session, message, "Not allowed to update this transient data entry.") + return } - room.RemoveTransientData(msg.Key) + if err := room.RemoveTransientData(msg.Key); err != nil { + response := message.NewWrappedErrorServerMessage(err) + session.SendMessage(response) + return + } default: - response := message.NewErrorServerMessage(NewError("ignored", "Unsupported message type.")) + response := message.NewErrorServerMessage(api.NewError("ignored", "Unsupported message type.")) session.SendMessage(response) } } -func sendNotAllowed(session Session, message *ClientMessage, reason string) { - response := message.NewErrorServerMessage(NewError("not_allowed", reason)) +func sendNotAllowed(session Session, message *api.ClientMessage, reason string) { + response := message.NewErrorServerMessage(api.NewError("not_allowed", reason)) session.SendMessage(response) } -func sendMcuClientNotFound(session Session, message *ClientMessage) { - response := message.NewErrorServerMessage(NewError("client_not_found", "No MCU client found to send message to.")) +func sendMcuClientNotFound(session Session, message *api.ClientMessage) { + response := message.NewErrorServerMessage(api.NewError("client_not_found", "No MCU client found to send message to.")) session.SendMessage(response) } -func sendMcuProcessingFailed(session Session, message *ClientMessage) { - response := message.NewErrorServerMessage(NewError("processing_failed", "Processing of the message failed, please check server logs.")) +func sendMcuProcessingFailed(session Session, message *api.ClientMessage) { + response := message.NewErrorServerMessage(api.NewError("processing_failed", "Processing of the message failed, please check server logs.")) session.SendMessage(response) } -func (h *Hub) isInSameCallRemote(ctx context.Context, senderSession *ClientSession, senderRoom *Room, recipientSessionId string) bool { +func (h *Hub) isInSameCallRemote(ctx context.Context, senderSession *ClientSession, senderRoom *Room, recipientSessionId api.PublicSessionId) bool { clients := h.rpcClients.GetClients() if len(clients) == 0 { return false @@ -2549,15 +2863,12 @@ func (h *Hub) isInSameCallRemote(ctx context.Context, senderSession *ClientSessi rpcCtx, cancel := context.WithCancel(ctx) defer cancel() for _, client := range clients { - wg.Add(1) - go func(client *GrpcClient) { - defer wg.Done() - - inCall, err := client.IsSessionInCall(rpcCtx, recipientSessionId, senderRoom) + wg.Go(func() { + inCall, err := client.IsSessionInCall(rpcCtx, recipientSessionId, senderRoom.Id(), senderSession.BackendUrl()) if errors.Is(err, context.Canceled) { return } else if err != nil { - log.Printf("Error checking session %s in call on %s: %s", recipientSessionId, client.Target(), err) + h.logger.Printf("Error checking session %s in call on %s: %s", recipientSessionId, client.Target(), err) return } else if !inCall { return @@ -2565,15 +2876,15 @@ func (h *Hub) isInSameCallRemote(ctx context.Context, senderSession *ClientSessi cancel() result.Store(true) - }(client) + }) } wg.Wait() return result.Load() } -func (h *Hub) isInSameCall(ctx context.Context, senderSession *ClientSession, recipientSessionId string) bool { - if senderSession.ClientType() == HelloClientTypeInternal { +func (h *Hub) isInSameCall(ctx context.Context, senderSession *ClientSession, recipientSessionId api.PublicSessionId) bool { + if senderSession.ClientType() == api.HelloClientTypeInternal { // Internal clients may subscribe all streams. return true } @@ -2592,7 +2903,7 @@ func (h *Hub) isInSameCall(ctx context.Context, senderSession *ClientSession, re recipientRoom := recipientSession.GetRoom() if recipientRoom == nil || !senderRoom.IsEqual(recipientRoom) || - (recipientSession.ClientType() != HelloClientTypeInternal && !recipientRoom.IsSessionInCall(recipientSession)) { + (recipientSession.ClientType() != api.HelloClientTypeInternal && !recipientRoom.IsSessionInCall(recipientSession)) { // Recipient is not in a room, a different room or not in the call. return false } @@ -2600,80 +2911,87 @@ func (h *Hub) isInSameCall(ctx context.Context, senderSession *ClientSession, re return true } -func (h *Hub) processMcuMessage(session *ClientSession, client_message *ClientMessage, message *MessageClientMessage, data *MessageClientMessageData) { +func (h *Hub) processMcuMessage(session *ClientSession, client_message *api.ClientMessage, message *api.MessageClientMessage, data *api.MessageClientMessageData) { ctx, cancel := context.WithTimeout(session.Context(), h.mcuTimeout) defer cancel() - var mc McuClient + var mc sfu.Client var err error var clientType string switch data.Type { case "requestoffer": if session.PublicId() == message.Recipient.SessionId { - log.Printf("Not requesting offer from itself for session %s", session.PublicId()) + h.logger.Printf("Not requesting offer from itself for session %s", session.PublicId()) return } // A user is only allowed to subscribe a stream if she is in the same room // as the other user and both have their "inCall" flag set. if !h.allowSubscribeAnyStream && !h.isInSameCall(ctx, session, message.Recipient.SessionId) { - log.Printf("Session %s is not in the same call as session %s, not requesting offer", session.PublicId(), message.Recipient.SessionId) + h.logger.Printf("Session %s is not in the same call as session %s, not requesting offer", session.PublicId(), message.Recipient.SessionId) sendNotAllowed(session, client_message, "Not allowed to request offer.") return } clientType = "subscriber" - mc, err = session.GetOrCreateSubscriber(ctx, h.mcu, message.Recipient.SessionId, StreamType(data.RoomType)) + mc, err = session.GetOrCreateSubscriber(ctx, h.mcu, message.Recipient.SessionId, sfu.StreamType(data.RoomType)) case "sendoffer": // Will be sent directly. return case "offer": clientType = "publisher" - mc, err = session.GetOrCreatePublisher(ctx, h.mcu, StreamType(data.RoomType), data) + mc, err = session.GetOrCreatePublisher(ctx, h.mcu, sfu.StreamType(data.RoomType), data) if err, ok := err.(*PermissionError); ok { - log.Printf("Session %s is not allowed to offer %s, ignoring (%s)", session.PublicId(), data.RoomType, err) + h.logger.Printf("Session %s is not allowed to offer %s, ignoring (%s)", session.PublicId(), data.RoomType, err) sendNotAllowed(session, client_message, "Not allowed to publish.") return } case "selectStream": if session.PublicId() == message.Recipient.SessionId { - log.Printf("Not selecting substream for own %s stream in session %s", data.RoomType, session.PublicId()) + h.logger.Printf("Not selecting substream for own %s stream in session %s", data.RoomType, session.PublicId()) return } clientType = "subscriber" - mc = session.GetSubscriber(message.Recipient.SessionId, StreamType(data.RoomType)) + mc = session.GetSubscriber(message.Recipient.SessionId, sfu.StreamType(data.RoomType)) default: + if data.Type == "candidate" && api.FilterCandidate(data.Candidate, h.allowedCandidates.Load(), h.blockedCandidates.Load()) { + // Silently ignore filtered candidates. + return + } + if session.PublicId() == message.Recipient.SessionId { if err := session.IsAllowedToSend(data); err != nil { - log.Printf("Session %s is not allowed to send candidate for %s, ignoring (%s)", session.PublicId(), data.RoomType, err) + h.logger.Printf("Session %s is not allowed to send candidate for %s, ignoring (%s)", session.PublicId(), data.RoomType, err) sendNotAllowed(session, client_message, "Not allowed to send candidate.") return } clientType = "publisher" - mc = session.GetPublisher(StreamType(data.RoomType)) + mc = session.GetPublisher(sfu.StreamType(data.RoomType)) } else { clientType = "subscriber" - mc = session.GetSubscriber(message.Recipient.SessionId, StreamType(data.RoomType)) + mc = session.GetSubscriber(message.Recipient.SessionId, sfu.StreamType(data.RoomType)) } } if err != nil { - log.Printf("Could not create MCU %s for session %s to send %+v to %s: %s", clientType, session.PublicId(), data, message.Recipient.SessionId, err) + h.logger.Printf("Could not create MCU %s for session %s to send %+v to %s: %s", clientType, session.PublicId(), data, message.Recipient.SessionId, err) sendMcuClientNotFound(session, client_message) return } else if mc == nil { - log.Printf("No MCU %s found for session %s to send %+v to %s", clientType, session.PublicId(), data, message.Recipient.SessionId) + h.logger.Printf("No MCU %s found for session %s to send %+v to %s", clientType, session.PublicId(), data, message.Recipient.SessionId) sendMcuClientNotFound(session, client_message) return } - mc.SendMessage(session.Context(), message, data, func(err error, response map[string]interface{}) { + mc.SendMessage(session.Context(), message, data, func(err error, response api.StringMap) { if err != nil { - log.Printf("Could not send MCU message %+v for session %s to %s: %s", data, session.PublicId(), message.Recipient.SessionId, err) - sendMcuProcessingFailed(session, client_message) + if !errors.Is(err, api.ErrCandidateFiltered) { + h.logger.Printf("Could not send MCU message %+v for session %s to %s: %s", data, session.PublicId(), message.Recipient.SessionId, err) + sendMcuProcessingFailed(session, client_message) + } return - } else if response == nil { + } else if len(response) == 0 { // No response received return } @@ -2682,11 +3000,11 @@ func (h *Hub) processMcuMessage(session *ClientSession, client_message *ClientMe }) } -func (h *Hub) sendMcuMessageResponse(session *ClientSession, mcuClient McuClient, message *MessageClientMessage, data *MessageClientMessageData, response map[string]interface{}) { - var response_message *ServerMessage +func (h *Hub) sendMcuMessageResponse(session *ClientSession, mcuClient sfu.Client, message *api.MessageClientMessage, data *api.MessageClientMessageData, response api.StringMap) { + var response_message *api.ServerMessage switch response["type"] { case "answer": - answer_message := &AnswerOfferMessage{ + answer_message := &api.AnswerOfferMessage{ To: session.PublicId(), From: session.PublicId(), Type: "answer", @@ -2696,13 +3014,13 @@ func (h *Hub) sendMcuMessageResponse(session *ClientSession, mcuClient McuClient } answer_data, err := json.Marshal(answer_message) if err != nil { - log.Printf("Could not serialize answer %+v to %s: %s", answer_message, session.PublicId(), err) + h.logger.Printf("Could not serialize answer %+v to %s: %s", answer_message, session.PublicId(), err) return } - response_message = &ServerMessage{ + response_message = &api.ServerMessage{ Type: "message", - Message: &MessageServerMessage{ - Sender: &MessageServerMessageSender{ + Message: &api.MessageServerMessage{ + Sender: &api.MessageServerMessageSender{ Type: "session", SessionId: session.PublicId(), UserId: session.UserId(), @@ -2711,7 +3029,7 @@ func (h *Hub) sendMcuMessageResponse(session *ClientSession, mcuClient McuClient }, } case "offer": - offer_message := &AnswerOfferMessage{ + offer_message := &api.AnswerOfferMessage{ To: session.PublicId(), From: message.Recipient.SessionId, Type: "offer", @@ -2721,13 +3039,13 @@ func (h *Hub) sendMcuMessageResponse(session *ClientSession, mcuClient McuClient } offer_data, err := json.Marshal(offer_message) if err != nil { - log.Printf("Could not serialize offer %+v to %s: %s", offer_message, session.PublicId(), err) + h.logger.Printf("Could not serialize offer %+v to %s: %s", offer_message, session.PublicId(), err) return } - response_message = &ServerMessage{ + response_message = &api.ServerMessage{ Type: "message", - Message: &MessageServerMessage{ - Sender: &MessageServerMessageSender{ + Message: &api.MessageServerMessage{ + Sender: &api.MessageServerMessageSender{ Type: "session", SessionId: message.Recipient.SessionId, // TODO(jojo): Set "UserId" field if known user. @@ -2736,27 +3054,35 @@ func (h *Hub) sendMcuMessageResponse(session *ClientSession, mcuClient McuClient }, } default: - log.Printf("Unsupported response %+v received to send to %s", response, session.PublicId()) + h.logger.Printf("Unsupported response %+v received to send to %s", response, session.PublicId()) return } session.SendMessage(response_message) } -func (h *Hub) processByeMsg(client HandlerClient, message *ClientMessage) { +func (h *Hub) processByeMsg(client ClientWithSession, message *api.ClientMessage) { client.SendByeResponse(message) if session := h.processUnregister(client); session != nil { session.Close() } } -func (h *Hub) processRoomUpdated(message *BackendServerRoomRequest) { - room := message.room +func (h *Hub) processRoomUpdated(message *talk.BackendServerRoomRequest) { + room := h.GetRoomForBackend(message.RoomId, message.Backend) + if room == nil { + return + } + room.UpdateProperties(message.Update.Properties) } -func (h *Hub) processRoomDeleted(message *BackendServerRoomRequest) { - room := message.room +func (h *Hub) processRoomDeleted(message *talk.BackendServerRoomRequest) { + room := h.GetRoomForBackend(message.RoomId, message.Backend) + if room == nil { + return + } + sessions := room.Close() for _, session := range sessions { // The session is no longer in the room @@ -2770,14 +3096,18 @@ func (h *Hub) processRoomDeleted(message *BackendServerRoomRequest) { } } -func (h *Hub) processRoomInCallChanged(message *BackendServerRoomRequest) { - room := message.room +func (h *Hub) processRoomInCallChanged(message *talk.BackendServerRoomRequest) { + room := h.GetRoomForBackend(message.RoomId, message.Backend) + if room == nil { + return + } + if message.InCall.All { var flags int if err := json.Unmarshal(message.InCall.InCall, &flags); err != nil { var incall bool if err := json.Unmarshal(message.InCall.InCall, &incall); err != nil { - log.Printf("Unsupported InCall flags type: %+v, ignoring", string(message.InCall.InCall)) + h.logger.Printf("Unsupported InCall flags type: %+v, ignoring", string(message.InCall.InCall)) return } @@ -2792,13 +3122,17 @@ func (h *Hub) processRoomInCallChanged(message *BackendServerRoomRequest) { } } -func (h *Hub) processRoomParticipants(message *BackendServerRoomRequest) { - room := message.room +func (h *Hub) processRoomParticipants(message *talk.BackendServerRoomRequest) { + room := h.GetRoomForBackend(message.RoomId, message.Backend) + if room == nil { + return + } + room.PublishUsersChanged(message.Participants.Changed, message.Participants.Users) } -func (h *Hub) GetStats() map[string]interface{} { - result := make(map[string]interface{}) +func (h *Hub) GetStats() api.StringMap { + result := make(api.StringMap) h.ru.RLock() result["rooms"] = len(h.rooms) h.ru.RUnlock() @@ -2813,66 +3147,41 @@ func (h *Hub) GetStats() map[string]interface{} { return result } -func GetRealUserIP(r *http.Request, trusted *AllowedIps) string { - addr := r.RemoteAddr - if host, _, err := net.SplitHostPort(addr); err == nil { - addr = host - } +func (h *Hub) GetServerInfoDialout() (result []talk.BackendServerInfoDialout) { + h.mu.RLock() + defer h.mu.RUnlock() - ip := net.ParseIP(addr) - if len(ip) == 0 { - return addr - } - - // Don't check any headers if the server can be reached by untrusted clients directly. - if trusted == nil || !trusted.Allowed(ip) { - return addr - } - - if realIP := r.Header.Get("X-Real-IP"); realIP != "" { - if ip := net.ParseIP(realIP); len(ip) > 0 { - return realIP + for session := range h.dialoutSessions { + dialout := talk.BackendServerInfoDialout{ + SessionId: session.PublicId(), } + if client := session.GetClient(); client != nil && client.IsConnected() { + dialout.Connected = true + dialout.Address = client.RemoteAddr() + if ua := client.UserAgent(); ua != "" { + dialout.UserAgent = ua + // Extract version from user-agent, expects "software/version". + if pos := strings.IndexByte(ua, '/'); pos != -1 { + version := ua[pos+1:] + if pos = strings.IndexByte(version, ' '); pos != -1 { + version = version[:pos] + } + dialout.Version = version + } + } + dialout.Features = session.GetFeatures() + } + result = append(result, dialout) } - // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#selecting_an_ip_address - forwarded := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",") - if len(forwarded) > 0 { - slices.Reverse(forwarded) - var lastTrusted string - for _, hop := range forwarded { - hop = strings.TrimSpace(hop) - // Make sure to remove any port. - if host, _, err := net.SplitHostPort(hop); err == nil { - hop = host - } - - ip := net.ParseIP(hop) - if len(ip) == 0 { - continue - } - - if trusted.Allowed(ip) { - lastTrusted = hop - continue - } - - return hop - } - - // If all entries in the "X-Forwarded-For" list are trusted, the left-most - // will be the client IP. This can happen if a subnet is trusted and the - // client also has an IP from this subnet. - if lastTrusted != "" { - return lastTrusted - } - } - - return addr + slices.SortFunc(result, func(a, b talk.BackendServerInfoDialout) int { + return strings.Compare(string(a.SessionId), string(b.SessionId)) + }) + return } func (h *Hub) getRealUserIP(r *http.Request) string { - return GetRealUserIP(r, h.trustedProxies.Load()) + return client.GetRealUserIP(r, h.trustedProxies.Load()) } func (h *Hub) serveWs(w http.ResponseWriter, r *http.Request) { @@ -2885,13 +3194,19 @@ func (h *Hub) serveWs(w http.ResponseWriter, r *http.Request) { conn, err := h.upgrader.Upgrade(w, r, header) if err != nil { - log.Printf("Could not upgrade request from %s: %s", addr, err) + h.logger.Printf("Could not upgrade request from %s: %s", addr, err) return } - client, err := NewClient(r.Context(), conn, addr, agent, h) + ctx := log.NewLoggerContext(r.Context(), h.logger) + if conn.Subprotocol() == janus.EventsSubprotocol { + janus.RunEventsHandler(ctx, h.mcu, conn, addr, agent) + return + } + + client, err := NewHubClient(ctx, conn, addr, agent, h) if err != nil { - log.Printf("Could not create client for %s: %s", addr, err) + h.logger.Printf("Could not create client for %s: %s", addr, err) return } @@ -2907,52 +3222,48 @@ func (h *Hub) serveWs(w http.ResponseWriter, r *http.Request) { client.ReadPump() } -func (h *Hub) OnLookupCountry(client HandlerClient) string { - ip := net.ParseIP(client.RemoteAddr()) - if ip == nil { - return noCountry +func (h *Hub) ProxySession(request grpc.RpcSessions_ProxySessionServer) error { + client, err := newRemoteGrpcClient(h, request) + if err != nil { + return err } - if overrides := h.geoipOverrides.Load(); overrides != nil { - for overrideNet, country := range *overrides { - if overrideNet.Contains(ip) { - return country - } - } + sid := h.registerClient(client) + defer h.unregisterClient(sid) + + return client.run() +} + +func (h *Hub) LookupCountry(addr string) geoip.Country { + ip := net.ParseIP(addr) + if ip == nil { + return geoip.NoCountry + } + + if country, found := h.geoipOverrides.Load().Lookup(ip); found { + return country } if ip.IsLoopback() { - return loopback + return geoip.Loopback } - country := unknownCountry + country := geoip.UnknownCountry if h.geoip != nil { var err error country, err = h.geoip.LookupCountry(ip) if err != nil { - log.Printf("Could not lookup country for %s: %s", ip, err) - return unknownCountry + h.logger.Printf("Could not lookup country for %s: %s", ip, err) + return geoip.UnknownCountry } if country == "" { - country = unknownCountry + country = geoip.UnknownCountry } } return country } -func (h *Hub) OnClosed(client HandlerClient) { - h.processUnregister(client) -} - -func (h *Hub) OnMessageReceived(client HandlerClient, data []byte) { - h.processMessage(client, data) -} - -func (h *Hub) OnRTTReceived(client HandlerClient, rtt time.Duration) { - // Ignore -} - func (h *Hub) ShutdownChannel() <-chan struct{} { return h.shutdown.C } diff --git a/server/hub_client.go b/server/hub_client.go new file mode 100644 index 0000000..3a7b973 --- /dev/null +++ b/server/hub_client.go @@ -0,0 +1,128 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package server + +import ( + "context" + "strings" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/client" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" +) + +var ( + InvalidFormat = client.InvalidFormat +) + +func init() { + RegisterClientStats() +} + +type HubClient struct { + client.Client + + hub *Hub + session atomic.Pointer[Session] +} + +func NewHubClient(ctx context.Context, conn *websocket.Conn, remoteAddress string, agent string, hub *Hub) (*HubClient, error) { + remoteAddress = strings.TrimSpace(remoteAddress) + if remoteAddress == "" { + remoteAddress = "unknown remote address" + } + agent = strings.TrimSpace(agent) + if agent == "" { + agent = "unknown user agent" + } + + client := &HubClient{ + hub: hub, + } + client.SetConn(ctx, conn, remoteAddress, agent, true, client) + return client, nil +} + +func (c *HubClient) OnLookupCountry(addr string) geoip.Country { + return c.hub.LookupCountry(addr) +} + +func (c *HubClient) OnClosed() { + c.hub.processUnregister(c) +} + +func (c *HubClient) OnMessageReceived(data []byte) { + c.hub.processMessage(c, data) +} + +func (c *HubClient) OnRTTReceived(rtt time.Duration) { + // Ignore +} + +func (c *HubClient) CloseSession() { + if session := c.GetSession(); session != nil { + session.Close() + } +} + +func (c *HubClient) IsAuthenticated() bool { + return c.GetSession() != nil +} + +func (c *HubClient) GetSession() Session { + session := c.session.Load() + if session == nil { + return nil + } + + return *session +} + +func (c *HubClient) SetSession(session Session) { + if session == nil { + c.session.Store(nil) + } else { + c.session.Store(&session) + } +} + +func (c *HubClient) GetSessionId() api.PublicSessionId { + session := c.GetSession() + if session == nil { + return "" + } + + return session.PublicId() +} + +func (c *HubClient) IsInRoom(id string) bool { + session := c.GetSession() + if session == nil { + return false + } + + return session.IsInRoom(id) +} diff --git a/client_stats_prometheus.go b/server/hub_client_stats_prometheus.go similarity index 91% rename from client_stats_prometheus.go rename to server/hub_client_stats_prometheus.go index e20447e..518548c 100644 --- a/client_stats_prometheus.go +++ b/server/hub_client_stats_prometheus.go @@ -19,10 +19,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" ) var ( @@ -39,5 +41,5 @@ var ( ) func RegisterClientStats() { - registerAll(clientStats...) + metrics.RegisterAll(clientStats...) } diff --git a/hub_stats_prometheus.go b/server/hub_stats_prometheus.go similarity index 92% rename from hub_stats_prometheus.go rename to server/hub_stats_prometheus.go index f3d5c1a..9082dd9 100644 --- a/hub_stats_prometheus.go +++ b/server/hub_stats_prometheus.go @@ -19,14 +19,16 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" ) var ( - statsHubRoomsCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + statsHubRoomsCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ // +checklocksignore: Global readonly variable. Namespace: "signaling", Subsystem: "hub", Name: "rooms", @@ -61,10 +63,11 @@ var ( statsHubRoomsCurrent, statsHubSessionsCurrent, statsHubSessionsTotal, + statsHubSessionsResumedTotal, statsHubSessionResumeFailed, } ) func RegisterHubStats() { - registerAll(hubStats...) + metrics.RegisterAll(hubStats...) } diff --git a/hub_test.go b/server/hub_test.go similarity index 58% rename from hub_test.go rename to server/hub_test.go index 0d4bf48..ed57b89 100644 --- a/hub_test.go +++ b/server/hub_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" @@ -33,6 +33,7 @@ import ( "encoding/json" "encoding/pem" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -47,8 +48,27 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" "github.com/gorilla/websocket" + "github.com/nats-io/nats-server/v2/server" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + eventstest "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/container" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + grpctest "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/mock" + natstest "github.com/strukturag/nextcloud-spreed-signaling/v2/nats/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/session" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + sfutest "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" ) const ( @@ -130,7 +150,24 @@ func getTestConfigWithMultipleBackends(server *httptest.Server) (*goconf.ConfigF return config, nil } -func CreateHubForTestWithConfig(t *testing.T, getConfigFunc func(*httptest.Server) (*goconf.ConfigFile, error)) (*Hub, AsyncEvents, *mux.Router, *httptest.Server) { +func getTestConfigWithMultipleUrls(server *httptest.Server) (*goconf.ConfigFile, error) { + config, err := getTestConfig(server) + if err != nil { + return nil, err + } + + config.RemoveOption("backend", "allowed") + config.RemoveOption("backend", "secret") + config.AddOption("backend", "backends", "backend1") + + config.AddOption("backend1", "urls", strings.Join([]string{server.URL + "/one", server.URL + "/two/"}, ",")) + config.AddOption("backend1", "secret", string(testBackendSecret)) + return config, nil +} + +func CreateHubForTestWithConfig(t *testing.T, getConfigFunc func(*httptest.Server) (*goconf.ConfigFile, error)) (*Hub, events.AsyncEvents, *mux.Router, *httptest.Server) { + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) r := mux.NewRouter() registerBackendHandler(t, r) @@ -140,12 +177,12 @@ func CreateHubForTestWithConfig(t *testing.T, getConfigFunc func(*httptest.Serve server.Close() }) - events := getAsyncEventsForTest(t) + events := eventstest.GetAsyncEventsForTest(t) config, err := getConfigFunc(server) require.NoError(err) - h, err := NewHub(config, events, nil, nil, nil, r, "no-version") + h, err := NewHub(ctx, config, events, nil, nil, nil, r, "no-version") require.NoError(err) - b, err := NewBackendServer(config, h, "no-version") + b, err := NewBackendServer(ctx, config, h, "no-version") require.NoError(err) require.NoError(b.Start(r)) @@ -161,19 +198,29 @@ func CreateHubForTestWithConfig(t *testing.T, getConfigFunc func(*httptest.Serve return h, events, r, server } -func CreateHubForTest(t *testing.T) (*Hub, AsyncEvents, *mux.Router, *httptest.Server) { +func CreateHubForTest(t *testing.T) (*Hub, events.AsyncEvents, *mux.Router, *httptest.Server) { return CreateHubForTestWithConfig(t, getTestConfig) } -func CreateHubWithMultipleBackendsForTest(t *testing.T) (*Hub, AsyncEvents, *mux.Router, *httptest.Server) { +func CreateHubWithMultipleBackendsForTest(t *testing.T) (*Hub, events.AsyncEvents, *mux.Router, *httptest.Server) { h, events, r, server := CreateHubForTestWithConfig(t, getTestConfigWithMultipleBackends) registerBackendHandlerUrl(t, r, "/one") registerBackendHandlerUrl(t, r, "/two") return h, events, r, server } +func CreateHubWithMultipleUrlsForTest(t *testing.T) (*Hub, events.AsyncEvents, *mux.Router, *httptest.Server) { + h, events, r, server := CreateHubForTestWithConfig(t, getTestConfigWithMultipleUrls) + registerBackendHandlerUrl(t, r, "/one") + registerBackendHandlerUrl(t, r, "/two") + return h, events, r, server +} + func CreateClusteredHubsForTestWithConfig(t *testing.T, getConfigFunc func(*httptest.Server) (*goconf.ConfigFile, error)) (*Hub, *Hub, *mux.Router, *mux.Router, *httptest.Server, *httptest.Server) { + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) + assert := assert.New(t) r1 := mux.NewRouter() registerBackendHandler(t, r1) @@ -190,44 +237,48 @@ func CreateClusteredHubsForTestWithConfig(t *testing.T, getConfigFunc func(*http server2.Close() }) - nats1 := startLocalNatsServer(t) - var nats2 string + nats1, _ := natstest.StartLocalServer(t) + var nats2 *server.Server if strings.Contains(t.Name(), "Federation") { - nats2 = startLocalNatsServer(t) + nats2, _ = natstest.StartLocalServer(t) } else { nats2 = nats1 } - grpcServer1, addr1 := NewGrpcServerForTest(t) - grpcServer2, addr2 := NewGrpcServerForTest(t) + grpcServer1, addr1 := grpctest.NewServerForTest(t) + grpcServer2, addr2 := grpctest.NewServerForTest(t) if strings.Contains(t.Name(), "Federation") { // Signaling servers should not form a cluster in federation tests. addr1, addr2 = addr2, addr1 } - events1, err := NewAsyncEvents(nats1) + events1, err := events.NewAsyncEvents(ctx, nats1.ClientURL()) require.NoError(err) t.Cleanup(func() { - events1.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + assert.NoError(events1.Close(ctx)) }) config1, err := getConfigFunc(server1) require.NoError(err) - client1, _ := NewGrpcClientsForTest(t, addr2) - h1, err := NewHub(config1, events1, grpcServer1, client1, nil, r1, "no-version") + client1, _ := grpctest.NewClientsForTest(t, addr2, nil) + h1, err := NewHub(ctx, config1, events1, grpcServer1, client1, nil, r1, "no-version") require.NoError(err) - b1, err := NewBackendServer(config1, h1, "no-version") + b1, err := NewBackendServer(ctx, config1, h1, "no-version") require.NoError(err) - events2, err := NewAsyncEvents(nats2) + events2, err := events.NewAsyncEvents(ctx, nats2.ClientURL()) require.NoError(err) t.Cleanup(func() { - events2.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + assert.NoError(events2.Close(ctx)) }) config2, err := getConfigFunc(server2) require.NoError(err) - client2, _ := NewGrpcClientsForTest(t, addr1) - h2, err := NewHub(config2, events2, grpcServer2, client2, nil, r2, "no-version") + client2, _ := grpctest.NewClientsForTest(t, addr1, nil) + h2, err := NewHub(ctx, config2, events2, grpcServer2, client2, nil, r2, "no-version") require.NoError(err) - b2, err := NewBackendServer(config2, h2, "no-version") + b2, err := NewBackendServer(ctx, config2, h2, "no-version") require.NoError(err) require.NoError(b1.Start(r1)) require.NoError(b2.Start(r2)) @@ -260,6 +311,8 @@ func WaitForHub(ctx context.Context, t *testing.T, h *Hub) { clients := len(h.clients) sessions := len(h.sessions) remoteSessions := len(h.remoteSessions) + federatedSessions := len(h.federatedSessions) + federationClients := len(h.federatedSessions) h.mu.Unlock() h.ru.Lock() rooms := len(h.rooms) @@ -270,6 +323,8 @@ func WaitForHub(ctx context.Context, t *testing.T, h *Hub) { rooms == 0 && sessions == 0 && remoteSessions == 0 && + federatedSessions == 0 && + federationClients == 0 && readActive == 0 && writeActive == 0 { break @@ -279,8 +334,18 @@ func WaitForHub(ctx context.Context, t *testing.T, h *Hub) { case <-ctx.Done(): h.mu.Lock() h.ru.Lock() - dumpGoroutines("", os.Stderr) - assert.Fail(t, "Error waiting for clients %+v / rooms %+v / sessions %+v / remoteSessions %v / %d read / %d write to terminate: %s", h.clients, h.rooms, h.sessions, h.remoteSessions, readActive, writeActive, ctx.Err()) + test.DumpGoroutines("", os.Stderr) + assert.Fail(t, "Error waiting for hub to terminate", "clients %+v / rooms %+v / sessions %+v / remoteSessions %v / federatedSessions %v / federationClients %v / %d read / %d write: %s", + h.clients, + h.rooms, + h.sessions, + remoteSessions, + federatedSessions, + federationClients, + readActive, + writeActive, + ctx.Err(), + ) h.ru.Unlock() h.mu.Unlock() return @@ -290,24 +355,24 @@ func WaitForHub(ctx context.Context, t *testing.T, h *Hub) { } } -func validateBackendChecksum(t *testing.T, f func(http.ResponseWriter, *http.Request, *BackendClientRequest) *BackendClientResponse) func(http.ResponseWriter, *http.Request) { +func validateBackendChecksum(t *testing.T, f func(http.ResponseWriter, *http.Request, *talk.BackendClientRequest) *talk.BackendClientResponse) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - require := require.New(t) + assert := assert.New(t) body, err := io.ReadAll(r.Body) - require.NoError(err) + assert.NoError(err) - rnd := r.Header.Get(HeaderBackendSignalingRandom) - checksum := r.Header.Get(HeaderBackendSignalingChecksum) + rnd := r.Header.Get(talk.HeaderBackendSignalingRandom) + checksum := r.Header.Get(talk.HeaderBackendSignalingChecksum) if rnd == "" || checksum == "" { - require.Fail("No checksum headers found in request to %s", r.URL) + assert.Fail("No checksum headers found", "request to %s", r.URL) } - if verify := CalculateBackendChecksum(rnd, body, testBackendSecret); verify != checksum { - require.Fail("Backend checksum verification failed for request to %s", r.URL) + if verify := talk.CalculateBackendChecksum(rnd, body, testBackendSecret); verify != checksum { + assert.Fail("Backend checksum verification failed", "request to %s", r.URL) } - var request BackendClientRequest - require.NoError(json.Unmarshal(body, &request)) + var request talk.BackendClientRequest + assert.NoError(json.Unmarshal(body, &request)) response := f(w, r, &request) if response == nil { @@ -316,12 +381,12 @@ func validateBackendChecksum(t *testing.T, f func(http.ResponseWriter, *http.Req } data, err := json.Marshal(response) - require.NoError(err) + assert.NoError(err) if r.Header.Get("OCS-APIRequest") != "" { - var ocs OcsResponse - ocs.Ocs = &OcsBody{ - Meta: OcsMeta{ + var ocs talk.OcsResponse + ocs.Ocs = &talk.OcsBody{ + Meta: talk.OcsMeta{ Status: "ok", StatusCode: http.StatusOK, Message: http.StatusText(http.StatusOK), @@ -329,7 +394,7 @@ func validateBackendChecksum(t *testing.T, f func(http.ResponseWriter, *http.Req Data: data, } data, err = json.Marshal(ocs) - require.NoError(err) + assert.NoError(err) } w.Header().Set("Content-Type", "application/json") @@ -338,43 +403,43 @@ func validateBackendChecksum(t *testing.T, f func(http.ResponseWriter, *http.Req } } -func processAuthRequest(t *testing.T, w http.ResponseWriter, r *http.Request, request *BackendClientRequest) *BackendClientResponse { +func processAuthRequest(t *testing.T, w http.ResponseWriter, r *http.Request, request *talk.BackendClientRequest) *talk.BackendClientResponse { require := require.New(t) if request.Type != "auth" || request.Auth == nil { - require.Fail("Expected an auth backend request, got %+v", request) + require.Fail("Expected an auth backend request", "received %+v", request) } var params TestBackendClientAuthParams if len(request.Auth.Params) > 0 { require.NoError(json.Unmarshal(request.Auth.Params, ¶ms)) } - if params.UserId == "" { + switch params.UserId { + case "": params.UserId = testDefaultUserId - } else if params.UserId == authAnonymousUserId { + case authAnonymousUserId: params.UserId = "" } - response := &BackendClientResponse{ + response := &talk.BackendClientResponse{ Type: "auth", - Auth: &BackendClientAuthResponse{ - Version: BackendVersion, + Auth: &talk.BackendClientAuthResponse{ + Version: talk.BackendVersion, UserId: params.UserId, }, } userdata := map[string]string{ "displayname": "Displayname " + params.UserId, } - data, err := json.Marshal(userdata) - require.NoError(err) + data, _ := json.Marshal(userdata) response.Auth.User = data return response } -func processRoomRequest(t *testing.T, w http.ResponseWriter, r *http.Request, request *BackendClientRequest) *BackendClientResponse { +func processRoomRequest(t *testing.T, w http.ResponseWriter, r *http.Request, request *talk.BackendClientRequest) *talk.BackendClientResponse { require := require.New(t) assert := assert.New(t) if request.Type != "room" || request.Room == nil { - require.Fail("Expected an room backend request, got %+v", request) + require.Fail("Expected an room backend request", "received %+v", request) } switch request.Room.RoomId { @@ -383,12 +448,12 @@ func processRoomRequest(t *testing.T, w http.ResponseWriter, r *http.Request, re case "test-room-takeover-room-session": // Additional checks for testcase "TestClientTakeoverRoomSession" if request.Room.Action == "leave" && request.Room.UserId == "test-userid1" { - assert.Fail("Should not receive \"leave\" event for first user, received %+v", request.Room) + assert.Fail("Should not receive \"leave\" event for first user", "received %+v", request.Room) } case "test-invalid-room": - response := &BackendClientResponse{ + response := &talk.BackendClientResponse{ Type: "error", - Error: &Error{ + Error: &api.Error{ Code: "no_such_room", Message: "The user is not invited to this room.", }, @@ -398,20 +463,25 @@ func processRoomRequest(t *testing.T, w http.ResponseWriter, r *http.Request, re if strings.Contains(t.Name(), "Federation") { // Check additional fields present for federated sessions. - if strings.Contains(request.Room.SessionId, "@federated") { - assert.Equal(ActorTypeFederatedUsers, request.Room.ActorType) + if strings.Contains(string(request.Room.SessionId), "@federated") { + assert.Equal(api.ActorTypeFederatedUsers, request.Room.ActorType) assert.NotEmpty(request.Room.ActorId) } else { assert.Empty(request.Room.ActorType) assert.Empty(request.Room.ActorId) } + } else if strings.Contains(t.Name(), "VirtualSessionActorInformation") && request.Room.UserId == "user1" { + if request.Room.Action == "" || request.Room.Action == "join" || request.Room.Action == "leave" { + assert.Equal("actor-type", request.Room.ActorType, "failed for %+v", request.Room) + assert.Equal("actor-id", request.Room.ActorId, "failed for %+v", request.Room) + } } // Allow joining any room. - response := &BackendClientResponse{ + response := &talk.BackendClientResponse{ Type: "room", - Room: &BackendClientRoomResponse{ - Version: BackendVersion, + Room: &talk.BackendClientRoomResponse{ + Version: talk.BackendVersion, RoomId: request.Room.RoomId, Properties: testRoomProperties, }, @@ -421,11 +491,10 @@ func processRoomRequest(t *testing.T, w http.ResponseWriter, r *http.Request, re data := map[string]string{ "userid": "userid-from-sessiondata", } - tmp, err := json.Marshal(data) - require.NoError(err) + tmp, _ := json.Marshal(data) response.Room.Session = tmp case "test-room-initial-permissions": - permissions := []Permission{PERMISSION_MAY_PUBLISH_AUDIO} + permissions := []api.Permission{api.PERMISSION_MAY_PUBLISH_AUDIO} response.Room.Permissions = &permissions } return response @@ -434,15 +503,16 @@ func processRoomRequest(t *testing.T, w http.ResponseWriter, r *http.Request, re var ( sessionRequestHander struct { sync.Mutex - handlers map[*testing.T]func(*BackendClientSessionRequest) + // +checklocks:Mutex + handlers map[*testing.T]func(*talk.BackendClientSessionRequest) } ) -func setSessionRequestHandler(t *testing.T, f func(*BackendClientSessionRequest)) { +func setSessionRequestHandler(t *testing.T, f func(*talk.BackendClientSessionRequest)) { sessionRequestHander.Lock() defer sessionRequestHander.Unlock() if sessionRequestHander.handlers == nil { - sessionRequestHander.handlers = make(map[*testing.T]func(*BackendClientSessionRequest)) + sessionRequestHander.handlers = make(map[*testing.T]func(*talk.BackendClientSessionRequest)) } if _, found := sessionRequestHander.handlers[t]; !found { t.Cleanup(func() { @@ -462,9 +532,9 @@ func clearSessionRequestHandler(t *testing.T) { // nolint delete(sessionRequestHander.handlers, t) } -func processSessionRequest(t *testing.T, w http.ResponseWriter, r *http.Request, request *BackendClientRequest) *BackendClientResponse { +func processSessionRequest(t *testing.T, w http.ResponseWriter, r *http.Request, request *talk.BackendClientRequest) *talk.BackendClientResponse { if request.Type != "session" || request.Session == nil { - require.Fail(t, "Expected an session backend request, got %+v", request) + require.Fail(t, "Expected an session backend request", "received %+v", request) } sessionRequestHander.Lock() @@ -473,45 +543,37 @@ func processSessionRequest(t *testing.T, w http.ResponseWriter, r *http.Request, f(request.Session) } - response := &BackendClientResponse{ + response := &talk.BackendClientResponse{ Type: "session", - Session: &BackendClientSessionResponse{ - Version: BackendVersion, + Session: &talk.BackendClientSessionResponse{ + Version: talk.BackendVersion, RoomId: request.Session.RoomId, }, } return response } -var pingRequests map[*testing.T][]*BackendClientRequest +var ( + pingRequests test.Storage[[]*talk.BackendClientRequest] +) -func getPingRequests(t *testing.T) []*BackendClientRequest { - return pingRequests[t] +func getPingRequests(t *testing.T) []*talk.BackendClientRequest { + entries, _ := pingRequests.Get(t) + return entries } func clearPingRequests(t *testing.T) { - delete(pingRequests, t) + pingRequests.Del(t) } -func storePingRequest(t *testing.T, request *BackendClientRequest) { - if entries, found := pingRequests[t]; !found { - if pingRequests == nil { - pingRequests = make(map[*testing.T][]*BackendClientRequest) - } - pingRequests[t] = []*BackendClientRequest{ - request, - } - t.Cleanup(func() { - clearPingRequests(t) - }) - } else { - pingRequests[t] = append(entries, request) - } +func storePingRequest(t *testing.T, request *talk.BackendClientRequest) { + entries, _ := pingRequests.Get(t) + pingRequests.Set(t, append(entries, request)) } -func processPingRequest(t *testing.T, w http.ResponseWriter, r *http.Request, request *BackendClientRequest) *BackendClientResponse { +func processPingRequest(t *testing.T, w http.ResponseWriter, r *http.Request, request *talk.BackendClientRequest) *talk.BackendClientResponse { if request.Type != "ping" || request.Ping == nil { - require.Fail(t, "Expected an ping backend request, got %+v", request) + require.Fail(t, "Expected an ping backend request", "received %+v", request) } if request.Ping.RoomId == "test-room-with-sessiondata" { @@ -522,23 +584,30 @@ func processPingRequest(t *testing.T, w http.ResponseWriter, r *http.Request, re storePingRequest(t, request) - response := &BackendClientResponse{ + response := &talk.BackendClientResponse{ Type: "ping", - Ping: &BackendClientRingResponse{ - Version: BackendVersion, + Ping: &talk.BackendClientRingResponse{ + Version: talk.BackendVersion, RoomId: request.Ping.RoomId, }, } return response } +type testAuthToken struct { + PrivateKey string + PublicKey string +} + +var ( + authTokens test.Storage[testAuthToken] +) + func ensureAuthTokens(t *testing.T) (string, string) { require := require.New(t) - if privateKey := os.Getenv("PRIVATE_AUTH_TOKEN_" + t.Name()); privateKey != "" { - publicKey := os.Getenv("PUBLIC_AUTH_TOKEN_" + t.Name()) - // should not happen, always both keys are created - require.NotEmpty(publicKey, "public key is empty") - return privateKey, publicKey + + if tokens, found := authTokens.Get(t); found { + return tokens.PrivateKey, tokens.PublicKey } var private []byte @@ -596,13 +665,16 @@ func ensureAuthTokens(t *testing.T) (string, string) { } privateKey := base64.StdEncoding.EncodeToString(private) - t.Setenv("PRIVATE_AUTH_TOKEN_"+t.Name(), privateKey) publicKey := base64.StdEncoding.EncodeToString(public) - t.Setenv("PUBLIC_AUTH_TOKEN_"+t.Name(), publicKey) + + authTokens.Set(t, testAuthToken{ + PrivateKey: privateKey, + PublicKey: publicKey, + }) return privateKey, publicKey } -func getPrivateAuthToken(t *testing.T) (key interface{}) { +func getPrivateAuthToken(t *testing.T) (key any) { private, _ := ensureAuthTokens(t) data, err := base64.StdEncoding.DecodeString(private) require.NoError(t, err) @@ -617,7 +689,7 @@ func getPrivateAuthToken(t *testing.T) (key interface{}) { return key } -func getPublicAuthToken(t *testing.T) (key interface{}) { +func getPublicAuthToken(t *testing.T) (key any) { _, public := ensureAuthTokens(t) data, err := base64.StdEncoding.DecodeString(public) require.NoError(t, err) @@ -636,8 +708,14 @@ func registerBackendHandler(t *testing.T, router *mux.Router) { registerBackendHandlerUrl(t, router, "/") } +var ( + skipV2Capabilities test.Storage[bool] +) + func registerBackendHandlerUrl(t *testing.T, router *mux.Router, url string) { - handleFunc := validateBackendChecksum(t, func(w http.ResponseWriter, r *http.Request, request *BackendClientRequest) *BackendClientResponse { + handleFunc := validateBackendChecksum(t, func(w http.ResponseWriter, r *http.Request, request *talk.BackendClientRequest) *talk.BackendClientResponse { + assert.Regexp(t, "/ocs/v2\\.php/apps/spreed/api/v[\\d]/signaling/backend$", r.URL.Path, "invalid url for backend request %+v", request) + switch request.Type { case "auth": return processAuthRequest(t, w, r, request) @@ -648,7 +726,7 @@ func registerBackendHandlerUrl(t *testing.T, router *mux.Router, url string) { case "ping": return processPingRequest(t, w, r, request) default: - require.Fail(t, "Unsupported request received: %+v", request) + require.Fail(t, "Unsupported request", "received: %+v", request) return nil } }) @@ -669,24 +747,21 @@ func registerBackendHandlerUrl(t *testing.T, router *mux.Router, url string) { if strings.Contains(t.Name(), "Federation") { features = append(features, "federation-v2") } - signaling := map[string]interface{}{ + signaling := api.StringMap{ "foo": "bar", "baz": 42, } - config := map[string]interface{}{ + config := api.StringMap{ "signaling": signaling, } if strings.Contains(t.Name(), "MultiRoom") { - signaling[ConfigKeySessionPingLimit] = 2 + signaling[talk.ConfigKeySessionPingLimit] = 2 } - useV2 := true - if os.Getenv("SKIP_V2_CAPABILITIES") != "" { - useV2 = false - } - if (strings.Contains(t.Name(), "V2") && useV2) || strings.Contains(t.Name(), "Federation") { + skipV2, _ := skipV2Capabilities.Get(t) + if (strings.Contains(t.Name(), "V2") && !skipV2) || strings.Contains(t.Name(), "Federation") { key := getPublicAuthToken(t) public, err := x509.MarshalPKIXPublicKey(key) - require.NoError(t, err) + assert.NoError(t, err) var pemType string if strings.Contains(t.Name(), "ECDSA") { pemType = "ECDSA PUBLIC KEY" @@ -703,17 +778,18 @@ func registerBackendHandlerUrl(t *testing.T, router *mux.Router, url string) { if strings.Contains(t.Name(), "Ed25519_Nextcloud") { // Simulate Nextcloud which returns the Ed25519 key as base64-encoded data. encoded := base64.StdEncoding.EncodeToString(key.(ed25519.PublicKey)) - signaling[ConfigKeyHelloV2TokenKey] = encoded + signaling[talk.ConfigKeyHelloV2TokenKey] = encoded } else { - signaling[ConfigKeyHelloV2TokenKey] = string(public) + signaling[talk.ConfigKeyHelloV2TokenKey] = string(public) } } - spreedCapa, _ := json.Marshal(map[string]interface{}{ + spreedCapa, err := json.Marshal(api.StringMap{ "features": features, "config": config, }) - response := &CapabilitiesResponse{ - Version: CapabilitiesVersion{ + assert.NoError(t, err) + response := &talk.CapabilitiesResponse{ + Version: talk.CapabilitiesVersion{ Major: 20, }, Capabilities: map[string]json.RawMessage{ @@ -724,9 +800,9 @@ func registerBackendHandlerUrl(t *testing.T, router *mux.Router, url string) { data, err := json.Marshal(response) assert.NoError(t, err, "Could not marshal %+v", response) - var ocs OcsResponse - ocs.Ocs = &OcsBody{ - Meta: OcsMeta{ + var ocs talk.OcsResponse + ocs.Ocs = &talk.OcsBody{ + Meta: talk.OcsMeta{ Status: "ok", StatusCode: http.StatusOK, Message: http.StatusText(http.StatusOK), @@ -734,7 +810,7 @@ func registerBackendHandlerUrl(t *testing.T, router *mux.Router, url string) { Data: data, } data, err = json.Marshal(ocs) - require.NoError(t, err) + assert.NoError(t, err) w.Header().Add("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(data) // nolint @@ -748,19 +824,62 @@ func registerBackendHandlerUrl(t *testing.T, router *mux.Router, url string) { } } -func performHousekeeping(hub *Hub, now time.Time) *sync.WaitGroup { - var wg sync.WaitGroup - wg.Add(1) - go func() { - hub.performHousekeeping(now) - wg.Done() - }() - return &wg +func Benchmark_DecodePrivateSessionIdCached(b *testing.B) { + require := require.New(b) + decodeCaches := make([]*container.LruCache[*session.SessionIdData], 0, numDecodeCaches) + for range numDecodeCaches { + decodeCaches = append(decodeCaches, container.NewLruCache[*session.SessionIdData](decodeCacheSize)) + } + backend := talk.NewCompatBackend(nil) + data := &session.SessionIdData{ + Sid: 1, + Created: time.Now().UnixMicro(), + BackendId: backend.Id(), + } + codec, err := session.NewSessionIdCodec([]byte("12345678901234567890123456789012"), []byte("09876543210987654321098765432109")) + require.NoError(err) + sid, err := codec.EncodePrivate(data) + require.NoError(err, "could not create session id") + hub := &Hub{ + sessionIds: codec, + decodeCaches: decodeCaches, + } + // Decode once to populate cache. + require.NotNil(hub.decodePrivateSessionId(sid)) + for b.Loop() { + hub.decodePrivateSessionId(sid) + } +} + +func Benchmark_DecodePublicSessionIdCached(b *testing.B) { + require := require.New(b) + decodeCaches := make([]*container.LruCache[*session.SessionIdData], 0, numDecodeCaches) + for range numDecodeCaches { + decodeCaches = append(decodeCaches, container.NewLruCache[*session.SessionIdData](decodeCacheSize)) + } + backend := talk.NewCompatBackend(nil) + data := &session.SessionIdData{ + Sid: 1, + Created: time.Now().UnixMicro(), + BackendId: backend.Id(), + } + codec, err := session.NewSessionIdCodec([]byte("12345678901234567890123456789012"), []byte("09876543210987654321098765432109")) + require.NoError(err) + sid, err := codec.EncodePublic(data) + require.NoError(err, "could not create session id") + hub := &Hub{ + sessionIds: codec, + decodeCaches: decodeCaches, + } + // Decode once to populate cache. + require.NotNil(hub.decodePublicSessionId(sid)) + for b.Loop() { + hub.decodePublicSessionId(sid) + } } func TestWebsocketFeatures(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) _, _, _, server := CreateHubForTest(t) @@ -768,7 +887,7 @@ func TestWebsocketFeatures(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - conn, response, err := websocket.DefaultDialer.DialContext(ctx, getWebsocketUrl(server.URL), nil) + conn, response, err := testClientDialer.DialContext(ctx, getWebsocketUrl(server.URL), nil) require.NoError(err) defer conn.Close() // nolint @@ -776,27 +895,22 @@ func TestWebsocketFeatures(t *testing.T) { assert.True(strings.HasPrefix(serverHeader, "nextcloud-spreed-signaling/"), "expected valid server header, got \"%s\"", serverHeader) features := response.Header.Get("X-Spreed-Signaling-Features") featuresList := make(map[string]bool) - for _, f := range strings.Split(features, ",") { - f = strings.TrimSpace(f) - if f != "" { - _, found := featuresList[f] - assert.False(found, "duplicate feature id \"%s\" in \"%s\"", f, features) - featuresList[f] = true - } + for f := range internal.SplitEntries(features, ",") { + _, found := featuresList[f] + assert.False(found, "duplicate feature id \"%s\" in \"%s\"", f, features) + featuresList[f] = true } if len(featuresList) <= 1 { - assert.Fail("expected valid features header, got \"%s\"", features) + assert.Fail("expected valid features header", "received \"%s\"", features) } _, found := featuresList["hello-v2"] - assert.True(found, "expected feature \"hello-v2\", got \"%s\"", features) + assert.True(found, "expected feature \"hello-v2\"", "received \"%s\"", features) assert.NoError(conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Time{})) } func TestInitialWelcome(t *testing.T) { t.Parallel() - CatchLogForTest(t) - require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -806,20 +920,17 @@ func TestInitialWelcome(t *testing.T) { client := NewTestClientContext(ctx, t, server, hub) defer client.CloseWithBye() - msg, err := client.RunUntilMessage(ctx) - require.NoError(err) - - assert.Equal("welcome", msg.Type, "%+v", msg) - if assert.NotNil(msg.Welcome, "%+v", msg) { - assert.NotEmpty(msg.Welcome.Version, "%+v", msg) - assert.NotEmpty(msg.Welcome.Features, "%+v", msg) + if msg, ok := client.RunUntilMessage(ctx); ok { + assert.Equal("welcome", msg.Type, "%+v", msg) + if assert.NotNil(msg.Welcome, "%+v", msg) { + assert.NotEmpty(msg.Welcome.Version, "%+v", msg) + assert.NotEmpty(msg.Welcome.Features, "%+v", msg) + } } } func TestExpectClientHello(t *testing.T) { t.Parallel() - CatchLogForTest(t) - require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -830,27 +941,22 @@ func TestExpectClientHello(t *testing.T) { // Perform housekeeping in the future, this will cause the connection to // be terminated due to the missing "Hello" request. - performHousekeeping(hub, time.Now().Add(initialHelloTimeout+time.Second)) + hub.performHousekeeping(time.Now().Add(initialHelloTimeout + time.Second)) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - message, err := client.RunUntilMessage(ctx) - require.NoError(checkUnexpectedClose(err)) - message2, err := client.RunUntilMessage(ctx) - if message2 != nil { - require.Fail("Received multiple messages, already have %+v, also got %+v", message, message2) + if message, ok := client.RunUntilMessage(ctx); ok { + if checkMessageType(t, message, "bye") { + assert.Equal("hello_timeout", message.Bye.Reason, "%+v", message.Bye) + } } - require.NoError(checkUnexpectedClose(err)) - if err := checkMessageType(message, "bye"); assert.NoError(err) { - assert.Equal("hello_timeout", message.Bye.Reason, "%+v", message.Bye) - } + client.RunUntilClosed(ctx) } func TestExpectClientHelloUnsupportedVersion(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -866,39 +972,32 @@ func TestExpectClientHelloUnsupportedVersion(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - message, err := client.RunUntilMessage(ctx) - require.NoError(checkUnexpectedClose(err)) - - if err := checkMessageType(message, "error"); assert.NoError(err) { - assert.Equal("invalid_hello_version", message.Error.Code) + if message, ok := client.RunUntilMessage(ctx); ok { + if checkMessageType(t, message, "error") { + assert.Equal("invalid_hello_version", message.Error.Code) + } } } func TestClientHelloV1(t *testing.T) { t.Parallel() - CatchLogForTest(t) - require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { - assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello) - assert.NotEmpty(hello.Hello.SessionId, "%+v", hello) - } + _, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello) + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello) } func TestClientHelloV2(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, algo := range testHelloV2Algorithms { t.Run(algo, func(t *testing.T) { + t.Parallel() require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -911,31 +1010,32 @@ func TestClientHelloV2(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) - assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) - assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) + if hello, ok := client.RunUntilHello(ctx); ok { + assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) - data := hub.decodePublicSessionId(hello.Hello.SessionId) - require.NotNil(data, "Could not decode session id: %s", hello.Hello.SessionId) + data := hub.decodePublicSessionId(hello.Hello.SessionId) + require.NotNil(data, "Could not decode session id: %s", hello.Hello.SessionId) - hub.mu.RLock() - session := hub.sessions[data.Sid] - hub.mu.RUnlock() - require.NotNil(session, "Could not get session for id %+v", data) + hub.mu.RLock() + session := hub.sessions[data.Sid] + hub.mu.RUnlock() + require.NotNil(session, "Could not get session for id %+v", data) - var userdata map[string]string - require.NoError(json.Unmarshal(session.UserData(), &userdata)) + var userdata map[string]string + require.NoError(json.Unmarshal(session.UserData(), &userdata)) - assert.Equal("Displayname "+testDefaultUserId, userdata["displayname"]) + assert.Equal("Displayname "+testDefaultUserId, userdata["displayname"]) + } }) } } func TestClientHelloV2_IssuedInFuture(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, algo := range testHelloV2Algorithms { t.Run(algo, func(t *testing.T) { + t.Parallel() require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -950,20 +1050,20 @@ func TestClientHelloV2_IssuedInFuture(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) - assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) - assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) + if hello, ok := client.RunUntilHello(ctx); ok { + assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) + } }) } } func TestClientHelloV2_IssuedFarInFuture(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, algo := range testHelloV2Algorithms { t.Run(algo, func(t *testing.T) { + t.Parallel() require := require.New(t) - assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) client := NewTestClient(t, server, hub) @@ -976,22 +1076,17 @@ func TestClientHelloV2_IssuedFarInFuture(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - message, err := client.RunUntilMessage(ctx) - require.NoError(checkUnexpectedClose(err)) - - if err := checkMessageType(message, "error"); assert.NoError(err) { - assert.Equal("token_not_valid_yet", message.Error.Code, "%+v", message) - } + client.RunUntilError(ctx, "token_not_valid_yet") // nolint }) } } func TestClientHelloV2_Expired(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, algo := range testHelloV2Algorithms { t.Run(algo, func(t *testing.T) { + t.Parallel() require := require.New(t) - assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) client := NewTestClient(t, server, hub) @@ -1004,22 +1099,17 @@ func TestClientHelloV2_Expired(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - message, err := client.RunUntilMessage(ctx) - require.NoError(checkUnexpectedClose(err)) - - if err := checkMessageType(message, "error"); assert.NoError(err) { - assert.Equal("token_expired", message.Error.Code, "%+v", message) - } + client.RunUntilError(ctx, "token_expired") // nolint }) } } func TestClientHelloV2_IssuedAtMissing(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, algo := range testHelloV2Algorithms { t.Run(algo, func(t *testing.T) { + t.Parallel() require := require.New(t) - assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) client := NewTestClient(t, server, hub) @@ -1032,22 +1122,17 @@ func TestClientHelloV2_IssuedAtMissing(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - message, err := client.RunUntilMessage(ctx) - require.NoError(checkUnexpectedClose(err)) - - if err := checkMessageType(message, "error"); assert.NoError(err) { - assert.Equal("token_not_valid_yet", message.Error.Code, "%+v", message) - } + client.RunUntilError(ctx, "token_not_valid_yet") // nolint }) } } func TestClientHelloV2_ExpiresAtMissing(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, algo := range testHelloV2Algorithms { t.Run(algo, func(t *testing.T) { + t.Parallel() require := require.New(t) - assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) client := NewTestClient(t, server, hub) @@ -1060,20 +1145,16 @@ func TestClientHelloV2_ExpiresAtMissing(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - message, err := client.RunUntilMessage(ctx) - require.NoError(checkUnexpectedClose(err)) - - if err := checkMessageType(message, "error"); assert.NoError(err) { - assert.Equal("token_expired", message.Error.Code, "%+v", message) - } + client.RunUntilError(ctx, "token_expired") // nolint }) } } func TestClientHelloV2_CachedCapabilities(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, algo := range testHelloV2Algorithms { t.Run(algo, func(t *testing.T) { + t.Parallel() require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -1082,28 +1163,26 @@ func TestClientHelloV2_CachedCapabilities(t *testing.T) { defer cancel() // Simulate old-style Nextcloud without capabilities for Hello V2. - t.Setenv("SKIP_V2_CAPABILITIES", "1") + skipV2Capabilities.Set(t, true) client1 := NewTestClient(t, server, hub) defer client1.CloseWithBye() require.NoError(client1.SendHelloV1(testDefaultUserId + "1")) - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) assert.Equal(testDefaultUserId+"1", hello1.Hello.UserId, "%+v", hello1.Hello) assert.NotEmpty(hello1.Hello.SessionId, "%+v", hello1.Hello) // Simulate updated Nextcloud with capabilities for Hello V2. - t.Setenv("SKIP_V2_CAPABILITIES", "") + skipV2Capabilities.Set(t, false) client2 := NewTestClient(t, server, hub) defer client2.CloseWithBye() require.NoError(client2.SendHelloV2(testDefaultUserId + "2")) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) assert.Equal(testDefaultUserId+"2", hello2.Hello.UserId, "%+v", hello2.Hello) assert.NotEmpty(hello2.Hello.SessionId, "%+v", hello2.Hello) }) @@ -1112,30 +1191,21 @@ func TestClientHelloV2_CachedCapabilities(t *testing.T) { func TestClientHelloWithSpaces(t *testing.T) { t.Parallel() - CatchLogForTest(t) - require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - userId := "test user with spaces" - require.NoError(client.SendHello(userId)) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { - assert.Equal(userId, hello.Hello.UserId, "%+v", hello.Hello) - assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) - } + _, hello := NewTestClientWithHello(ctx, t, server, hub, userId) + assert.Equal(userId, hello.Hello.UserId, "%+v", hello.Hello) + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) } func TestClientHelloAllowAll(t *testing.T) { t.Parallel() - CatchLogForTest(t) - require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTestWithConfig(t, func(server *httptest.Server) (*goconf.ConfigFile, error) { config, err := getTestConfig(server) @@ -1148,22 +1218,16 @@ func TestClientHelloAllowAll(t *testing.T) { return config, nil }) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { - assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) - assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) - } + _, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) } func TestClientHelloSessionLimit(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -1242,12 +1306,12 @@ func TestClientHelloSessionLimit(t *testing.T) { params1 := TestBackendClientAuthParams{ UserId: testDefaultUserId, } - require.NoError(client.SendHelloParams(server1.URL+"/one", HelloVersionV1, "client", nil, params1)) + require.NoError(client.SendHelloParams(server1.URL+"/one", api.HelloVersionV1, "client", nil, params1)) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { + if hello, ok := client.RunUntilHello(ctx); ok { assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) } @@ -1259,19 +1323,14 @@ func TestClientHelloSessionLimit(t *testing.T) { params2 := TestBackendClientAuthParams{ UserId: testDefaultUserId + "2", } - require.NoError(client2.SendHelloParams(server1.URL+"/one", HelloVersionV1, "client", nil, params2)) + require.NoError(client2.SendHelloParams(server1.URL+"/one", api.HelloVersionV1, "client", nil, params2)) - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - assert.Equal("error", msg.Type, "%+v", msg) - if assert.NotNil(msg.Error, "%+v", msg) { - assert.Equal("session_limit_exceeded", msg.Error.Code, "%+v", msg) - } - } + client2.RunUntilError(ctx, "session_limit_exceeded") //nolint // The client can connect to a different backend. - require.NoError(client2.SendHelloParams(server1.URL+"/two", HelloVersionV1, "client", nil, params2)) + require.NoError(client2.SendHelloParams(server1.URL+"/two", api.HelloVersionV1, "client", nil, params2)) - if hello, err := client2.RunUntilHello(ctx); assert.NoError(err) { + if hello, ok := client2.RunUntilHello(ctx); ok { assert.Equal(testDefaultUserId+"2", hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) } @@ -1286,9 +1345,9 @@ func TestClientHelloSessionLimit(t *testing.T) { params3 := TestBackendClientAuthParams{ UserId: testDefaultUserId + "3", } - require.NoError(client3.SendHelloParams(server1.URL+"/one", HelloVersionV1, "client", nil, params3)) + require.NoError(client3.SendHelloParams(server1.URL+"/one", api.HelloVersionV1, "client", nil, params3)) - if hello, err := client3.RunUntilHello(ctx); assert.NoError(err) { + if hello, ok := client3.RunUntilHello(ctx); ok { assert.Equal(testDefaultUserId+"3", hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) } @@ -1298,46 +1357,49 @@ func TestClientHelloSessionLimit(t *testing.T) { func TestSessionIdsUnordered(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - publicSessionIds := make([]string, 0) - for i := 0; i < 20; i++ { - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() + var mu sync.Mutex + var publicSessionIds []api.PublicSessionId + var wg sync.WaitGroup + for range 20 { + wg.Go(func() { + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() - require.NoError(client.SendHello(testDefaultUserId)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { + _, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // nolint:testifylint assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) data := hub.decodePublicSessionId(hello.Hello.SessionId) if !assert.NotNil(data, "Could not decode session id: %s", hello.Hello.SessionId) { - break + return } hub.mu.RLock() session := hub.sessions[data.Sid] hub.mu.RUnlock() if !assert.NotNil(session, "Could not get session for id %+v", data) { - break + return } + mu.Lock() publicSessionIds = append(publicSessionIds, session.PublicId()) - } + mu.Unlock() + }) } + wg.Wait() + + mu.Lock() + defer mu.Unlock() require.NotEmpty(publicSessionIds, "no session ids decoded") larger := 0 smaller := 0 - prevSid := "" + var prevSid api.PublicSessionId for i, sid := range publicSessionIds { if i > 0 { if sid > prevSid { @@ -1358,21 +1420,14 @@ func TestSessionIdsUnordered(t *testing.T) { func TestClientHelloResume(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) require.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) @@ -1384,16 +1439,31 @@ func TestClientHelloResume(t *testing.T) { defer client.CloseWithBye() require.NoError(client.SendHelloResume(hello.Hello.ResumeId)) - if hello2, err := client.RunUntilHello(ctx); assert.NoError(err) { + if hello2, ok := client.RunUntilHello(ctx); ok { assert.Equal(testDefaultUserId, hello2.Hello.UserId, "%+v", hello2.Hello) assert.Equal(hello.Hello.SessionId, hello2.Hello.SessionId, "%+v", hello2.Hello) assert.Equal(hello.Hello.ResumeId, hello2.Hello.ResumeId, "%+v", hello2.Hello) } } +type throttlerTiming struct { + t *testing.T + + now time.Time + expectedSleep time.Duration +} + +func (t *throttlerTiming) getNow() time.Time { + return t.now +} + +func (t *throttlerTiming) doDelay(ctx context.Context, duration time.Duration) { + t.t.Helper() + assert.Equal(t.t, t.expectedSleep, duration) +} + func TestClientHelloResumeThrottle(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -1402,10 +1472,12 @@ func TestClientHelloResumeThrottle(t *testing.T) { t: t, now: time.Now(), } - th := newMemoryThrottlerForTest(t) - th.getNow = timing.getNow - th.doDelay = timing.doDelay - hub.throttler = th + throttler, err := async.NewCustomMemoryThrottler(timing.getNow, timing.doDelay) + require.NoError(err) + t.Cleanup(func() { + throttler.Close() + }) + hub.throttler = throttler ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -1416,20 +1488,9 @@ func TestClientHelloResumeThrottle(t *testing.T) { timing.expectedSleep = 100 * time.Millisecond require.NoError(client.SendHelloResume("this-is-invalid")) - if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.Equal("error", msg.Type, "%+v", msg) - if assert.NotNil(msg.Error, "%+v", msg) { - assert.Equal("no_such_session", msg.Error.Code, "%+v", msg) - } - } + client.RunUntilError(ctx, "no_such_session") //nolint - client = NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) assert.Equal(testDefaultUserId, hello.Hello.UserId) assert.NotEmpty(hello.Hello.SessionId) assert.NotEmpty(hello.Hello.ResumeId) @@ -1439,7 +1500,7 @@ func TestClientHelloResumeThrottle(t *testing.T) { // Perform housekeeping in the future, this will cause the session to be // cleaned up after it is expired. - performHousekeeping(hub, time.Now().Add(sessionExpireDuration+time.Second)).Wait() + hub.performHousekeeping(time.Now().Add(sessionExpireDuration + time.Second)) client = NewTestClient(t, server, hub) defer client.CloseWithBye() @@ -1447,12 +1508,7 @@ func TestClientHelloResumeThrottle(t *testing.T) { // Valid but expired resume ids will not be throttled. timing.expectedSleep = 0 * time.Millisecond require.NoError(client.SendHelloResume(hello.Hello.ResumeId)) - if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.Equal("error", msg.Type, "%+v", msg) - if assert.NotNil(msg.Error, "%+v", msg) { - assert.Equal("no_such_session", msg.Error.Code, "%+v", msg) - } - } + client.RunUntilError(ctx, "no_such_session") //nolint client = NewTestClient(t, server, hub) defer client.CloseWithBye() @@ -1460,31 +1516,18 @@ func TestClientHelloResumeThrottle(t *testing.T) { timing.expectedSleep = 200 * time.Millisecond require.NoError(client.SendHelloResume("this-is-invalid")) - if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.Equal("error", msg.Type, "%+v", msg) - if assert.NotNil(msg.Error, "%+v", msg) { - assert.Equal("no_such_session", msg.Error.Code, "%+v", msg) - } - } + client.RunUntilError(ctx, "no_such_session") //nolint } func TestClientHelloResumeExpired(t *testing.T) { t.Parallel() - CatchLogForTest(t) - require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) @@ -1494,37 +1537,26 @@ func TestClientHelloResumeExpired(t *testing.T) { // Perform housekeeping in the future, this will cause the session to be // cleaned up after it is expired. - performHousekeeping(hub, time.Now().Add(sessionExpireDuration+time.Second)).Wait() + hub.performHousekeeping(time.Now().Add(sessionExpireDuration + time.Second)) client = NewTestClient(t, server, hub) defer client.CloseWithBye() - require.NoError(client.SendHelloResume(hello.Hello.ResumeId)) - if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.Equal("error", msg.Type, "%+v", msg) - if assert.NotNil(msg.Error, "%+v", msg) { - assert.Equal("no_such_session", msg.Error.Code, "%+v", msg) - } + if assert.NoError(client.SendHelloResume(hello.Hello.ResumeId)) { + client.RunUntilError(ctx, "no_such_session") //nolint } } func TestClientHelloResumeTakeover(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client1.RunUntilHello(ctx) - require.NoError(err) + client1, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) require.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) @@ -1533,44 +1565,32 @@ func TestClientHelloResumeTakeover(t *testing.T) { defer client2.CloseWithBye() require.NoError(client2.SendHelloResume(hello.Hello.ResumeId)) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) assert.Equal(testDefaultUserId, hello2.Hello.UserId, "%+v", hello2.Hello) assert.Equal(hello.Hello.SessionId, hello2.Hello.SessionId, "%+v", hello2.Hello) assert.Equal(hello.Hello.ResumeId, hello2.Hello.ResumeId, "%+v", hello2.Hello) // The first client got disconnected with a reason in a "Bye" message. - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { + if msg, ok := client1.RunUntilMessage(ctx); ok { assert.Equal("bye", msg.Type, "%+v", msg) if assert.NotNil(msg.Bye, "%+v", msg) { assert.Equal("session_resumed", msg.Bye.Reason, "%+v", msg) } } - if msg, err := client1.RunUntilMessage(ctx); err == nil { - assert.Fail("Expected error but received %+v", msg) - } else if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { - assert.Fail("Expected close error but received %+v", err) - } + client1.RunUntilClosed(ctx) } func TestClientHelloResumeOtherHub(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) @@ -1595,13 +1615,7 @@ func TestClientHelloResumeOtherHub(t *testing.T) { assert.Equal(0, count, "Should have removed all sessions") // The new client will get the same (internal) sid for his session. - newClient := NewTestClient(t, server, hub) - defer newClient.CloseWithBye() - - require.NoError(newClient.SendHello(testDefaultUserId)) - - hello2, err := newClient.RunUntilHello(ctx) - require.NoError(err) + _, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) assert.Equal(testDefaultUserId, hello2.Hello.UserId, "%+v", hello2.Hello) assert.NotEmpty(hello2.Hello.SessionId, "%+v", hello2.Hello) assert.NotEmpty(hello2.Hello.ResumeId, "%+v", hello2.Hello) @@ -1610,12 +1624,7 @@ func TestClientHelloResumeOtherHub(t *testing.T) { client = NewTestClient(t, server, hub) defer client.CloseWithBye() require.NoError(client.SendHelloResume(hello.Hello.ResumeId)) - if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.Equal("error", msg.Type, "%+v", msg) - if assert.NotNil(msg.Error, "%+v", msg) { - assert.Equal("no_such_session", msg.Error.Code, "%+v", msg) - } - } + client.RunUntilError(ctx, "no_such_session") //nolint // Expire old sessions hub.performHousekeeping(time.Now().Add(2 * sessionExpireDuration)) @@ -1623,30 +1632,19 @@ func TestClientHelloResumeOtherHub(t *testing.T) { func TestClientHelloResumePublicId(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) // Test that a client can't resume a "public" session of another user. hub, _, _, server := CreateHubForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) - recipient2 := MessageClientMessageRecipient{ + recipient2 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, } @@ -1655,8 +1653,8 @@ func TestClientHelloResumePublicId(t *testing.T) { client1.SendMessage(recipient2, data) // nolint var payload string - var sender *MessageServerMessageSender - if err := checkReceiveClientMessageWithSender(ctx, client2, "session", hello1.Hello, &payload, &sender); assert.NoError(err) { + var sender *api.MessageServerMessageSender + if checkReceiveClientMessageWithSender(ctx, t, client2, "session", hello1.Hello, &payload, &sender) { assert.Equal(data, payload) } @@ -1667,13 +1665,8 @@ func TestClientHelloResumePublicId(t *testing.T) { defer client1.CloseWithBye() // Can't resume a session with the id received from messages of a client. - require.NoError(client1.SendHelloResume(sender.SessionId)) - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.Equal("error", msg.Type, "%+v", msg) - if assert.NotNil(msg.Error, "%+v", msg) { - assert.Equal("no_such_session", msg.Error.Code, "%+v", msg) - } - } + require.NoError(client1.SendHelloResume(api.PrivateSessionId(sender.SessionId))) + client1.RunUntilError(ctx, "no_such_session") // nolint // Expire old sessions hub.performHousekeeping(time.Now().Add(2 * sessionExpireDuration)) @@ -1681,28 +1674,21 @@ func TestClientHelloResumePublicId(t *testing.T) { func TestClientHelloByeResume(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) require.NoError(client.SendBye()) - if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(checkMessageType(message, "bye")) + if message, ok := client.RunUntilMessage(ctx); ok { + checkMessageType(t, message, "bye") } client.Close() @@ -1713,31 +1699,19 @@ func TestClientHelloByeResume(t *testing.T) { defer client.CloseWithBye() require.NoError(client.SendHelloResume(hello.Hello.ResumeId)) - if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.Equal("error", msg.Type, "%+v", msg) - if assert.NotNil(msg.Error, "%+v", msg) { - assert.Equal("no_such_session", msg.Error.Code, "%+v", msg) - } - } + client.RunUntilError(ctx, "no_such_session") //nolint } func TestClientHelloResumeAndJoin(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) @@ -1749,17 +1723,17 @@ func TestClientHelloResumeAndJoin(t *testing.T) { defer client.CloseWithBye() require.NoError(client.SendHelloResume(hello.Hello.ResumeId)) - hello2, err := client.RunUntilHello(ctx) - require.NoError(err) - assert.Equal(testDefaultUserId, hello2.Hello.UserId, "%+v", hello2.Hello) - assert.Equal(hello.Hello.SessionId, hello2.Hello.SessionId, "%+v", hello2.Hello) - assert.Equal(hello.Hello.ResumeId, hello2.Hello.ResumeId, "%+v", hello2.Hello) + if hello2, ok := client.RunUntilHello(ctx); ok && hello != nil { + assert.Equal(testDefaultUserId, hello2.Hello.UserId, "%+v", hello2.Hello) + assert.Equal(hello.Hello.SessionId, hello2.Hello.SessionId, "%+v", hello2.Hello) + assert.Equal(hello.Hello.ResumeId, hello2.Hello.ResumeId, "%+v", hello2.Hello) + } // Join room by id. roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) + if roomMsg, ok := client.JoinRoom(ctx, roomId); ok { + assert.Equal(roomId, roomMsg.Room.RoomId) + } } func runGrpcProxyTest(t *testing.T, f func(hub1, hub2 *Hub, server1, server2 *httptest.Server)) { @@ -1800,22 +1774,15 @@ func runGrpcProxyTest(t *testing.T, f func(hub1, hub2 *Hub, server1, server2 *ht f(hub1, hub2, server1, server2) } -func TestClientHelloResumeProxy(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { +func TestClientHelloResumeProxy(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { runGrpcProxyTest(t, func(hub1, hub2 *Hub, server1, server2 *httptest.Server) { require := require.New(t) assert := assert.New(t) - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client1.RunUntilHello(ctx) - require.NoError(err) + client1, hello := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId) assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) @@ -1827,54 +1794,45 @@ func TestClientHelloResumeProxy(t *testing.T) { defer client2.CloseWithBye() require.NoError(client2.SendHelloResume(hello.Hello.ResumeId)) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) assert.Equal(testDefaultUserId, hello2.Hello.UserId, "%+v", hello2.Hello) assert.Equal(hello.Hello.SessionId, hello2.Hello.SessionId, "%+v", hello2.Hello) assert.Equal(hello.Hello.ResumeId, hello2.Hello.ResumeId, "%+v", hello2.Hello) // Join room by id. roomId := "test-room" - roomMsg, err := client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // We will receive a "joined" event. - assert.NoError(client2.RunUntilJoined(ctx, hello.Hello)) + client2.RunUntilJoined(ctx, hello.Hello) room := hub1.getRoom(roomId) require.NotNil(room, "Could not find room %s", roomId) room2 := hub2.getRoom(roomId) require.Nil(room2, "Should not have gotten room %s", roomId) - users := []map[string]interface{}{ + users := []api.StringMap{ { "sessionId": "the-session-id", "inCall": 1, }, } room.PublishUsersInCallChanged(users, users) - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", nil)) + checkReceiveClientEvent(ctx, t, client2, "update", nil) }) }) } -func TestClientHelloResumeProxy_Takeover(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { +func TestClientHelloResumeProxy_Takeover(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { runGrpcProxyTest(t, func(hub1, hub2 *Hub, server1, server2 *httptest.Server) { require := require.New(t) assert := assert.New(t) - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client1.RunUntilHello(ctx) - require.NoError(err) + client1, hello := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId) assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) require.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) @@ -1883,69 +1841,52 @@ func TestClientHelloResumeProxy_Takeover(t *testing.T) { defer client2.CloseWithBye() require.NoError(client2.SendHelloResume(hello.Hello.ResumeId)) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) assert.Equal(testDefaultUserId, hello2.Hello.UserId, "%+v", hello2.Hello) assert.Equal(hello.Hello.SessionId, hello2.Hello.SessionId, "%+v", hello2.Hello) assert.Equal(hello.Hello.ResumeId, hello2.Hello.ResumeId, "%+v", hello2.Hello) // The first client got disconnected with a reason in a "Bye" message. - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { + if msg, ok := client1.RunUntilMessage(ctx); ok { assert.Equal("bye", msg.Type, "%+v", msg) if assert.NotNil(msg.Bye, "%+v", msg) { assert.Equal("session_resumed", msg.Bye.Reason, "%+v", msg) } } - if msg, err := client1.RunUntilMessage(ctx); err == nil { - assert.Fail("Expected error but received %+v", msg) - } else if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { - assert.Fail("Expected close error but received %+v", err) - } + client1.RunUntilClosed(ctx) client3 := NewTestClient(t, server1, hub1) defer client3.CloseWithBye() require.NoError(client3.SendHelloResume(hello.Hello.ResumeId)) - hello3, err := client3.RunUntilHello(ctx) - require.NoError(err) + hello3 := MustSucceed1(t, client3.RunUntilHello, ctx) assert.Equal(testDefaultUserId, hello3.Hello.UserId, "%+v", hello3.Hello) assert.Equal(hello.Hello.SessionId, hello3.Hello.SessionId, "%+v", hello3.Hello) assert.Equal(hello.Hello.ResumeId, hello3.Hello.ResumeId, "%+v", hello3.Hello) // The second client got disconnected with a reason in a "Bye" message. - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if msg, ok := client2.RunUntilMessage(ctx); ok { assert.Equal("bye", msg.Type, "%+v", msg) if assert.NotNil(msg.Bye, "%+v", msg) { assert.Equal("session_resumed", msg.Bye.Reason, "%+v", msg) } } - if msg, err := client2.RunUntilMessage(ctx); err == nil { - assert.Fail("Expected error but received %+v", msg) - } else if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { - assert.Fail("Expected close error but received %+v", err) - } + client2.RunUntilClosed(ctx) }) }) } -func TestClientHelloResumeProxy_Disconnect(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { +func TestClientHelloResumeProxy_Disconnect(t *testing.T) { // nolint:paralleltest + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { runGrpcProxyTest(t, func(hub1, hub2 *Hub, server1, server2 *httptest.Server) { require := require.New(t) assert := assert.New(t) - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client1.RunUntilHello(ctx) - require.NoError(err) + client1, hello := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId) assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) @@ -1957,14 +1898,13 @@ func TestClientHelloResumeProxy_Disconnect(t *testing.T) { defer client2.CloseWithBye() require.NoError(client2.SendHelloResume(hello.Hello.ResumeId)) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) assert.Equal(testDefaultUserId, hello2.Hello.UserId, "%+v", hello2.Hello) assert.Equal(hello.Hello.SessionId, hello2.Hello.SessionId, "%+v", hello2.Hello) assert.Equal(hello.Hello.ResumeId, hello2.Hello.ResumeId, "%+v", hello2.Hello) // Simulate unclean shutdown of second instance. - hub2.rpcServer.conn.Stop() + hub2.rpcServer.CloseUnclean() assert.NoError(client2.WaitForClientRemoved(ctx)) }) @@ -1973,29 +1913,20 @@ func TestClientHelloResumeProxy_Disconnect(t *testing.T) { func TestClientHelloClient(t *testing.T) { t.Parallel() - CatchLogForTest(t) - require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHelloClient(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { - assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) - assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) - assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) - } + _, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) + assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) } func TestClientHelloClient_V3Api(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -2008,12 +1939,12 @@ func TestClientHelloClient_V3Api(t *testing.T) { } // The "/api/v1/signaling/" URL will be changed to use "v3" as the "signaling-v3" // feature is returned by the capabilities endpoint. - require.NoError(client.SendHelloParams(server.URL+"/ocs/v2.php/apps/spreed/api/v1/signaling/backend", HelloVersionV1, "client", nil, params)) + require.NoError(client.SendHelloParams(server.URL, api.HelloVersionV1, "client", nil, params)) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { + if hello, ok := client.RunUntilHello(ctx); ok { assert.Equal(testDefaultUserId, hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) @@ -2022,7 +1953,6 @@ func TestClientHelloClient_V3Api(t *testing.T) { func TestClientHelloInternal(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -2035,7 +1965,7 @@ func TestClientHelloInternal(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - if hello, err := client.RunUntilHello(ctx); assert.NoError(err) { + if hello, ok := client.RunUntilHello(ctx); ok { assert.Empty(hello.Hello.UserId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) @@ -2043,7 +1973,7 @@ func TestClientHelloInternal(t *testing.T) { } func TestClientMessageToSessionId(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -2063,43 +1993,35 @@ func TestClientMessageToSessionId(t *testing.T) { hub1, hub2, server1, server2 = CreateClusteredHubsForTest(t) } - mcu1, err := NewTestMCU() - require.NoError(err) + mcu1 := sfutest.NewSFU(t) hub1.SetMcu(mcu1) if hub1 != hub2 { - mcu2, err := NewTestMCU() - require.NoError(err) + mcu2 := sfutest.NewSFU(t) hub2.SetMcu(mcu2) } - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) - recipient1 := MessageClientMessageRecipient{ + // Make sure the session subscription events are processed. + eventstest.WaitForAsyncEventsFlushed(ctx, t, hub1.events) + eventstest.WaitForAsyncEventsFlushed(ctx, t, hub2.events) + + recipient1 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, } - recipient2 := MessageClientMessageRecipient{ + recipient2 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, } - data1 := map[string]interface{}{ + data1 := api.StringMap{ "type": "test", "message": "from-1-to-2", } @@ -2108,11 +2030,11 @@ func TestClientMessageToSessionId(t *testing.T) { client2.SendMessage(recipient1, data2) // nolint var payload1 string - if err := checkReceiveClientMessage(ctx, client1, "session", hello2.Hello, &payload1); assert.NoError(err) { + if checkReceiveClientMessage(ctx, t, client1, "session", hello2.Hello, &payload1) { assert.Equal(data2, payload1) } - var payload2 map[string]interface{} - if err := checkReceiveClientMessage(ctx, client2, "session", hello1.Hello, &payload2); assert.NoError(err) { + var payload2 api.StringMap + if checkReceiveClientMessage(ctx, t, client2, "session", hello1.Hello, &payload2) { assert.Equal(data1, payload2) } }) @@ -2120,7 +2042,7 @@ func TestClientMessageToSessionId(t *testing.T) { } func TestClientControlToSessionId(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -2140,28 +2062,22 @@ func TestClientControlToSessionId(t *testing.T) { hub1, hub2, server1, server2 = CreateClusteredHubsForTest(t) } - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) - recipient1 := MessageClientMessageRecipient{ + // Make sure the session subscription events are processed. + eventstest.WaitForAsyncEventsFlushed(ctx, t, hub1.events) + eventstest.WaitForAsyncEventsFlushed(ctx, t, hub2.events) + + recipient1 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, } - recipient2 := MessageClientMessageRecipient{ + recipient2 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, } @@ -2172,10 +2088,10 @@ func TestClientControlToSessionId(t *testing.T) { client2.SendControl(recipient1, data2) // nolint var payload string - if err := checkReceiveClientControl(ctx, client1, "session", hello2.Hello, &payload); assert.NoError(err) { + if checkReceiveClientControl(ctx, t, client1, "session", hello2.Hello, &payload) { assert.Equal(data2, payload) } - if err := checkReceiveClientControl(ctx, client2, "session", hello1.Hello, &payload); assert.NoError(err) { + if checkReceiveClientControl(ctx, t, client2, "session", hello1.Hello, &payload) { assert.Equal(data1, payload) } }) @@ -2184,26 +2100,15 @@ func TestClientControlToSessionId(t *testing.T) { func TestClientControlMissingPermissions(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId).(*ClientSession) @@ -2212,22 +2117,22 @@ func TestClientControlMissingPermissions(t *testing.T) { require.NotNil(session2, "Session %s does not exist", hello2.Hello.SessionId) // Client 1 may not send control messages (will be ignored). - session1.SetPermissions([]Permission{ - PERMISSION_MAY_PUBLISH_AUDIO, - PERMISSION_MAY_PUBLISH_VIDEO, + session1.SetPermissions([]api.Permission{ + api.PERMISSION_MAY_PUBLISH_AUDIO, + api.PERMISSION_MAY_PUBLISH_VIDEO, }) // Client 2 may send control messages. - session2.SetPermissions([]Permission{ - PERMISSION_MAY_PUBLISH_AUDIO, - PERMISSION_MAY_PUBLISH_VIDEO, - PERMISSION_MAY_CONTROL, + session2.SetPermissions([]api.Permission{ + api.PERMISSION_MAY_PUBLISH_AUDIO, + api.PERMISSION_MAY_PUBLISH_VIDEO, + api.PERMISSION_MAY_CONTROL, }) - recipient1 := MessageClientMessageRecipient{ + recipient1 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, } - recipient2 := MessageClientMessageRecipient{ + recipient2 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, } @@ -2238,50 +2143,35 @@ func TestClientControlMissingPermissions(t *testing.T) { client2.SendControl(recipient1, data2) // nolint var payload string - if err := checkReceiveClientControl(ctx, client1, "session", hello2.Hello, &payload); assert.NoError(err) { + if checkReceiveClientControl(ctx, t, client1, "session", hello2.Hello, &payload) { assert.Equal(data2, payload) } ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel2() - if err := checkReceiveClientMessage(ctx2, client2, "session", hello1.Hello, &payload); err == nil { - assert.Fail("Expected no payload, got %+v", payload) - } else { - assert.ErrorIs(err, ErrNoMessageReceived) - } + client2.RunUntilErrorIs(ctx2, context.DeadlineExceeded) } func TestClientMessageToUserId(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) require.NotEqual(hello1.Hello.UserId, hello2.Hello.UserId) - recipient1 := MessageClientMessageRecipient{ + recipient1 := api.MessageClientMessageRecipient{ Type: "user", UserId: hello1.Hello.UserId, } - recipient2 := MessageClientMessageRecipient{ + recipient2 := api.MessageClientMessageRecipient{ Type: "user", UserId: hello2.Hello.UserId, } @@ -2292,45 +2182,34 @@ func TestClientMessageToUserId(t *testing.T) { client2.SendMessage(recipient1, data2) // nolint var payload string - if err := checkReceiveClientMessage(ctx, client1, "user", hello2.Hello, &payload); assert.NoError(err) { + if checkReceiveClientMessage(ctx, t, client1, "user", hello2.Hello, &payload) { assert.Equal(data2, payload) } - if err := checkReceiveClientMessage(ctx, client2, "user", hello1.Hello, &payload); assert.NoError(err) { + if checkReceiveClientMessage(ctx, t, client2, "user", hello1.Hello, &payload) { assert.Equal(data1, payload) } } func TestClientControlToUserId(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) require.NotEqual(hello1.Hello.UserId, hello2.Hello.UserId) - recipient1 := MessageClientMessageRecipient{ + recipient1 := api.MessageClientMessageRecipient{ Type: "user", UserId: hello1.Hello.UserId, } - recipient2 := MessageClientMessageRecipient{ + recipient2 := api.MessageClientMessageRecipient{ Type: "user", UserId: hello2.Hello.UserId, } @@ -2341,41 +2220,27 @@ func TestClientControlToUserId(t *testing.T) { client2.SendControl(recipient1, data2) // nolint var payload string - if err := checkReceiveClientControl(ctx, client1, "user", hello2.Hello, &payload); assert.NoError(err) { + if checkReceiveClientControl(ctx, t, client1, "user", hello2.Hello, &payload) { assert.Equal(data2, payload) } - if err := checkReceiveClientControl(ctx, client2, "user", hello1.Hello, &payload); assert.NoError(err) { + if checkReceiveClientControl(ctx, t, client2, "user", hello1.Hello, &payload) { assert.Equal(data1, payload) } } func TestClientMessageToUserIdMultipleSessions(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2a := NewTestClient(t, server, hub) - defer client2a.CloseWithBye() - require.NoError(client2a.SendHello(testDefaultUserId + "2")) - client2b := NewTestClient(t, server, hub) - defer client2b.CloseWithBye() - require.NoError(client2b.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2a, err := client2a.RunUntilHello(ctx) - require.NoError(err) - hello2b, err := client2b.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2a, hello2a := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") + client2b, hello2b := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2a.Hello.SessionId) require.NotEqual(hello1.Hello.SessionId, hello2b.Hello.SessionId) @@ -2385,7 +2250,7 @@ func TestClientMessageToUserIdMultipleSessions(t *testing.T) { require.NotEqual(hello1.Hello.UserId, hello2b.Hello.UserId) require.Equal(hello2a.Hello.UserId, hello2b.Hello.UserId) - recipient := MessageClientMessageRecipient{ + recipient := api.MessageClientMessageRecipient{ Type: "user", UserId: hello2a.Hello.UserId, } @@ -2395,23 +2260,16 @@ func TestClientMessageToUserIdMultipleSessions(t *testing.T) { // Both clients will receive the message as it was sent to the user. var payload string - if err := checkReceiveClientMessage(ctx, client2a, "user", hello1.Hello, &payload); assert.NoError(err) { + if checkReceiveClientMessage(ctx, t, client2a, "user", hello1.Hello, &payload) { assert.Equal(data1, payload) } - if err := checkReceiveClientMessage(ctx, client2b, "user", hello1.Hello, &payload); assert.NoError(err) { + if checkReceiveClientMessage(ctx, t, client2b, "user", hello1.Hello, &payload) { assert.Equal(data1, payload) } } -func WaitForUsersJoined(ctx context.Context, t *testing.T, client1 *TestClient, hello1 *ServerMessage, client2 *TestClient, hello2 *ServerMessage) { - // We will receive "joined" events for all clients. The ordering is not - // defined as messages are processed and sent by asynchronous event handlers. - assert.NoError(t, client1.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) - assert.NoError(t, client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) -} - func TestClientMessageToRoom(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -2434,37 +2292,25 @@ func TestClientMessageToRoom(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) require.NotEqual(hello1.Hello.UserId, hello2.Hello.UserId) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Give message processing some time. time.Sleep(10 * time.Millisecond) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) - recipient := MessageClientMessageRecipient{ + recipient := api.MessageClientMessageRecipient{ Type: "room", } @@ -2474,11 +2320,11 @@ func TestClientMessageToRoom(t *testing.T) { client2.SendMessage(recipient, data2) // nolint var payload string - if err := checkReceiveClientMessage(ctx, client1, "room", hello2.Hello, &payload); assert.NoError(err) { + if checkReceiveClientMessage(ctx, t, client1, "room", hello2.Hello, &payload) { assert.Equal(data2, payload) } - if err := checkReceiveClientMessage(ctx, client2, "room", hello1.Hello, &payload); assert.NoError(err) { + if checkReceiveClientMessage(ctx, t, client2, "room", hello1.Hello, &payload) { assert.Equal(data1, payload) } }) @@ -2486,7 +2332,7 @@ func TestClientMessageToRoom(t *testing.T) { } func TestClientControlToRoom(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -2509,37 +2355,25 @@ func TestClientControlToRoom(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) require.NotEqual(hello1.Hello.UserId, hello2.Hello.UserId) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Give message processing some time. time.Sleep(10 * time.Millisecond) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) - recipient := MessageClientMessageRecipient{ + recipient := api.MessageClientMessageRecipient{ Type: "room", } @@ -2549,11 +2383,11 @@ func TestClientControlToRoom(t *testing.T) { client2.SendControl(recipient, data2) // nolint var payload string - if err := checkReceiveClientControl(ctx, client1, "room", hello2.Hello, &payload); assert.NoError(err) { + if checkReceiveClientControl(ctx, t, client1, "room", hello2.Hello, &payload) { assert.Equal(data2, payload) } - if err := checkReceiveClientControl(ctx, client2, "room", hello1.Hello, &payload); assert.NoError(err) { + if checkReceiveClientControl(ctx, t, client2, "room", hello1.Hello, &payload) { assert.Equal(data1, payload) } }) @@ -2561,7 +2395,7 @@ func TestClientControlToRoom(t *testing.T) { } func TestClientMessageToCall(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -2584,38 +2418,26 @@ func TestClientMessageToCall(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) require.NotEqual(hello1.Hello.UserId, hello2.Hello.UserId) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Give message processing some time. time.Sleep(10 * time.Millisecond) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) // Simulate request from the backend that somebody joined the call. - users := []map[string]interface{}{ + users := []api.StringMap{ { "sessionId": hello1.Hello.SessionId, "inCall": 1, @@ -2624,10 +2446,10 @@ func TestClientMessageToCall(t *testing.T) { room1 := hub1.getRoom(roomId) require.NotNil(room1, "Could not find room %s", roomId) room1.PublishUsersInCallChanged(users, users) - assert.NoError(checkReceiveClientEvent(ctx, client1, "update", nil)) - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", nil)) + checkReceiveClientEvent(ctx, t, client1, "update", nil) + checkReceiveClientEvent(ctx, t, client2, "update", nil) - recipient := MessageClientMessageRecipient{ + recipient := api.MessageClientMessageRecipient{ Type: "call", } @@ -2637,7 +2459,7 @@ func TestClientMessageToCall(t *testing.T) { client2.SendMessage(recipient, data2) // nolint var payload string - if err := checkReceiveClientMessage(ctx, client1, "call", hello2.Hello, &payload); assert.NoError(err) { + if checkReceiveClientMessage(ctx, t, client1, "call", hello2.Hello, &payload) { assert.Equal(data2, payload) } @@ -2645,14 +2467,10 @@ func TestClientMessageToCall(t *testing.T) { ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel2() - if message, err := client2.RunUntilMessage(ctx2); err == nil { - assert.Fail("Expected no message", "got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client2.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) // Simulate request from the backend that somebody joined the call. - users = []map[string]interface{}{ + users = []api.StringMap{ { "sessionId": hello1.Hello.SessionId, "inCall": 1, @@ -2665,16 +2483,16 @@ func TestClientMessageToCall(t *testing.T) { room2 := hub2.getRoom(roomId) require.NotNil(room2, "Could not find room %s", roomId) room2.PublishUsersInCallChanged(users, users) - assert.NoError(checkReceiveClientEvent(ctx, client1, "update", nil)) - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", nil)) + checkReceiveClientEvent(ctx, t, client1, "update", nil) + checkReceiveClientEvent(ctx, t, client2, "update", nil) client1.SendMessage(recipient, data1) // nolint client2.SendMessage(recipient, data2) // nolint - if err := checkReceiveClientMessage(ctx, client1, "call", hello2.Hello, &payload); assert.NoError(err) { + if checkReceiveClientMessage(ctx, t, client1, "call", hello2.Hello, &payload) { assert.Equal(data2, payload) } - if err := checkReceiveClientMessage(ctx, client2, "call", hello1.Hello, &payload); assert.NoError(err) { + if checkReceiveClientMessage(ctx, t, client2, "call", hello1.Hello, &payload) { assert.Equal(data1, payload) } }) @@ -2682,7 +2500,7 @@ func TestClientMessageToCall(t *testing.T) { } func TestClientControlToCall(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -2705,38 +2523,26 @@ func TestClientControlToCall(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) require.NotEqual(hello1.Hello.UserId, hello2.Hello.UserId) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Give message processing some time. time.Sleep(10 * time.Millisecond) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) // Simulate request from the backend that somebody joined the call. - users := []map[string]interface{}{ + users := []api.StringMap{ { "sessionId": hello1.Hello.SessionId, "inCall": 1, @@ -2745,10 +2551,10 @@ func TestClientControlToCall(t *testing.T) { room1 := hub1.getRoom(roomId) require.NotNil(room1, "Could not find room %s", roomId) room1.PublishUsersInCallChanged(users, users) - assert.NoError(checkReceiveClientEvent(ctx, client1, "update", nil)) - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", nil)) + checkReceiveClientEvent(ctx, t, client1, "update", nil) + checkReceiveClientEvent(ctx, t, client2, "update", nil) - recipient := MessageClientMessageRecipient{ + recipient := api.MessageClientMessageRecipient{ Type: "call", } @@ -2758,7 +2564,7 @@ func TestClientControlToCall(t *testing.T) { client2.SendControl(recipient, data2) // nolint var payload string - if err := checkReceiveClientControl(ctx, client1, "call", hello2.Hello, &payload); assert.NoError(err) { + if checkReceiveClientControl(ctx, t, client1, "call", hello2.Hello, &payload) { assert.Equal(data2, payload) } @@ -2766,14 +2572,10 @@ func TestClientControlToCall(t *testing.T) { ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel2() - if message, err := client2.RunUntilMessage(ctx2); err == nil { - assert.Fail("Expected no message", "got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client2.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) // Simulate request from the backend that somebody joined the call. - users = []map[string]interface{}{ + users = []api.StringMap{ { "sessionId": hello1.Hello.SessionId, "inCall": 1, @@ -2786,16 +2588,16 @@ func TestClientControlToCall(t *testing.T) { room2 := hub2.getRoom(roomId) require.NotNil(room2, "Could not find room %s", roomId) room2.PublishUsersInCallChanged(users, users) - assert.NoError(checkReceiveClientEvent(ctx, client1, "update", nil)) - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", nil)) + checkReceiveClientEvent(ctx, t, client1, "update", nil) + checkReceiveClientEvent(ctx, t, client2, "update", nil) client1.SendControl(recipient, data1) // nolint client2.SendControl(recipient, data2) // nolint - if err := checkReceiveClientControl(ctx, client1, "call", hello2.Hello, &payload); assert.NoError(err) { + if checkReceiveClientControl(ctx, t, client1, "call", hello2.Hello, &payload) { assert.Equal(data2, payload) } - if err := checkReceiveClientControl(ctx, client2, "call", hello1.Hello, &payload); assert.NoError(err) { + if checkReceiveClientControl(ctx, t, client2, "call", hello1.Hello, &payload) { assert.Equal(data1, payload) } }) @@ -2804,162 +2606,229 @@ func TestClientControlToCall(t *testing.T) { func TestJoinRoom(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() - require.NoError(client.SendHello(testDefaultUserId)) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + assert.Nil(roomMsg.Room.Bandwidth) + + // We will receive a "joined" event. + client.RunUntilJoined(ctx, hello.Hello) + + // Leave room. + roomMsg = MustSucceed2(t, client.JoinRoom, ctx, "") + require.Empty(roomMsg.Room.RoomId) +} + +func TestJoinRoomBackendBandwidth(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTestWithConfig(t, func(server *httptest.Server) (*goconf.ConfigFile, error) { + config, err := getTestConfig(server) + if err != nil { + return nil, err + } + + config.AddOption("backend", "maxstreambitrate", "1000") + config.AddOption("backend", "maxscreenbitrate", "2000") + return config, nil + }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // Join room by id. roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) - // We will receive a "joined" event. - assert.NoError(client.RunUntilJoined(ctx, hello.Hello)) + if bw := roomMsg.Room.Bandwidth; assert.NotNil(bw) { + assert.EqualValues(1000, bw.MaxStreamBitrate) + assert.EqualValues(2000, bw.MaxScreenBitrate) + } - // Leave room. - roomMsg, err = client.JoinRoom(ctx, "") - require.NoError(err) - require.Equal("", roomMsg.Room.RoomId) + // We will receive a "joined" event. + client.RunUntilJoined(ctx, hello.Hello) +} + +func TestJoinRoomMcuBandwidth(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + mcu := sfutest.NewSFU(t) + hub.SetMcu(mcu) + + mcu.SetBandwidthLimits(1000, 2000) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + if bw := roomMsg.Room.Bandwidth; assert.NotNil(bw) { + assert.EqualValues(1000, bw.MaxStreamBitrate) + assert.EqualValues(2000, bw.MaxScreenBitrate) + } + + // We will receive a "joined" event. + client.RunUntilJoined(ctx, hello.Hello) +} + +func TestJoinRoomPreferMcuBandwidth(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTestWithConfig(t, func(server *httptest.Server) (*goconf.ConfigFile, error) { + config, err := getTestConfig(server) + if err != nil { + return nil, err + } + + config.AddOption("backend", "maxstreambitrate", "1000") + config.AddOption("backend", "maxscreenbitrate", "2000") + return config, nil + }) + + mcu := sfutest.NewSFU(t) + hub.SetMcu(mcu) + + // The MCU bandwidth limits overwrite any backend limits. + mcu.SetBandwidthLimits(100, 200) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + if bw := roomMsg.Room.Bandwidth; assert.NotNil(bw) { + assert.EqualValues(100, bw.MaxStreamBitrate) + assert.EqualValues(200, bw.MaxScreenBitrate) + } + + // We will receive a "joined" event. + client.RunUntilJoined(ctx, hello.Hello) } func TestJoinInvalidRoom(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // Join room by id. roomId := "test-invalid-room" - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: "ABCD", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: roomId, - SessionId: roomId + "-" + hello.Hello.SessionId, + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", roomId, hello.Hello.SessionId)), }, } require.NoError(client.WriteJSON(msg)) - message, err := client.RunUntilMessage(ctx) - require.NoError(err) - require.NoError(checkUnexpectedClose(err)) - - assert.Equal(msg.Id, message.Id) - if assert.NoError(checkMessageType(message, "error")) { - assert.Equal("no_such_room", message.Error.Code) + if message, ok := client.RunUntilMessage(ctx); ok { + assert.Equal(msg.Id, message.Id) + if checkMessageType(t, message, "error") { + assert.Equal("no_such_room", message.Error.Code) + } } } func TestJoinRoomTwice(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // Join room by id. roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) require.Equal(string(testRoomProperties), string(roomMsg.Room.Properties)) // We will receive a "joined" event. - assert.NoError(client.RunUntilJoined(ctx, hello.Hello)) + client.RunUntilJoined(ctx, hello.Hello) - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: "ABCD", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: roomId, - SessionId: roomId + "-" + client.publicId + "-2", + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s-2", roomId, client.publicId)), }, } require.NoError(client.WriteJSON(msg)) - message, err := client.RunUntilMessage(ctx) - require.NoError(err) - require.NoError(checkUnexpectedClose(err)) - - assert.Equal(msg.Id, message.Id) - if assert.NoError(checkMessageType(message, "error")) { - assert.Equal("already_joined", message.Error.Code) - assert.NotNil(message.Error.Details) - } - - var roomDetails RoomErrorDetails - if err := json.Unmarshal(message.Error.Details, &roomDetails); assert.NoError(err) { - if assert.NotNil(roomDetails.Room, "%+v", message) { - assert.Equal(roomId, roomDetails.Room.RoomId) - assert.Equal(string(testRoomProperties), string(roomDetails.Room.Properties)) + if message, ok := client.RunUntilMessage(ctx); ok { + assert.Equal(msg.Id, message.Id) + if checkMessageType(t, message, "error") { + assert.Equal("already_joined", message.Error.Code) + if assert.NotEmpty(message.Error.Details) { + var roomDetails api.RoomErrorDetails + if err := json.Unmarshal(message.Error.Details, &roomDetails); assert.NoError(err) { + if assert.NotNil(roomDetails.Room, "%+v", message) { + assert.Equal(roomId, roomDetails.Room.RoomId) + assert.Equal(string(testRoomProperties), string(roomDetails.Room.Properties)) + } + } + } } } } func TestExpectAnonymousJoinRoom(t *testing.T) { t.Parallel() - CatchLogForTest(t) - require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(authAnonymousUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - if assert.NoError(err) { - assert.Empty(hello.Hello.UserId, "%+v", hello.Hello) - assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) - assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) - } + client, hello := NewTestClientWithHello(ctx, t, server, hub, authAnonymousUserId) + assert.Empty(hello.Hello.UserId, "%+v", hello.Hello) + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) + assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) // Perform housekeeping in the future, this will cause the connection to // be terminated because the anonymous client didn't join a room. - performHousekeeping(hub, time.Now().Add(anonmyousJoinRoomTimeout+time.Second)) + hub.performHousekeeping(time.Now().Add(anonmyousJoinRoomTimeout + time.Second)) - if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { - if assert.NoError(checkMessageType(message, "bye")) { + if message, ok := client.RunUntilMessage(ctx); ok { + if checkMessageType(t, message, "bye") { assert.Equal("room_join_timeout", message.Bye.Reason, "%+v", message.Bye) } } @@ -2971,60 +2840,46 @@ func TestExpectAnonymousJoinRoom(t *testing.T) { func TestExpectAnonymousJoinRoomAfterLeave(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(authAnonymousUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - if assert.NoError(err) { - assert.Empty(hello.Hello.UserId, "%+v", hello.Hello) - assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) - assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) - } + client, hello := NewTestClientWithHello(ctx, t, server, hub, authAnonymousUserId) + assert.Empty(hello.Hello.UserId, "%+v", hello.Hello) + assert.NotEmpty(hello.Hello.SessionId, "%+v", hello.Hello) + assert.NotEmpty(hello.Hello.ResumeId, "%+v", hello.Hello) // Join room by id. roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // We will receive a "joined" event. - assert.NoError(client.RunUntilJoined(ctx, hello.Hello)) + client.RunUntilJoined(ctx, hello.Hello) // Perform housekeeping in the future, this will keep the connection as the // session joined a room. - performHousekeeping(hub, time.Now().Add(anonmyousJoinRoomTimeout+time.Second)) + hub.performHousekeeping(time.Now().Add(anonmyousJoinRoomTimeout + time.Second)) // No message about the closing is sent to the new connection. ctx2, cancel2 := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel2() - if message, err := client.RunUntilMessage(ctx2); err == nil { - assert.Fail("Expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) // Leave room - roomMsg, err = client.JoinRoom(ctx, "") - require.NoError(err) - require.Equal("", roomMsg.Room.RoomId) + roomMsg = MustSucceed2(t, client.JoinRoom, ctx, "") + require.Empty(roomMsg.Room.RoomId) // Perform housekeeping in the future, this will cause the connection to // be terminated because the anonymous client didn't join a room. - performHousekeeping(hub, time.Now().Add(anonmyousJoinRoomTimeout+time.Second)) + hub.performHousekeeping(time.Now().Add(anonmyousJoinRoomTimeout + time.Second)) - if message, err := client.RunUntilMessage(ctx); assert.NoError(err) { - if assert.NoError(checkMessageType(message, "bye")) { + if message, ok := client.RunUntilMessage(ctx); ok { + if checkMessageType(t, message, "bye") { assert.Equal("room_join_timeout", message.Bye.Reason, "%+v", message.Bye) } } @@ -3036,146 +2891,107 @@ func TestExpectAnonymousJoinRoomAfterLeave(t *testing.T) { func TestJoinRoomChange(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) - assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // Join room by id. roomId := "test-room" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // We will receive a "joined" event. - assert.NoError(client.RunUntilJoined(ctx, hello.Hello)) + client.RunUntilJoined(ctx, hello.Hello) // Change room. roomId = "other-test-room" - roomMsg, err = client.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // We will receive a "joined" event. - assert.NoError(client.RunUntilJoined(ctx, hello.Hello)) + client.RunUntilJoined(ctx, hello.Hello) // Leave room. - roomMsg, err = client.JoinRoom(ctx, "") - require.NoError(err) - require.Equal("", roomMsg.Room.RoomId) + roomMsg = MustSucceed2(t, client.JoinRoom, ctx, "") + require.Empty(roomMsg.Room.RoomId) } func TestJoinMultiple(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) - assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) // Join room by id (first client). roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // We will receive a "joined" event. - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) // Join room by id (second client). - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // We will receive a "joined" event for the first and the second client. - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) // The first client will also receive a "joined" event from the second client. - assert.NoError(client1.RunUntilJoined(ctx, hello2.Hello)) + client1.RunUntilJoined(ctx, hello2.Hello) // Leave room. - roomMsg, err = client1.JoinRoom(ctx, "") - require.NoError(err) - require.Equal("", roomMsg.Room.RoomId) + roomMsg = MustSucceed2(t, client1.JoinRoom, ctx, "") + require.Empty(roomMsg.Room.RoomId) // The second client will now receive a "left" event - assert.NoError(client2.RunUntilLeft(ctx, hello1.Hello)) + client2.RunUntilLeft(ctx, hello1.Hello) - roomMsg, err = client2.JoinRoom(ctx, "") - require.NoError(err) - require.Equal("", roomMsg.Room.RoomId) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, "") + require.Empty(roomMsg.Room.RoomId) } func TestJoinDisplaynamesPermission(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") session2 := hub.GetSessionByPublicId(hello2.Hello.SessionId).(*ClientSession) require.NotNil(session2, "Session %s does not exist", hello2.Hello.SessionId) // Client 2 may not receive display names. - session2.SetPermissions([]Permission{PERMISSION_HIDE_DISPLAYNAMES}) + session2.SetPermissions([]api.Permission{api.PERMISSION_HIDE_DISPLAYNAMES}) // Join room by id (first client). roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // We will receive a "joined" event. - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) // Join room by id (second client). - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // We will receive a "joined" event for the first and the second client. - if events, unexpected, err := client2.RunUntilJoinedAndReturn(ctx, hello1.Hello, hello2.Hello); assert.NoError(err) { + if events, unexpected, ok := client2.RunUntilJoinedAndReturn(ctx, hello1.Hello, hello2.Hello); ok { assert.Empty(unexpected) if assert.Len(events, 2) { assert.Nil(events[0].User) @@ -3183,7 +2999,7 @@ func TestJoinDisplaynamesPermission(t *testing.T) { } } // The first client will also receive a "joined" event from the second client. - if events, unexpected, err := client1.RunUntilJoinedAndReturn(ctx, hello2.Hello); assert.NoError(err) { + if events, unexpected, ok := client1.RunUntilJoinedAndReturn(ctx, hello2.Hello); ok { assert.Empty(unexpected) if assert.Len(events, 1) { assert.NotNil(events[0].User) @@ -3193,7 +3009,6 @@ func TestJoinDisplaynamesPermission(t *testing.T) { func TestInitialRoomPermissions(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -3201,55 +3016,41 @@ func TestInitialRoomPermissions(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId + "1")) - - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // Join room by id. roomId := "test-room-initial-permissions" - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) - assert.NoError(client.RunUntilJoined(ctx, hello.Hello)) + client.RunUntilJoined(ctx, hello.Hello) session := hub.GetSessionByPublicId(hello.Hello.SessionId).(*ClientSession) require.NotNil(session, "Session %s does not exist", hello.Hello.SessionId) - assert.True(session.HasPermission(PERMISSION_MAY_PUBLISH_AUDIO), "Session %s should have %s, got %+v", session.PublicId(), PERMISSION_MAY_PUBLISH_AUDIO, session.permissions) - assert.False(session.HasPermission(PERMISSION_MAY_PUBLISH_VIDEO), "Session %s should not have %s, got %+v", session.PublicId(), PERMISSION_MAY_PUBLISH_VIDEO, session.permissions) + assert.True(session.HasPermission(api.PERMISSION_MAY_PUBLISH_AUDIO), "Session %s should have %s, got %+v", session.PublicId(), api.PERMISSION_MAY_PUBLISH_AUDIO, session.GetPermissions()) + assert.False(session.HasPermission(api.PERMISSION_MAY_PUBLISH_VIDEO), "Session %s should not have %s, got %+v", session.PublicId(), api.PERMISSION_MAY_PUBLISH_VIDEO, session.GetPermissions()) } func TestJoinRoomSwitchClient(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello, err := client.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // Join room by id. roomId := "test-room-slow" - msg := &ClientMessage{ + msg := &api.ClientMessage{ Id: "ABCD", Type: "room", - Room: &RoomClientMessage{ + Room: &api.RoomClientMessage{ RoomId: roomId, - SessionId: roomId + "-" + hello.Hello.SessionId, + SessionId: api.RoomSessionId(fmt.Sprintf("%s-%s", roomId, hello.Hello.SessionId)), }, } require.NoError(client.WriteJSON(msg)) @@ -3264,291 +3065,50 @@ func TestJoinRoomSwitchClient(t *testing.T) { client2 := NewTestClient(t, server, hub) defer client2.CloseWithBye() require.NoError(client2.SendHelloResume(hello.Hello.ResumeId)) - if hello2, err := client2.RunUntilHello(ctx); assert.NoError(err) { + if hello2, ok := client2.RunUntilHello(ctx); ok { assert.Equal(testDefaultUserId, hello2.Hello.UserId, "%+v", hello2.Hello) assert.Equal(hello.Hello.SessionId, hello2.Hello.SessionId, "%+v", hello2.Hello) assert.Equal(hello.Hello.ResumeId, hello2.Hello.ResumeId, "%+v", hello2.Hello) } - roomMsg, err := client2.RunUntilMessage(ctx) - require.NoError(err) - require.NoError(checkUnexpectedClose(err)) - require.NoError(checkMessageType(roomMsg, "room")) - require.Equal(roomId, roomMsg.Room.RoomId) + roomMsg := MustSucceed1(t, client2.RunUntilMessage, ctx) + if checkMessageType(t, roomMsg, "room") { + assert.Equal(roomId, roomMsg.Room.RoomId) + } // We will receive a "joined" event. - assert.NoError(client2.RunUntilJoined(ctx, hello.Hello)) + client2.RunUntilJoined(ctx, hello.Hello) // Leave room. - roomMsg, err = client2.JoinRoom(ctx, "") - require.NoError(err) - require.Equal("", roomMsg.Room.RoomId) -} - -func TestGetRealUserIP(t *testing.T) { - testcases := []struct { - expected string - headers http.Header - trusted string - addr string - }{ - { - "192.168.1.2", - nil, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "10.11.12.13", - nil, - "192.168.0.0/16", - "10.11.12.13:23456", - }, - { - "10.11.12.13", - http.Header{ - http.CanonicalHeaderKey("x-real-ip"): []string{"10.11.12.13"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "2002:db8::1", - http.Header{ - http.CanonicalHeaderKey("x-real-ip"): []string{"2002:db8::1"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "11.12.13.14", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "10.11.12.13", - http.Header{ - http.CanonicalHeaderKey("x-real-ip"): []string{"10.11.12.13"}, - }, - "2001:db8::/48", - "[2001:db8::1]:23456", - }, - { - "2002:db8::1", - http.Header{ - http.CanonicalHeaderKey("x-real-ip"): []string{"2002:db8::1"}, - }, - "2001:db8::/48", - "[2001:db8::1]:23456", - }, - { - "2002:db8::1", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 192.168.30.32"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "2002:db8::1", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 2001:db8::1"}, - }, - "192.168.0.0/16, 2001:db8::/48", - "192.168.1.2:23456", - }, - { - "2002:db8::1", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 192.168.30.32"}, - }, - "192.168.0.0/16, 2001:db8::/48", - "[2001:db8::1]:23456", - }, - { - "2002:db8::1", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 2001:db8::2"}, - }, - "2001:db8::/48", - "[2001:db8::1]:23456", - }, - // "X-Real-IP" has preference before "X-Forwarded-For" - { - "10.11.12.13", - http.Header{ - http.CanonicalHeaderKey("x-real-ip"): []string{"10.11.12.13"}, - http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - // Multiple "X-Forwarded-For" headers are merged. - { - "11.12.13.14", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14", "192.168.30.32"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "11.12.13.14", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"1.2.3.4", "11.12.13.14", "192.168.30.32"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "11.12.13.14", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"1.2.3.4", "2.3.4.5", "11.12.13.14", "192.168.31.32", "192.168.30.32"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - // Headers are ignored if coming from untrusted clients. - { - "10.11.12.13", - http.Header{ - http.CanonicalHeaderKey("x-real-ip"): []string{"11.12.13.14"}, - }, - "192.168.0.0/16", - "10.11.12.13:23456", - }, - { - "10.11.12.13", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"}, - }, - "192.168.0.0/16", - "10.11.12.13:23456", - }, - // X-Forwarded-For is filtered for trusted proxies. - { - "1.2.3.4", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 1.2.3.4"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "1.2.3.4", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 1.2.3.4, 192.168.2.3"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "10.11.12.13", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 1.2.3.4"}, - }, - "192.168.0.0/16", - "10.11.12.13:23456", - }, - // Invalid IPs are ignored. - { - "192.168.1.2", - http.Header{ - http.CanonicalHeaderKey("x-real-ip"): []string{"this-is-not-an-ip"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "11.12.13.14", - http.Header{ - http.CanonicalHeaderKey("x-real-ip"): []string{"this-is-not-an-ip"}, - http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "11.12.13.14", - http.Header{ - http.CanonicalHeaderKey("x-real-ip"): []string{"this-is-not-an-ip"}, - http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32, proxy1"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "192.168.1.2", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"this-is-not-an-ip"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - { - "192.168.2.3", - http.Header{ - http.CanonicalHeaderKey("x-forwarded-for"): []string{"this-is-not-an-ip, 192.168.2.3"}, - }, - "192.168.0.0/16", - "192.168.1.2:23456", - }, - } - - for _, tc := range testcases { - trustedProxies, err := ParseAllowedIps(tc.trusted) - if !assert.NoError(t, err, "invalid trusted proxies in %+v", tc) { - continue - } - request := &http.Request{ - RemoteAddr: tc.addr, - Header: tc.headers, - } - assert.Equal(t, tc.expected, GetRealUserIP(request, trustedProxies), "failed for %+v", tc) - } + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, "") + require.Empty(roomMsg.Room.RoomId) } func TestClientMessageToSessionIdWhileDisconnected(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) client2.Close() assert.NoError(client2.WaitForClientRemoved(ctx)) - recipient2 := MessageClientMessageRecipient{ + recipient2 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, } - // The two chat messages should get combined into one when receiving pending messages. - chat_refresh := "{\"type\":\"chat\",\"chat\":{\"refresh\":true}}" - var data1 map[string]interface{} + chat_refresh := "{\"type\":\"foo\",\"foo\":{\"testing\":true}}" + var data1 api.StringMap require.NoError(json.Unmarshal([]byte(chat_refresh), &data1)) client1.SendMessage(recipient2, data1) // nolint - client1.SendMessage(recipient2, data1) // nolint // Simulate some time until client resumes the session. time.Sleep(10 * time.Millisecond) @@ -3556,68 +3116,120 @@ func TestClientMessageToSessionIdWhileDisconnected(t *testing.T) { client2 = NewTestClient(t, server, hub) defer client2.CloseWithBye() require.NoError(client2.SendHelloResume(hello2.Hello.ResumeId)) - if hello3, err := client2.RunUntilHello(ctx); assert.NoError(err) { + if hello3, ok := client2.RunUntilHello(ctx); ok { assert.Equal(testDefaultUserId+"2", hello3.Hello.UserId, "%+v", hello3.Hello) assert.Equal(hello2.Hello.SessionId, hello3.Hello.SessionId, "%+v", hello3.Hello) assert.Equal(hello2.Hello.ResumeId, hello3.Hello.ResumeId, "%+v", hello3.Hello) } - var payload map[string]interface{} - if err := checkReceiveClientMessage(ctx, client2, "session", hello1.Hello, &payload); assert.NoError(err) { + var payload api.StringMap + if checkReceiveClientMessage(ctx, t, client2, "session", hello1.Hello, &payload) { assert.Equal(data1, payload) } +} + +func TestCombineChatRefreshWhileDisconnected(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + client.RunUntilJoined(ctx, hello.Hello) + + room := hub.getRoom(roomId) + require.NotNil(room) + + client.Close() + assert.NoError(client.WaitForClientRemoved(ctx)) + + // The two chat messages should get combined into one when receiving pending messages. + chat_refresh := "{\"type\":\"chat\",\"chat\":{\"refresh\":true}}" + var data api.StringMap + require.NoError(json.Unmarshal([]byte(chat_refresh), &data)) + + // Simulate requests from the backend. + room.processAsyncMessage(&events.AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "message", + Message: &talk.BackendRoomMessageRequest{ + Data: json.RawMessage(chat_refresh), + }, + }, + }) + room.processAsyncMessage(&events.AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "message", + Message: &talk.BackendRoomMessageRequest{ + Data: json.RawMessage(chat_refresh), + }, + }, + }) + + // Simulate some time until client resumes the session. + time.Sleep(10 * time.Millisecond) + + client = NewTestClient(t, server, hub) + defer client.CloseWithBye() + require.NoError(client.SendHelloResume(hello.Hello.ResumeId)) + if hello2, ok := client.RunUntilHello(ctx); ok { + assert.Equal(hello.Hello.UserId, hello2.Hello.UserId) + assert.Equal(hello.Hello.SessionId, hello2.Hello.SessionId) + assert.Equal(hello.Hello.ResumeId, hello2.Hello.ResumeId) + } + + if msg, ok := client.RunUntilRoomMessage(ctx); ok { + assert.Equal(roomId, msg.RoomId) + var payload api.StringMap + if err := json.Unmarshal(msg.Data, &payload); assert.NoError(err) { + assert.Equal(data, payload) + } + } ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel2() - if err := checkReceiveClientMessage(ctx2, client2, "session", hello1.Hello, &payload); err == nil { - assert.Fail("Expected no payload, got %+v", payload) - } else { - assert.ErrorIs(err, ErrNoMessageReceived) - } + client.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) } func TestRoomParticipantsListUpdateWhileDisconnected(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") require.NotEqual(hello1.Hello.SessionId, hello2.Hello.SessionId) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Give message processing some time. time.Sleep(10 * time.Millisecond) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) // Simulate request from the backend that somebody joined the call. - users := []map[string]interface{}{ + users := []api.StringMap{ { "sessionId": "the-session-id", "inCall": 1, @@ -3626,7 +3238,7 @@ func TestRoomParticipantsListUpdateWhileDisconnected(t *testing.T) { room := hub.getRoom(roomId) require.NotNil(room, "Could not find room %s", roomId) room.PublishUsersInCallChanged(users, users) - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", nil)) + checkReceiveClientEvent(ctx, t, client2, "update", nil) client2.Close() assert.NoError(client2.WaitForClientRemoved(ctx)) @@ -3636,20 +3248,20 @@ func TestRoomParticipantsListUpdateWhileDisconnected(t *testing.T) { // Give asynchronous events some time to be processed. time.Sleep(100 * time.Millisecond) - recipient2 := MessageClientMessageRecipient{ + recipient2 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, } chat_refresh := "{\"type\":\"chat\",\"chat\":{\"refresh\":true}}" - var data1 map[string]interface{} + var data1 api.StringMap require.NoError(json.Unmarshal([]byte(chat_refresh), &data1)) client1.SendMessage(recipient2, data1) // nolint client2 = NewTestClient(t, server, hub) defer client2.CloseWithBye() require.NoError(client2.SendHelloResume(hello2.Hello.ResumeId)) - if hello3, err := client2.RunUntilHello(ctx); assert.NoError(err) { + if hello3, ok := client2.RunUntilHello(ctx); ok { assert.Equal(testDefaultUserId+"2", hello3.Hello.UserId, "%+v", hello3.Hello) assert.Equal(hello2.Hello.SessionId, hello3.Hello.SessionId, "%+v", hello3.Hello) assert.Equal(hello2.Hello.ResumeId, hello3.Hello.ResumeId, "%+v", hello3.Hello) @@ -3657,25 +3269,21 @@ func TestRoomParticipantsListUpdateWhileDisconnected(t *testing.T) { // The participants list update event is triggered again after the session resume. // TODO(jojo): Check contents of message and try with multiple users. - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", nil)) + checkReceiveClientEvent(ctx, t, client2, "update", nil) - var payload map[string]interface{} - if err := checkReceiveClientMessage(ctx, client2, "session", hello1.Hello, &payload); assert.NoError(err) { + var payload api.StringMap + if checkReceiveClientMessage(ctx, t, client2, "session", hello1.Hello, &payload) { assert.Equal(data1, payload) } ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel2() - if err := checkReceiveClientMessage(ctx2, client2, "session", hello1.Hello, &payload); err == nil { - assert.Fail("Expected no payload, got %+v", payload) - } else { - assert.ErrorIs(err, ErrNoMessageReceived) - } + client2.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) } func TestClientTakeoverRoomSession(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -3700,22 +3308,15 @@ func RunTestClientTakeoverRoomSession(t *testing.T) { hub1, hub2, server1, server2 = CreateClusteredHubsForTest(t) } - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId + "1")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") // Join room by id. roomId := "test-room-takeover-room-session" - roomSessionid := "room-session-id" - roomMsg, err := client1.JoinRoomWithRoomSession(ctx, roomId, roomSessionid) - require.NoError(err) + roomSessionid := api.RoomSessionId("room-session-id") + roomMsg := MustSucceed3(t, client1.JoinRoomWithRoomSession, ctx, roomId, roomSessionid) require.Equal(roomId, roomMsg.Room.RoomId) hubRoom := hub1.getRoom(roomId) @@ -3724,46 +3325,28 @@ func RunTestClientTakeoverRoomSession(t *testing.T) { session1 := hub1.GetSessionByPublicId(hello1.Hello.SessionId) require.NotNil(session1, "There should be a session %s", hello1.Hello.SessionId) - client3 := NewTestClient(t, server2, hub2) - defer client3.CloseWithBye() + client3, hello3 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"3") - require.NoError(client3.SendHello(testDefaultUserId + "3")) - - hello3, err := client3.RunUntilHello(ctx) - require.NoError(err) - - roomMsg, err = client3.JoinRoomWithRoomSession(ctx, roomId, roomSessionid+"other") - require.NoError(err) + roomMsg = MustSucceed3(t, client3.JoinRoomWithRoomSession, ctx, roomId, roomSessionid+"other") require.Equal(roomId, roomMsg.Room.RoomId) // Wait until both users have joined. WaitForUsersJoined(ctx, t, client1, hello1, client3, hello3) - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") - require.NoError(client2.SendHello(testDefaultUserId + "2")) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - - roomMsg, err = client2.JoinRoomWithRoomSession(ctx, roomId, roomSessionid) - require.NoError(err) + roomMsg = MustSucceed3(t, client2.JoinRoomWithRoomSession, ctx, roomId, roomSessionid) require.Equal(roomId, roomMsg.Room.RoomId) // The first client got disconnected with a reason in a "Bye" message. - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { + if msg, ok := client1.RunUntilMessage(ctx); ok { assert.Equal("bye", msg.Type, "%+v", msg) if assert.NotNil(msg.Bye, "%+v", msg) { assert.Equal("room_session_reconnected", msg.Bye.Reason, "%+v", msg) } } - if msg, err := client1.RunUntilMessage(ctx); err == nil { - assert.Fail("Expected error but received %+v", msg) - } else if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { - assert.Fail("Expected close error but received %+v", err) - } + client1.RunUntilClosed(ctx) // The first session has been closed session1 = hub1.GetSessionByPublicId(hello1.Hello.SessionId) @@ -3771,69 +3354,59 @@ func RunTestClientTakeoverRoomSession(t *testing.T) { // The new client will receive "joined" events for the existing client3 and // himself. - assert.NoError(client2.RunUntilJoined(ctx, hello3.Hello, hello2.Hello)) + client2.RunUntilJoined(ctx, hello3.Hello, hello2.Hello) // No message about the closing is sent to the new connection. ctx2, cancel2 := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel2() - if message, err := client2.RunUntilMessage(ctx2); err == nil { - assert.Fail("Expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client2.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) // The permanently connected client will receive a "left" event from the - // overridden session and a "joined" for the new session. In that order as - // both were on the same server. - assert.NoError(client3.RunUntilLeft(ctx, hello1.Hello)) - assert.NoError(client3.RunUntilJoined(ctx, hello2.Hello)) + // overridden session and a "joined" for the new session. + msg1 := MustSucceed1(t, client3.RunUntilMessage, ctx) + msg2 := MustSucceed1(t, client3.RunUntilMessage, ctx) + if msg1.Type == "event" && msg2.Type == "event" && msg1.Event.Type == "join" { + t.Logf("Switching messages order") + msg1, msg2 = msg2, msg1 + } + + client3.checkMessageRoomLeave(msg1, hello1.Hello) + if checkMessageType(t, msg2, "event") && + assert.Equal("room", msg2.Event.Target, "invalid target in %+v", msg2) && + assert.Equal("join", msg2.Event.Type, "invalid event type in %+v", msg2) && + assert.Len(msg2.Event.Join, 1, "invalid number of join event entries: %+v", msg2.Event) { + assert.Equal(hello2.Hello.SessionId, msg2.Event.Join[0].SessionId) + assert.Equal(hello2.Hello.UserId, msg2.Event.Join[0].UserId) + } } func TestClientSendOfferPermissions(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) - assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - mcu, err := NewTestMCU() - require.NoError(err) + mcu := sfutest.NewSFU(t) require.NoError(mcu.Start(ctx)) defer mcu.Stop() hub.SetMcu(mcu) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId + "1")) - - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - - require.NoError(client2.SendHello(testDefaultUserId + "2")) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // Give message processing some time. time.Sleep(10 * time.Millisecond) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) @@ -3844,43 +3417,42 @@ func TestClientSendOfferPermissions(t *testing.T) { require.NotNil(session2, "Session %s does not exist", hello2.Hello.SessionId) // Client 1 is the moderator - session1.SetPermissions([]Permission{PERMISSION_MAY_PUBLISH_MEDIA, PERMISSION_MAY_PUBLISH_SCREEN}) + session1.SetPermissions([]api.Permission{api.PERMISSION_MAY_PUBLISH_MEDIA, api.PERMISSION_MAY_PUBLISH_SCREEN}) // Client 2 is a guest participant. - session2.SetPermissions([]Permission{}) + session2.SetPermissions([]api.Permission{}) // Client 2 may not send an offer (he doesn't have the necessary permissions). - require.NoError(client2.SendMessage(MessageClientMessageRecipient{ + require.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "sendoffer", Sid: "12345", RoomType: "screen", })) - msg, err := client2.RunUntilMessage(ctx) - require.NoError(err) - require.NoError(checkMessageError(msg, "not_allowed")) + msg := MustSucceed1(t, client2.RunUntilMessage, ctx) + require.True(checkMessageError(t, msg, "not_allowed")) - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client1.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "offer", Sid: "12345", RoomType: "screen", - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioAndVideo, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, }, })) - require.NoError(client1.RunUntilAnswer(ctx, MockSdpAnswerAudioAndVideo)) + client1.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioAndVideo) // Client 1 may send an offer. - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client1.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "sendoffer", Sid: "54321", RoomType: "screen", @@ -3890,91 +3462,75 @@ func TestClientSendOfferPermissions(t *testing.T) { ctx2, cancel2 := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel2() - if message, err := client1.RunUntilMessage(ctx2); err == nil { - assert.Fail("Expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client1.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) // ...but the other peer will get an offer. - require.NoError(client2.RunUntilOffer(ctx, MockSdpOfferAudioAndVideo)) + client2.RunUntilOffer(ctx, mock.MockSdpOfferAudioAndVideo) } func TestClientSendOfferPermissionsAudioOnly(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) - assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - mcu, err := NewTestMCU() - require.NoError(err) + mcu := sfutest.NewSFU(t) require.NoError(mcu.Start(ctx)) defer mcu.Stop() hub.SetMcu(mcu) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId + "1")) - - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client.RunUntilJoined(ctx, hello.Hello) - session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId).(*ClientSession) - require.NotNil(session1, "Session %s does not exist", hello1.Hello.SessionId) + session := hub.GetSessionByPublicId(hello.Hello.SessionId).(*ClientSession) + require.NotNil(session, "Session %s does not exist", hello.Hello.SessionId) // Client is allowed to send audio only. - session1.SetPermissions([]Permission{PERMISSION_MAY_PUBLISH_AUDIO}) + session.SetPermissions([]api.Permission{api.PERMISSION_MAY_PUBLISH_AUDIO}) // Client may not send an offer with audio and video. - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client.SendMessage(api.MessageClientMessageRecipient{ Type: "session", - SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + SessionId: hello.Hello.SessionId, + }, api.MessageClientMessageData{ Type: "offer", Sid: "54321", RoomType: "video", - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioAndVideo, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, }, })) - msg, err := client1.RunUntilMessage(ctx) - require.NoError(err) - require.NoError(checkMessageError(msg, "not_allowed")) + msg := MustSucceed1(t, client.RunUntilMessage, ctx) + require.True(checkMessageError(t, msg, "not_allowed")) // Client may send an offer (audio only). - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client.SendMessage(api.MessageClientMessageRecipient{ Type: "session", - SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + SessionId: hello.Hello.SessionId, + }, api.MessageClientMessageData{ Type: "offer", Sid: "54321", RoomType: "video", - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioOnly, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioOnly, }, })) - require.NoError(client1.RunUntilAnswer(ctx, MockSdpAnswerAudioOnly)) + client.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioOnly) } func TestClientSendOfferPermissionsAudioVideo(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -3982,63 +3538,55 @@ func TestClientSendOfferPermissionsAudioVideo(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - mcu, err := NewTestMCU() - require.NoError(err) + mcu := sfutest.NewSFU(t) require.NoError(mcu.Start(ctx)) defer mcu.Stop() hub.SetMcu(mcu) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId + "1")) - - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client.RunUntilJoined(ctx, hello.Hello) - session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId).(*ClientSession) - require.NotNil(session1, "Session %s does not exist", hello1.Hello.SessionId) + session := hub.GetSessionByPublicId(hello.Hello.SessionId).(*ClientSession) + require.NotNil(session, "Session %s does not exist", hello.Hello.SessionId) // Client is allowed to send audio and video. - session1.SetPermissions([]Permission{PERMISSION_MAY_PUBLISH_AUDIO, PERMISSION_MAY_PUBLISH_VIDEO}) + session.SetPermissions([]api.Permission{api.PERMISSION_MAY_PUBLISH_AUDIO, api.PERMISSION_MAY_PUBLISH_VIDEO}) - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client.SendMessage(api.MessageClientMessageRecipient{ Type: "session", - SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + SessionId: hello.Hello.SessionId, + }, api.MessageClientMessageData{ Type: "offer", Sid: "54321", RoomType: "video", - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioAndVideo, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, }, })) - require.NoError(client1.RunUntilAnswer(ctx, MockSdpAnswerAudioAndVideo)) + require.True(client.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioAndVideo)) // Client is no longer allowed to send video, this will stop the publisher. - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "participants", - Participants: &BackendRoomParticipantsRequest{ - Changed: []map[string]interface{}{ + Participants: &talk.BackendRoomParticipantsRequest{ + Changed: []api.StringMap{ { - "sessionId": roomId + "-" + hello1.Hello.SessionId, - "permissions": []Permission{PERMISSION_MAY_PUBLISH_AUDIO}, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello.Hello.SessionId), + "permissions": []api.Permission{api.PERMISSION_MAY_PUBLISH_AUDIO}, }, }, - Users: []map[string]interface{}{ + Users: []api.StringMap{ { - "sessionId": roomId + "-" + hello1.Hello.SessionId, - "permissions": []Permission{PERMISSION_MAY_PUBLISH_AUDIO}, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello.Hello.SessionId), + "permissions": []api.Permission{api.PERMISSION_MAY_PUBLISH_AUDIO}, }, }, }, @@ -4067,7 +3615,7 @@ loop: } for _, pub := range pubs { - if pub.isClosed() { + if pub.IsClosed() { break loop } } @@ -4079,7 +3627,6 @@ loop: func TestClientSendOfferPermissionsAudioVideoMedia(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) @@ -4087,64 +3634,56 @@ func TestClientSendOfferPermissionsAudioVideoMedia(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - mcu, err := NewTestMCU() - require.NoError(err) + mcu := sfutest.NewSFU(t) require.NoError(mcu.Start(ctx)) defer mcu.Stop() hub.SetMcu(mcu) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId + "1")) - - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client.RunUntilJoined(ctx, hello.Hello) - session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId).(*ClientSession) - require.NotNil(session1, "Session %s does not exist", hello1.Hello.SessionId) + session := hub.GetSessionByPublicId(hello.Hello.SessionId).(*ClientSession) + require.NotNil(session, "Session %s does not exist", hello.Hello.SessionId) // Client is allowed to send audio and video. - session1.SetPermissions([]Permission{PERMISSION_MAY_PUBLISH_MEDIA}) + session.SetPermissions([]api.Permission{api.PERMISSION_MAY_PUBLISH_MEDIA}) // Client may send an offer (audio and video). - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client.SendMessage(api.MessageClientMessageRecipient{ Type: "session", - SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + SessionId: hello.Hello.SessionId, + }, api.MessageClientMessageData{ Type: "offer", Sid: "54321", RoomType: "video", - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioAndVideo, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, }, })) - require.NoError(client1.RunUntilAnswer(ctx, MockSdpAnswerAudioAndVideo)) + require.True(client.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioAndVideo)) // Client is no longer allowed to send video, this will stop the publisher. - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "participants", - Participants: &BackendRoomParticipantsRequest{ - Changed: []map[string]interface{}{ + Participants: &talk.BackendRoomParticipantsRequest{ + Changed: []api.StringMap{ { - "sessionId": roomId + "-" + hello1.Hello.SessionId, - "permissions": []Permission{PERMISSION_MAY_PUBLISH_MEDIA, PERMISSION_MAY_CONTROL}, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello.Hello.SessionId), + "permissions": []api.Permission{api.PERMISSION_MAY_PUBLISH_MEDIA, api.PERMISSION_MAY_CONTROL}, }, }, - Users: []map[string]interface{}{ + Users: []api.StringMap{ { - "sessionId": roomId + "-" + hello1.Hello.SessionId, - "permissions": []Permission{PERMISSION_MAY_PUBLISH_MEDIA, PERMISSION_MAY_CONTROL}, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello.Hello.SessionId), + "permissions": []api.Permission{api.PERMISSION_MAY_PUBLISH_MEDIA, api.PERMISSION_MAY_CONTROL}, }, }, }, @@ -4175,7 +3714,7 @@ loop: } for _, pub := range pubs { - if !assert.False(pub.isClosed(), "publisher was closed") { + if !assert.False(pub.IsClosed(), "publisher was closed") { break loop } } @@ -4186,12 +3725,11 @@ loop: } func TestClientRequestOfferNotInRoom(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() require := require.New(t) - assert := assert.New(t) var hub1 *Hub var hub2 *Hub var server1 *httptest.Server @@ -4208,91 +3746,73 @@ func TestClientRequestOfferNotInRoom(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - mcu, err := NewTestMCU() - require.NoError(err) + mcu := sfutest.NewSFU(t) require.NoError(mcu.Start(ctx)) defer mcu.Stop() hub1.SetMcu(mcu) hub2.SetMcu(mcu) - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId + "1")) - - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - - require.NoError(client2.SendHello(testDefaultUserId + "2")) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoomWithRoomSession(ctx, roomId, "roomsession1") - require.NoError(err) + roomMsg := MustSucceed3(t, client1.JoinRoomWithRoomSession, ctx, roomId, "roomsession1") require.Equal(roomId, roomMsg.Room.RoomId) // We will receive a "joined" event. - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client1.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "offer", Sid: "54321", RoomType: "screen", - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioAndVideo, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, }, })) - require.NoError(client1.RunUntilAnswer(ctx, MockSdpAnswerAudioAndVideo)) + require.True(client1.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioAndVideo)) // Client 2 may not request an offer (he is not in the room yet). - require.NoError(client2.SendMessage(MessageClientMessageRecipient{ + require.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "requestoffer", Sid: "12345", RoomType: "screen", })) - msg, err := client2.RunUntilMessage(ctx) - require.NoError(err) - require.NoError(checkMessageError(msg, "not_allowed")) + msg := MustSucceed1(t, client2.RunUntilMessage, ctx) + require.True(checkMessageError(t, msg, "not_allowed")) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) // We will receive a "joined" event. - require.NoError(client1.RunUntilJoined(ctx, hello2.Hello)) - require.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + require.True(client1.RunUntilJoined(ctx, hello2.Hello)) + require.True(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) // Client 2 may not request an offer (he is not in the call yet). - require.NoError(client2.SendMessage(MessageClientMessageRecipient{ + require.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "requestoffer", Sid: "12345", RoomType: "screen", })) - msg, err = client2.RunUntilMessage(ctx) - require.NoError(err) - require.NoError(checkMessageError(msg, "not_allowed")) + msg = MustSucceed1(t, client2.RunUntilMessage, ctx) + require.True(checkMessageError(t, msg, "not_allowed")) // Simulate request from the backend that somebody joined the call. - users1 := []map[string]interface{}{ + users1 := []api.StringMap{ { "sessionId": hello2.Hello.SessionId, "inCall": 1, @@ -4301,25 +3821,24 @@ func TestClientRequestOfferNotInRoom(t *testing.T) { room2 := hub2.getRoom(roomId) require.NotNil(room2, "Could not find room %s", roomId) room2.PublishUsersInCallChanged(users1, users1) - assert.NoError(checkReceiveClientEvent(ctx, client1, "update", nil)) - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", nil)) + checkReceiveClientEvent(ctx, t, client1, "update", nil) + checkReceiveClientEvent(ctx, t, client2, "update", nil) // Client 2 may not request an offer (recipient is not in the call yet). - require.NoError(client2.SendMessage(MessageClientMessageRecipient{ + require.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "requestoffer", Sid: "12345", RoomType: "screen", })) - msg, err = client2.RunUntilMessage(ctx) - require.NoError(err) - require.NoError(checkMessageError(msg, "not_allowed")) + msg = MustSucceed1(t, client2.RunUntilMessage, ctx) + require.True(checkMessageError(t, msg, "not_allowed")) // Simulate request from the backend that somebody joined the call. - users2 := []map[string]interface{}{ + users2 := []api.StringMap{ { "sessionId": hello1.Hello.SessionId, "inCall": 1, @@ -4328,30 +3847,30 @@ func TestClientRequestOfferNotInRoom(t *testing.T) { room1 := hub1.getRoom(roomId) require.NotNil(room1, "Could not find room %s", roomId) room1.PublishUsersInCallChanged(users2, users2) - assert.NoError(checkReceiveClientEvent(ctx, client1, "update", nil)) - assert.NoError(checkReceiveClientEvent(ctx, client2, "update", nil)) + checkReceiveClientEvent(ctx, t, client1, "update", nil) + checkReceiveClientEvent(ctx, t, client2, "update", nil) // Client 2 may request an offer now (both are in the same room and call). - require.NoError(client2.SendMessage(MessageClientMessageRecipient{ + require.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "requestoffer", Sid: "12345", RoomType: "screen", })) - require.NoError(client2.RunUntilOffer(ctx, MockSdpOfferAudioAndVideo)) + require.True(client2.RunUntilOffer(ctx, mock.MockSdpOfferAudioAndVideo)) - require.NoError(client2.SendMessage(MessageClientMessageRecipient{ + require.NoError(client2.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "answer", Sid: "12345", RoomType: "screen", - Payload: map[string]interface{}{ - "sdp": MockSdpAnswerAudioAndVideo, + Payload: api.StringMap{ + "sdp": mock.MockSdpAnswerAudioAndVideo, }, })) @@ -4359,20 +3878,14 @@ func TestClientRequestOfferNotInRoom(t *testing.T) { ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel2() - if message, err := client2.RunUntilMessage(ctx2); err == nil { - assert.Fail("Expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client2.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) }) } } func TestNoSendBetweenSessionsOnDifferentBackends(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) - assert := assert.New(t) // Clients can't send messages to sessions connected from other backends. hub, _, _, server := CreateHubWithMultipleBackendsForTest(t) @@ -4385,9 +3898,8 @@ func TestNoSendBetweenSessionsOnDifferentBackends(t *testing.T) { params1 := TestBackendClientAuthParams{ UserId: "user1", } - require.NoError(client1.SendHelloParams(server.URL+"/one", HelloVersionV1, "client", nil, params1)) - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) + require.NoError(client1.SendHelloParams(server.URL+"/one", api.HelloVersionV1, "client", nil, params1)) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) client2 := NewTestClient(t, server, hub) defer client2.CloseWithBye() @@ -4395,15 +3907,66 @@ func TestNoSendBetweenSessionsOnDifferentBackends(t *testing.T) { params2 := TestBackendClientAuthParams{ UserId: "user2", } - require.NoError(client2.SendHelloParams(server.URL+"/two", HelloVersionV1, "client", nil, params2)) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + require.NoError(client2.SendHelloParams(server.URL+"/two", api.HelloVersionV1, "client", nil, params2)) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) - recipient1 := MessageClientMessageRecipient{ + recipient1 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, } - recipient2 := MessageClientMessageRecipient{ + recipient2 := api.MessageClientMessageRecipient{ + Type: "session", + SessionId: hello2.Hello.SessionId, + } + + data1 := "from-1-to-2" + client1.SendMessage(recipient2, data1) // nolint + data2 := "from-2-to-1" + client2.SendMessage(recipient1, data2) // nolint + + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel2() + + client1.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) + + ctx3, cancel3 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel3() + + client2.RunUntilErrorIs(ctx3, ErrNoMessageReceived, context.DeadlineExceeded) +} + +func TestSendBetweenDifferentUrls(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubWithMultipleUrlsForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + + params1 := TestBackendClientAuthParams{ + UserId: "user1", + } + require.NoError(client1.SendHelloParams(server.URL+"/one", api.HelloVersionV1, "client", nil, params1)) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) + + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + + params2 := TestBackendClientAuthParams{ + UserId: "user2", + } + require.NoError(client2.SendHelloParams(server.URL+"/two", api.HelloVersionV1, "client", nil, params2)) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) + + recipient1 := api.MessageClientMessageRecipient{ + Type: "session", + SessionId: hello1.Hello.SessionId, + } + recipient2 := api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, } @@ -4414,26 +3977,16 @@ func TestNoSendBetweenSessionsOnDifferentBackends(t *testing.T) { client2.SendMessage(recipient1, data2) // nolint var payload string - ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel2() - if err := checkReceiveClientMessage(ctx2, client1, "session", hello2.Hello, &payload); err == nil { - assert.Fail("Expected no payload, got %+v", payload) - } else { - assert.ErrorIs(err, ErrNoMessageReceived) + if checkReceiveClientMessage(ctx, t, client1, "session", hello2.Hello, &payload) { + assert.Equal(data2, payload) } - - ctx3, cancel3 := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel3() - if err := checkReceiveClientMessage(ctx3, client2, "session", hello1.Hello, &payload); err == nil { - assert.Fail("Expected no payload, got %+v", payload) - } else { - assert.ErrorIs(err, ErrNoMessageReceived) + if checkReceiveClientMessage(ctx, t, client2, "session", hello1.Hello, &payload) { + assert.Equal(data1, payload) } } func TestNoSameRoomOnDifferentBackends(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubWithMultipleBackendsForTest(t) @@ -4447,9 +4000,8 @@ func TestNoSameRoomOnDifferentBackends(t *testing.T) { params1 := TestBackendClientAuthParams{ UserId: "user1", } - require.NoError(client1.SendHelloParams(server.URL+"/one", HelloVersionV1, "client", nil, params1)) - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) + require.NoError(client1.SendHelloParams(server.URL+"/one", api.HelloVersionV1, "client", nil, params1)) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) client2 := NewTestClient(t, server, hub) defer client2.CloseWithBye() @@ -4457,24 +4009,21 @@ func TestNoSameRoomOnDifferentBackends(t *testing.T) { params2 := TestBackendClientAuthParams{ UserId: "user2", } - require.NoError(client2.SendHelloParams(server.URL+"/two", HelloVersionV1, "client", nil, params2)) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + require.NoError(client2.SendHelloParams(server.URL+"/two", api.HelloVersionV1, "client", nil, params2)) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) - if msg1, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkMessageJoined(msg1, hello1.Hello)) + if msg1, ok := client1.RunUntilMessage(ctx); ok { + client1.checkMessageJoined(msg1, hello1.Hello) } - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) - if msg2, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client2.checkMessageJoined(msg2, hello2.Hello)) + if msg2, ok := client2.RunUntilMessage(ctx); ok { + client2.checkMessageJoined(msg2, hello2.Hello) } hub.ru.RLock() @@ -4486,12 +4035,77 @@ func TestNoSameRoomOnDifferentBackends(t *testing.T) { hub.ru.RUnlock() if assert.Len(rooms, 2) { - if rooms[0].IsEqual(rooms[1]) { - assert.Fail("Rooms should be different: %+v", rooms) - } + assert.False(rooms[0].IsEqual(rooms[1]), "Rooms should be different: %+v", rooms) } - recipient := MessageClientMessageRecipient{ + recipient := api.MessageClientMessageRecipient{ + Type: "room", + } + + data1 := "from-1-to-2" + client1.SendMessage(recipient, data1) // nolint + data2 := "from-2-to-1" + client2.SendMessage(recipient, data2) // nolint + + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel2() + + client1.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) + + ctx3, cancel3 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel3() + + client2.RunUntilErrorIs(ctx3, ErrNoMessageReceived, context.DeadlineExceeded) +} + +func TestSameRoomOnDifferentUrls(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubWithMultipleUrlsForTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client1 := NewTestClient(t, server, hub) + defer client1.CloseWithBye() + + params1 := TestBackendClientAuthParams{ + UserId: "user1", + } + require.NoError(client1.SendHelloParams(server.URL+"/one", api.HelloVersionV1, "client", nil, params1)) + hello1 := MustSucceed1(t, client1.RunUntilHello, ctx) + + client2 := NewTestClient(t, server, hub) + defer client2.CloseWithBye() + + params2 := TestBackendClientAuthParams{ + UserId: "user2", + } + require.NoError(client2.SendHelloParams(server.URL+"/two", api.HelloVersionV1, "client", nil, params2)) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) + + hub.ru.RLock() + var rooms []*Room + for _, room := range hub.rooms { + defer room.Close() + rooms = append(rooms, room) + } + hub.ru.RUnlock() + + assert.Len(rooms, 1) + + recipient := api.MessageClientMessageRecipient{ Type: "room", } @@ -4501,30 +4115,20 @@ func TestNoSameRoomOnDifferentBackends(t *testing.T) { client2.SendMessage(recipient, data2) // nolint var payload string - ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel2() - if err := checkReceiveClientMessage(ctx2, client1, "session", hello2.Hello, &payload); err == nil { - assert.Fail("Expected no payload, got %+v", payload) - } else { - assert.ErrorIs(err, ErrNoMessageReceived) + if checkReceiveClientMessage(ctx, t, client1, "room", hello2.Hello, &payload) { + assert.Equal(data2, payload) } - - ctx3, cancel3 := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel3() - if err := checkReceiveClientMessage(ctx3, client2, "session", hello1.Hello, &payload); err == nil { - assert.Fail("Expected no payload, got %+v", payload) - } else { - assert.ErrorIs(err, ErrNoMessageReceived) + if checkReceiveClientMessage(ctx, t, client2, "room", hello1.Hello, &payload) { + assert.Equal(data1, payload) } } func TestClientSendOffer(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() require := require.New(t) - assert := assert.New(t) var hub1 *Hub var hub2 *Hub var server1 *httptest.Server @@ -4541,63 +4145,47 @@ func TestClientSendOffer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - mcu, err := NewTestMCU() - require.NoError(err) + mcu := sfutest.NewSFU(t) require.NoError(mcu.Start(ctx)) defer mcu.Stop() hub1.SetMcu(mcu) hub2.SetMcu(mcu) - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId + "1")) - - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - - require.NoError(client2.SendHello(testDefaultUserId + "2")) - - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoomWithRoomSession(ctx, roomId, "roomsession1") - require.NoError(err) + roomMsg := MustSucceed3(t, client1.JoinRoomWithRoomSession, ctx, roomId, "roomsession1") require.Equal(roomId, roomMsg.Room.RoomId) // Give message processing some time. time.Sleep(10 * time.Millisecond) - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client1.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "offer", Sid: "12345", RoomType: "video", - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioAndVideo, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, }, })) - require.NoError(client1.RunUntilAnswer(ctx, MockSdpAnswerAudioAndVideo)) + require.True(client1.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioAndVideo)) - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client1.SendMessage(api.MessageClientMessageRecipient{ Type: "session", SessionId: hello2.Hello.SessionId, - }, MessageClientMessageData{ + }, api.MessageClientMessageData{ Type: "sendoffer", RoomType: "video", })) @@ -4606,71 +4194,57 @@ func TestClientSendOffer(t *testing.T) { ctx2, cancel2 := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel2() - if message, err := client1.RunUntilMessage(ctx2); err == nil { - assert.Fail("Expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client1.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) // ...but the other peer will get an offer. - require.NoError(client2.RunUntilOffer(ctx, MockSdpOfferAudioAndVideo)) + client2.RunUntilOffer(ctx, mock.MockSdpOfferAudioAndVideo) }) } } func TestClientUnshareScreen(t *testing.T) { t.Parallel() - CatchLogForTest(t) require := require.New(t) - assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - mcu, err := NewTestMCU() - require.NoError(err) + mcu := sfutest.NewSFU(t) require.NoError(mcu.Start(ctx)) defer mcu.Stop() hub.SetMcu(mcu) - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId + "1")) - - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) // Join room by id. roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) require.Equal(roomId, roomMsg.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client.RunUntilJoined(ctx, hello.Hello) - session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId).(*ClientSession) - require.NotNil(session1, "Session %s does not exist", hello1.Hello.SessionId) + session := hub.GetSessionByPublicId(hello.Hello.SessionId).(*ClientSession) + require.NotNil(session, "Session %s does not exist", hello.Hello.SessionId) - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client.SendMessage(api.MessageClientMessageRecipient{ Type: "session", - SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + SessionId: hello.Hello.SessionId, + }, api.MessageClientMessageData{ Type: "offer", Sid: "54321", RoomType: "screen", - Payload: map[string]interface{}{ - "sdp": MockSdpOfferAudioOnly, + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioOnly, }, })) - require.NoError(client1.RunUntilAnswer(ctx, MockSdpAnswerAudioOnly)) + client.RunUntilAnswer(ctx, mock.MockSdpAnswerAudioOnly) - publisher := mcu.GetPublisher(hello1.Hello.SessionId) - require.NotNil(publisher, "No publisher for %s found", hello1.Hello.SessionId) - require.False(publisher.isClosed(), "Publisher %s should not be closed", hello1.Hello.SessionId) + publisher := mcu.GetPublisher(hello.Hello.SessionId) + require.NotNil(publisher, "No publisher for %s found", hello.Hello.SessionId) + require.False(publisher.IsClosed(), "Publisher %s should not be closed", hello.Hello.SessionId) old := cleanupScreenPublisherDelay cleanupScreenPublisherDelay = time.Millisecond @@ -4678,10 +4252,10 @@ func TestClientUnshareScreen(t *testing.T) { cleanupScreenPublisherDelay = old }() - require.NoError(client1.SendMessage(MessageClientMessageRecipient{ + require.NoError(client.SendMessage(api.MessageClientMessageRecipient{ Type: "session", - SessionId: hello1.Hello.SessionId, - }, MessageClientMessageData{ + SessionId: hello.Hello.SessionId, + }, api.MessageClientMessageData{ Type: "unshareScreen", Sid: "54321", RoomType: "screen", @@ -4689,11 +4263,11 @@ func TestClientUnshareScreen(t *testing.T) { time.Sleep(10 * time.Millisecond) - require.True(publisher.isClosed(), "Publisher %s should be closed", hello1.Hello.SessionId) + require.True(publisher.IsClosed(), "Publisher %s should be closed", hello.Hello.SessionId) } func TestVirtualClientSessions(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -4715,70 +4289,59 @@ func TestVirtualClientSessions(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId)) - - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId) roomId := "test-room" - _, err = client1.JoinRoom(ctx, roomId) - require.NoError(err) + MustSucceed2(t, client1.JoinRoom, ctx, roomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) client2 := NewTestClient(t, server2, hub2) defer client2.CloseWithBye() require.NoError(client2.SendHelloInternal()) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) session2 := hub2.GetSessionByPublicId(hello2.Hello.SessionId).(*ClientSession) require.NotNil(session2, "Session %s does not exist", hello2.Hello.SessionId) - _, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + MustSucceed2(t, client2.JoinRoom, ctx, roomId) - assert.NoError(client1.RunUntilJoined(ctx, hello2.Hello)) + client1.RunUntilJoined(ctx, hello2.Hello) - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - if msg, err := checkMessageParticipantsInCall(msg); assert.NoError(err) { + if msg, ok := client1.RunUntilMessage(ctx); ok { + if msg, ok := checkMessageParticipantsInCall(t, msg); ok { if assert.Len(msg.Users, 1) { assert.Equal(true, msg.Users[0]["internal"], "%+v", msg) - assert.Equal(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) assert.EqualValues(3, msg.Users[0]["inCall"], "%+v", msg) } } } - _, unexpected, err := client2.RunUntilJoinedAndReturn(ctx, hello1.Hello, hello2.Hello) - assert.NoError(err) + _, unexpected, _ := client2.RunUntilJoinedAndReturn(ctx, hello1.Hello, hello2.Hello) if len(unexpected) == 0 { - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if msg, ok := client2.RunUntilMessage(ctx); ok { unexpected = append(unexpected, msg) } } require.Len(unexpected, 1) - if msg, err := checkMessageParticipantsInCall(unexpected[0]); assert.NoError(err) { + if msg, ok := checkMessageParticipantsInCall(t, unexpected[0]); ok { if assert.Len(msg.Users, 1) { assert.Equal(true, msg.Users[0]["internal"]) - assert.Equal(hello2.Hello.SessionId, msg.Users[0]["sessionId"]) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[0]["sessionId"]) assert.EqualValues(FlagInCall|FlagWithAudio, msg.Users[0]["inCall"]) } } calledCtx, calledCancel := context.WithTimeout(ctx, time.Second) - virtualSessionId := "virtual-session-id" + virtualSessionId := api.PublicSessionId("virtual-session-id") virtualUserId := "virtual-user-id" generatedSessionId := GetVirtualSessionId(session2, virtualSessionId) - setSessionRequestHandler(t, func(request *BackendClientSessionRequest) { + setSessionRequestHandler(t, func(request *talk.BackendClientSessionRequest) { defer calledCancel() assert.Equal("add", request.Action, "%+v", request) assert.Equal(roomId, request.RoomId, "%+v", request) @@ -4786,8 +4349,8 @@ func TestVirtualClientSessions(t *testing.T) { assert.Equal(virtualUserId, request.UserId, "%+v", request) }) - require.NoError(client2.SendInternalAddSession(&AddSessionInternalClientMessage{ - CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ + require.NoError(client2.SendInternalAddSession(&api.AddSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ SessionId: virtualSessionId, RoomId: roomId, }, @@ -4807,52 +4370,52 @@ func TestVirtualClientSessions(t *testing.T) { virtualSession := virtualSessions[0] - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkMessageJoinedSession(msg, virtualSession.PublicId(), virtualUserId)) + if msg, ok := client1.RunUntilMessage(ctx); ok { + client1.checkMessageJoinedSession(msg, virtualSession.PublicId(), virtualUserId) } - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - if msg, err := checkMessageParticipantsInCall(msg); assert.NoError(err) { + if msg, ok := client1.RunUntilMessage(ctx); ok { + if msg, ok := checkMessageParticipantsInCall(t, msg); ok { if assert.Len(msg.Users, 2) { assert.Equal(true, msg.Users[0]["internal"], "%+v", msg) - assert.Equal(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithAudio, msg.Users[0]["inCall"], "%+v", msg) assert.Equal(true, msg.Users[1]["virtual"], "%+v", msg) - assert.Equal(virtualSession.PublicId(), msg.Users[1]["sessionId"], "%+v", msg) + assert.EqualValues(virtualSession.PublicId(), msg.Users[1]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithPhone, msg.Users[1]["inCall"], "%+v", msg) } } } - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - if flags, err := checkMessageParticipantFlags(msg); assert.NoError(err) { + if msg, ok := client1.RunUntilMessage(ctx); ok { + if flags, ok := checkMessageParticipantFlags(t, msg); ok { assert.Equal(roomId, flags.RoomId) assert.Equal(virtualSession.PublicId(), flags.SessionId) assert.EqualValues(FLAG_MUTED_SPEAKING, flags.Flags) } } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client2.checkMessageJoinedSession(msg, virtualSession.PublicId(), virtualUserId)) + if msg, ok := client2.RunUntilMessage(ctx); ok { + client2.checkMessageJoinedSession(msg, virtualSession.PublicId(), virtualUserId) } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - if msg, err := checkMessageParticipantsInCall(msg); assert.NoError(err) { + if msg, ok := client2.RunUntilMessage(ctx); ok { + if msg, ok := checkMessageParticipantsInCall(t, msg); ok { if assert.Len(msg.Users, 2) { assert.Equal(true, msg.Users[0]["internal"], "%+v", msg) - assert.Equal(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithAudio, msg.Users[0]["inCall"], "%+v", msg) assert.Equal(true, msg.Users[1]["virtual"], "%+v", msg) - assert.Equal(virtualSession.PublicId(), msg.Users[1]["sessionId"], "%+v", msg) + assert.EqualValues(virtualSession.PublicId(), msg.Users[1]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithPhone, msg.Users[1]["inCall"], "%+v", msg) } } } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - if flags, err := checkMessageParticipantFlags(msg); assert.NoError(err) { + if msg, ok := client2.RunUntilMessage(ctx); ok { + if flags, ok := checkMessageParticipantFlags(t, msg); ok { assert.Equal(roomId, flags.RoomId) assert.Equal(virtualSession.PublicId(), flags.SessionId) assert.EqualValues(FLAG_MUTED_SPEAKING, flags.Flags) @@ -4860,8 +4423,8 @@ func TestVirtualClientSessions(t *testing.T) { } updatedFlags := uint32(0) - require.NoError(client2.SendInternalUpdateSession(&UpdateSessionInternalClientMessage{ - CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ + require.NoError(client2.SendInternalUpdateSession(&api.UpdateSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ SessionId: virtualSessionId, RoomId: roomId, }, @@ -4869,16 +4432,16 @@ func TestVirtualClientSessions(t *testing.T) { Flags: &updatedFlags, })) - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - if flags, err := checkMessageParticipantFlags(msg); assert.NoError(err) { + if msg, ok := client1.RunUntilMessage(ctx); ok { + if flags, ok := checkMessageParticipantFlags(t, msg); ok { assert.Equal(roomId, flags.RoomId) assert.Equal(virtualSession.PublicId(), flags.SessionId) assert.EqualValues(0, flags.Flags) } } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - if flags, err := checkMessageParticipantFlags(msg); assert.NoError(err) { + if msg, ok := client2.RunUntilMessage(ctx); ok { + if flags, ok := checkMessageParticipantFlags(t, msg); ok { assert.Equal(roomId, flags.RoomId) assert.Equal(virtualSession.PublicId(), flags.SessionId) assert.EqualValues(0, flags.Flags) @@ -4887,7 +4450,7 @@ func TestVirtualClientSessions(t *testing.T) { calledCtx, calledCancel = context.WithTimeout(ctx, time.Second) - setSessionRequestHandler(t, func(request *BackendClientSessionRequest) { + setSessionRequestHandler(t, func(request *talk.BackendClientSessionRequest) { defer calledCancel() assert.Equal("remove", request.Action, "%+v", request) assert.Equal(roomId, request.RoomId, "%+v", request) @@ -4896,7 +4459,7 @@ func TestVirtualClientSessions(t *testing.T) { }) // Messages to virtual sessions are sent to the associated client session. - virtualRecipient := MessageClientMessageRecipient{ + virtualRecipient := api.MessageClientMessageRecipient{ Type: "session", SessionId: virtualSession.PublicId(), } @@ -4905,9 +4468,9 @@ func TestVirtualClientSessions(t *testing.T) { client1.SendMessage(virtualRecipient, data) // nolint var payload string - var sender *MessageServerMessageSender - var recipient *MessageClientMessageRecipient - if err := checkReceiveClientMessageWithSenderAndRecipient(ctx, client2, "session", hello1.Hello, &payload, &sender, &recipient); assert.NoError(err) { + var sender *api.MessageServerMessageSender + var recipient *api.MessageClientMessageRecipient + if checkReceiveClientMessageWithSenderAndRecipient(ctx, t, client2, "session", hello1.Hello, &payload, &sender, &recipient) { assert.Equal(virtualSessionId, recipient.SessionId, "%+v", recipient) assert.Equal(data, payload) } @@ -4915,13 +4478,13 @@ func TestVirtualClientSessions(t *testing.T) { data = "control-to-virtual" client1.SendControl(virtualRecipient, data) // nolint - if err := checkReceiveClientControlWithSenderAndRecipient(ctx, client2, "session", hello1.Hello, &payload, &sender, &recipient); assert.NoError(err) { + if checkReceiveClientControlWithSenderAndRecipient(ctx, t, client2, "session", hello1.Hello, &payload, &sender, &recipient) { assert.Equal(virtualSessionId, recipient.SessionId, "%+v", recipient) assert.Equal(data, payload) } - require.NoError(client2.SendInternalRemoveSession(&RemoveSessionInternalClientMessage{ - CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ + require.NoError(client2.SendInternalRemoveSession(&api.RemoveSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ SessionId: virtualSessionId, RoomId: roomId, }, @@ -4933,19 +4496,19 @@ func TestVirtualClientSessions(t *testing.T) { require.NoError(err) } - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkMessageRoomLeaveSession(msg, virtualSession.PublicId())) + if msg, ok := client1.RunUntilMessage(ctx); ok { + client1.checkMessageRoomLeaveSession(msg, virtualSession.PublicId()) } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkMessageRoomLeaveSession(msg, virtualSession.PublicId())) + if msg, ok := client2.RunUntilMessage(ctx); ok { + client1.checkMessageRoomLeaveSession(msg, virtualSession.PublicId()) } }) } } func TestDuplicateVirtualSessions(t *testing.T) { - CatchLogForTest(t) + t.Parallel() for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -4967,70 +4530,67 @@ func TestDuplicateVirtualSessions(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - - require.NoError(client1.SendHello(testDefaultUserId)) - - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId) roomId := "test-room" - _, err = client1.JoinRoom(ctx, roomId) - require.NoError(err) + MustSucceed2(t, client1.JoinRoom, ctx, roomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello)) + client1.RunUntilJoined(ctx, hello1.Hello) client2 := NewTestClient(t, server2, hub2) defer client2.CloseWithBye() require.NoError(client2.SendHelloInternal()) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + hello2 := MustSucceed1(t, client2.RunUntilHello, ctx) session2 := hub2.GetSessionByPublicId(hello2.Hello.SessionId).(*ClientSession) require.NotNil(session2, "Session %s does not exist", hello2.Hello.SessionId) - _, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) + MustSucceed2(t, client2.JoinRoom, ctx, roomId) - assert.NoError(client1.RunUntilJoined(ctx, hello2.Hello)) + if _, additional, ok := client1.RunUntilJoinedAndReturn(ctx, hello2.Hello); ok { + // TODO: Should not receive participants update before joined event. + if len(additional) == 0 { + if msg, ok := client1.RunUntilMessage(ctx); ok { + additional = append(additional, msg) + } + } - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - if msg, err := checkMessageParticipantsInCall(msg); assert.NoError(err) { - if assert.Len(msg.Users, 1) { - assert.Equal(true, msg.Users[0]["internal"], "%+v", msg) - assert.Equal(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) - assert.EqualValues(3, msg.Users[0]["inCall"], "%+v", msg) + if assert.Len(additional, 1) { + if msg, ok := checkMessageParticipantsInCall(t, additional[0]); ok { + if assert.Len(msg.Users, 1) { + assert.Equal(true, msg.Users[0]["internal"], "%+v", msg) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) + assert.EqualValues(3, msg.Users[0]["inCall"], "%+v", msg) + } } } } - _, unexpected, err := client2.RunUntilJoinedAndReturn(ctx, hello1.Hello, hello2.Hello) - assert.NoError(err) + _, unexpected, _ := client2.RunUntilJoinedAndReturn(ctx, hello1.Hello, hello2.Hello) if len(unexpected) == 0 { - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { + if msg, ok := client2.RunUntilMessage(ctx); ok { unexpected = append(unexpected, msg) } } require.Len(unexpected, 1) - if msg, err := checkMessageParticipantsInCall(unexpected[0]); assert.NoError(err) { + if msg, ok := checkMessageParticipantsInCall(t, unexpected[0]); ok { if assert.Len(msg.Users, 1) { assert.Equal(true, msg.Users[0]["internal"]) - assert.Equal(hello2.Hello.SessionId, msg.Users[0]["sessionId"]) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[0]["sessionId"]) assert.EqualValues(FlagInCall|FlagWithAudio, msg.Users[0]["inCall"]) } } calledCtx, calledCancel := context.WithTimeout(ctx, time.Second) - virtualSessionId := "virtual-session-id" + virtualSessionId := api.PublicSessionId("virtual-session-id") virtualUserId := "virtual-user-id" generatedSessionId := GetVirtualSessionId(session2, virtualSessionId) - setSessionRequestHandler(t, func(request *BackendClientSessionRequest) { + setSessionRequestHandler(t, func(request *talk.BackendClientSessionRequest) { defer calledCancel() assert.Equal("add", request.Action, "%+v", request) assert.Equal(roomId, request.RoomId, "%+v", request) @@ -5038,8 +4598,8 @@ func TestDuplicateVirtualSessions(t *testing.T) { assert.Equal(virtualUserId, request.UserId, "%+v", request) }) - require.NoError(client2.SendInternalAddSession(&AddSessionInternalClientMessage{ - CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ + require.NoError(client2.SendInternalAddSession(&api.AddSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ SessionId: virtualSessionId, RoomId: roomId, }, @@ -5058,63 +4618,63 @@ func TestDuplicateVirtualSessions(t *testing.T) { } virtualSession := virtualSessions[0] - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client1.checkMessageJoinedSession(msg, virtualSession.PublicId(), virtualUserId)) + if msg, ok := client1.RunUntilMessage(ctx); ok { + client1.checkMessageJoinedSession(msg, virtualSession.PublicId(), virtualUserId) } - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - if msg, err := checkMessageParticipantsInCall(msg); assert.NoError(err) { + if msg, ok := client1.RunUntilMessage(ctx); ok { + if msg, ok := checkMessageParticipantsInCall(t, msg); ok { if assert.Len(msg.Users, 2) { assert.Equal(true, msg.Users[0]["internal"], "%+v", msg) - assert.Equal(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithAudio, msg.Users[0]["inCall"], "%+v", msg) assert.Equal(true, msg.Users[1]["virtual"], "%+v", msg) - assert.Equal(virtualSession.PublicId(), msg.Users[1]["sessionId"], "%+v", msg) + assert.EqualValues(virtualSession.PublicId(), msg.Users[1]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithPhone, msg.Users[1]["inCall"], "%+v", msg) } } } - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - if flags, err := checkMessageParticipantFlags(msg); assert.NoError(err) { + if msg, ok := client1.RunUntilMessage(ctx); ok { + if flags, ok := checkMessageParticipantFlags(t, msg); ok { assert.Equal(roomId, flags.RoomId) assert.Equal(virtualSession.PublicId(), flags.SessionId) assert.EqualValues(FLAG_MUTED_SPEAKING, flags.Flags) } } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client2.checkMessageJoinedSession(msg, virtualSession.PublicId(), virtualUserId)) + if msg, ok := client2.RunUntilMessage(ctx); ok { + client2.checkMessageJoinedSession(msg, virtualSession.PublicId(), virtualUserId) } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - if msg, err := checkMessageParticipantsInCall(msg); assert.NoError(err) { + if msg, ok := client2.RunUntilMessage(ctx); ok { + if msg, ok := checkMessageParticipantsInCall(t, msg); ok { if assert.Len(msg.Users, 2) { assert.Equal(true, msg.Users[0]["internal"], "%+v", msg) - assert.Equal(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[0]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithAudio, msg.Users[0]["inCall"], "%+v", msg) assert.Equal(true, msg.Users[1]["virtual"], "%+v", msg) - assert.Equal(virtualSession.PublicId(), msg.Users[1]["sessionId"], "%+v", msg) + assert.EqualValues(virtualSession.PublicId(), msg.Users[1]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithPhone, msg.Users[1]["inCall"], "%+v", msg) } } } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - if flags, err := checkMessageParticipantFlags(msg); assert.NoError(err) { + if msg, ok := client2.RunUntilMessage(ctx); ok { + if flags, ok := checkMessageParticipantFlags(t, msg); ok { assert.Equal(roomId, flags.RoomId) assert.Equal(virtualSession.PublicId(), flags.SessionId) assert.EqualValues(FLAG_MUTED_SPEAKING, flags.Flags) } } - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "incall", - InCall: &BackendRoomInCallRequest{ + InCall: &talk.BackendRoomInCallRequest{ InCall: []byte("0"), - Users: []map[string]interface{}{ + Users: []api.StringMap{ { "sessionId": virtualSession.PublicId(), "participantPermissions": 246, @@ -5123,7 +4683,7 @@ func TestDuplicateVirtualSessions(t *testing.T) { }, { // Request is coming from Nextcloud, so use its session id (which is our "room session id"). - "sessionId": roomId + "-" + hello1.Hello.SessionId, + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), "participantPermissions": 254, "participantType": 1, "lastPing": 234567890, @@ -5141,43 +4701,43 @@ func TestDuplicateVirtualSessions(t *testing.T) { assert.NoError(err) assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - if msg, err := checkMessageParticipantsInCall(msg); assert.NoError(err) { + if msg, ok := client1.RunUntilMessage(ctx); ok { + if msg, ok := checkMessageParticipantsInCall(t, msg); ok { if assert.Len(msg.Users, 3) { assert.Equal(true, msg.Users[0]["virtual"], "%+v", msg) - assert.Equal(virtualSession.PublicId(), msg.Users[0]["sessionId"], "%+v", msg) + assert.EqualValues(virtualSession.PublicId(), msg.Users[0]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithPhone, msg.Users[0]["inCall"], "%+v", msg) assert.EqualValues(246, msg.Users[0]["participantPermissions"], "%+v", msg) assert.EqualValues(4, msg.Users[0]["participantType"], "%+v", msg) - assert.Equal(hello1.Hello.SessionId, msg.Users[1]["sessionId"], "%+v", msg) + assert.EqualValues(hello1.Hello.SessionId, msg.Users[1]["sessionId"], "%+v", msg) assert.Nil(msg.Users[1]["inCall"], "%+v", msg) assert.EqualValues(254, msg.Users[1]["participantPermissions"], "%+v", msg) assert.EqualValues(1, msg.Users[1]["participantType"], "%+v", msg) assert.Equal(true, msg.Users[2]["internal"], "%+v", msg) - assert.Equal(hello2.Hello.SessionId, msg.Users[2]["sessionId"], "%+v", msg) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[2]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithAudio, msg.Users[2]["inCall"], "%+v", msg) } } } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - if msg, err := checkMessageParticipantsInCall(msg); assert.NoError(err) { + if msg, ok := client2.RunUntilMessage(ctx); ok { + if msg, ok := checkMessageParticipantsInCall(t, msg); ok { if assert.Len(msg.Users, 3) { assert.Equal(true, msg.Users[0]["virtual"], "%+v", msg) - assert.Equal(virtualSession.PublicId(), msg.Users[0]["sessionId"], "%+v", msg) + assert.EqualValues(virtualSession.PublicId(), msg.Users[0]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithPhone, msg.Users[0]["inCall"], "%+v", msg) assert.EqualValues(246, msg.Users[0]["participantPermissions"], "%+v", msg) assert.EqualValues(4, msg.Users[0]["participantType"], "%+v", msg) - assert.Equal(hello1.Hello.SessionId, msg.Users[1]["sessionId"], "%+v", msg) + assert.EqualValues(hello1.Hello.SessionId, msg.Users[1]["sessionId"], "%+v", msg) assert.Nil(msg.Users[1]["inCall"], "%+v", msg) assert.EqualValues(254, msg.Users[1]["participantPermissions"], "%+v", msg) assert.EqualValues(1, msg.Users[1]["participantType"], "%+v", msg) assert.Equal(true, msg.Users[2]["internal"], "%+v", msg) - assert.Equal(hello2.Hello.SessionId, msg.Users[2]["sessionId"], "%+v", msg) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[2]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithAudio, msg.Users[2]["inCall"], "%+v", msg) } } @@ -5190,34 +4750,34 @@ func TestDuplicateVirtualSessions(t *testing.T) { defer client3.CloseWithBye() require.NoError(client3.SendHelloResume(hello1.Hello.ResumeId)) - if hello3, err := client3.RunUntilHello(ctx); assert.NoError(err) { + if hello3, ok := client3.RunUntilHello(ctx); ok { assert.Equal(testDefaultUserId, hello3.Hello.UserId, "%+v", hello3.Hello) assert.Equal(hello1.Hello.SessionId, hello3.Hello.SessionId, "%+v", hello3.Hello) assert.Equal(hello1.Hello.ResumeId, hello3.Hello.ResumeId, "%+v", hello3.Hello) } - if msg, err := client3.RunUntilMessage(ctx); assert.NoError(err) { - if msg, err := checkMessageParticipantsInCall(msg); assert.NoError(err) { + if msg, ok := client3.RunUntilMessage(ctx); ok { + if msg, ok := checkMessageParticipantsInCall(t, msg); ok { if assert.Len(msg.Users, 3) { assert.Equal(true, msg.Users[0]["virtual"], "%+v", msg) - assert.Equal(virtualSession.PublicId(), msg.Users[0]["sessionId"], "%+v", msg) + assert.EqualValues(virtualSession.PublicId(), msg.Users[0]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithPhone, msg.Users[0]["inCall"], "%+v", msg) assert.EqualValues(246, msg.Users[0]["participantPermissions"], "%+v", msg) assert.EqualValues(4, msg.Users[0]["participantType"], "%+v", msg) - assert.Equal(hello1.Hello.SessionId, msg.Users[1]["sessionId"], "%+v", msg) + assert.EqualValues(hello1.Hello.SessionId, msg.Users[1]["sessionId"], "%+v", msg) assert.Nil(msg.Users[1]["inCall"], "%+v", msg) assert.EqualValues(254, msg.Users[1]["participantPermissions"], "%+v", msg) assert.EqualValues(1, msg.Users[1]["participantType"], "%+v", msg) assert.Equal(true, msg.Users[2]["internal"], "%+v", msg) - assert.Equal(hello2.Hello.SessionId, msg.Users[2]["sessionId"], "%+v", msg) + assert.EqualValues(hello2.Hello.SessionId, msg.Users[2]["sessionId"], "%+v", msg) assert.EqualValues(FlagInCall|FlagWithAudio, msg.Users[2]["inCall"], "%+v", msg) } } } - setSessionRequestHandler(t, func(request *BackendClientSessionRequest) { + setSessionRequestHandler(t, func(request *talk.BackendClientSessionRequest) { defer calledCancel() assert.Equal("remove", request.Action, "%+v", request) assert.Equal(roomId, request.RoomId, "%+v", request) @@ -5228,8 +4788,7 @@ func TestDuplicateVirtualSessions(t *testing.T) { } } -func DoTestSwitchToOne(t *testing.T, details map[string]interface{}) { - CatchLogForTest(t) +func DoTestSwitchToOne(t *testing.T, details api.StringMap) { for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -5249,53 +4808,48 @@ func DoTestSwitchToOne(t *testing.T, details map[string]interface{}) { hub1, hub2, server1, server2 = CreateClusteredHubsForTest(t) } - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") - roomSessionId1 := "roomsession1" + roomSessionId1 := api.RoomSessionId("roomsession1") roomId1 := "test-room" - roomMsg, err := client1.JoinRoomWithRoomSession(ctx, roomId1, roomSessionId1) - require.NoError(err) + roomMsg := MustSucceed3(t, client1.JoinRoomWithRoomSession, ctx, roomId1, roomSessionId1) require.Equal(roomId1, roomMsg.Room.RoomId) - roomSessionId2 := "roomsession2" - roomMsg, err = client2.JoinRoomWithRoomSession(ctx, roomId1, roomSessionId2) - require.NoError(err) + // TODO: If we join both clients immediately and then afterwards wait for both with + // "WaitForUsersJoined", the clustered test sometimes fails under load because the + // second session receives the remote "joined" event before joining the room itself. + client1.RunUntilJoined(ctx, hello1.Hello) + + roomSessionId2 := api.RoomSessionId("roomsession2") + roomMsg = MustSucceed3(t, client2.JoinRoomWithRoomSession, ctx, roomId1, roomSessionId2) require.Equal(roomId1, roomMsg.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client1.RunUntilJoined(ctx, hello2.Hello) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) roomId2 := "test-room-2" var sessions json.RawMessage + var err error if details != nil { - sessions, err = json.Marshal(map[string]interface{}{ + sessions, err = json.Marshal(map[api.RoomSessionId]any{ roomSessionId1: details, }) require.NoError(err) } else { - sessions, err = json.Marshal([]string{ + sessions, err = json.Marshal([]api.RoomSessionId{ roomSessionId1, }) require.NoError(err) } // Notify first client to switch to different room. - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "switchto", - SwitchTo: &BackendRoomSwitchToMessageRequest{ + SwitchTo: &talk.BackendRoomSwitchToMessageRequest{ RoomId: roomId2, Sessions: sessions, }, @@ -5315,34 +4869,30 @@ func DoTestSwitchToOne(t *testing.T, details map[string]interface{}) { detailsData, err = json.Marshal(details) require.NoError(err) } - _, err = client1.RunUntilSwitchTo(ctx, roomId2, detailsData) - assert.NoError(err) + client1.RunUntilSwitchTo(ctx, roomId2, detailsData) // The other client will not receive a message. ctx2, cancel2 := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel2() - if message, err := client2.RunUntilMessage(ctx2); err == nil { - assert.Fail("Expected no message, got %+v", message) - } else if err != ErrNoMessageReceived && err != context.DeadlineExceeded { - assert.NoError(err) - } + client2.RunUntilErrorIs(ctx2, ErrNoMessageReceived, context.DeadlineExceeded) }) } } func TestSwitchToOneMap(t *testing.T) { - DoTestSwitchToOne(t, map[string]interface{}{ + t.Parallel() + DoTestSwitchToOne(t, api.StringMap{ "foo": "bar", }) } func TestSwitchToOneList(t *testing.T) { + t.Parallel() DoTestSwitchToOne(t, nil) } -func DoTestSwitchToMultiple(t *testing.T, details1 map[string]interface{}, details2 map[string]interface{}) { - CatchLogForTest(t) +func DoTestSwitchToMultiple(t *testing.T, details1 api.StringMap, details2 api.StringMap) { for _, subtest := range clusteredTests { t.Run(subtest, func(t *testing.T) { t.Parallel() @@ -5362,54 +4912,51 @@ func DoTestSwitchToMultiple(t *testing.T, details1 map[string]interface{}, detai hub1, hub2, server1, server2 = CreateClusteredHubsForTest(t) } - client1 := NewTestClient(t, server1, hub1) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - client2 := NewTestClient(t, server2, hub2) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + defer client1.CloseWithBye() + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") + defer client2.CloseWithBye() - roomSessionId1 := "roomsession1" + roomSessionId1 := api.RoomSessionId("roomsession1") roomId1 := "test-room" - roomMsg, err := client1.JoinRoomWithRoomSession(ctx, roomId1, roomSessionId1) - require.NoError(err) + roomMsg := MustSucceed3(t, client1.JoinRoomWithRoomSession, ctx, roomId1, roomSessionId1) require.Equal(roomId1, roomMsg.Room.RoomId) - roomSessionId2 := "roomsession2" - roomMsg, err = client2.JoinRoomWithRoomSession(ctx, roomId1, roomSessionId2) - require.NoError(err) + // TODO: If we join both clients immediately and then afterwards wait for both with + // "WaitForUsersJoined", the clustered test sometimes fails under load because the + // second session receives the remote "joined" event before joining the room itself. + client1.RunUntilJoined(ctx, hello1.Hello) + + roomSessionId2 := api.RoomSessionId("roomsession2") + roomMsg = MustSucceed3(t, client2.JoinRoomWithRoomSession, ctx, roomId1, roomSessionId2) require.Equal(roomId1, roomMsg.Room.RoomId) - assert.NoError(client1.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) - assert.NoError(client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello)) + client1.RunUntilJoined(ctx, hello2.Hello) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) roomId2 := "test-room-2" var sessions json.RawMessage + var err error if details1 != nil || details2 != nil { - sessions, err = json.Marshal(map[string]interface{}{ + sessions, err = json.Marshal(map[api.RoomSessionId]any{ roomSessionId1: details1, roomSessionId2: details2, }) require.NoError(err) } else { - sessions, err = json.Marshal([]string{ + sessions, err = json.Marshal([]api.RoomSessionId{ roomSessionId1, roomSessionId2, }) require.NoError(err) } - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "switchto", - SwitchTo: &BackendRoomSwitchToMessageRequest{ + SwitchTo: &talk.BackendRoomSwitchToMessageRequest{ RoomId: roomId2, Sessions: sessions, }, @@ -5429,41 +4976,41 @@ func DoTestSwitchToMultiple(t *testing.T, details1 map[string]interface{}, detai detailsData1, err = json.Marshal(details1) require.NoError(err) } - _, err = client1.RunUntilSwitchTo(ctx, roomId2, detailsData1) - assert.NoError(err) + client1.RunUntilSwitchTo(ctx, roomId2, detailsData1) var detailsData2 json.RawMessage if details2 != nil { detailsData2, err = json.Marshal(details2) require.NoError(err) } - _, err = client2.RunUntilSwitchTo(ctx, roomId2, detailsData2) - assert.NoError(err) + client2.RunUntilSwitchTo(ctx, roomId2, detailsData2) }) } } func TestSwitchToMultipleMap(t *testing.T) { - DoTestSwitchToMultiple(t, map[string]interface{}{ + t.Parallel() + DoTestSwitchToMultiple(t, api.StringMap{ "foo": "bar", - }, map[string]interface{}{ + }, api.StringMap{ "bar": "baz", }) } func TestSwitchToMultipleList(t *testing.T) { + t.Parallel() DoTestSwitchToMultiple(t, nil, nil) } func TestSwitchToMultipleMixed(t *testing.T) { - DoTestSwitchToMultiple(t, map[string]interface{}{ + t.Parallel() + DoTestSwitchToMultiple(t, api.StringMap{ "foo": "bar", }, nil) } func TestGeoipOverrides(t *testing.T) { t.Parallel() - CatchLogForTest(t) assert := assert.New(t) country1 := "DE" country2 := "IT" @@ -5480,16 +5027,17 @@ func TestGeoipOverrides(t *testing.T) { return conf, err }) - assert.Equal(loopback, hub.OnLookupCountry(&Client{addr: "127.0.0.1"})) - assert.Equal(unknownCountry, hub.OnLookupCountry(&Client{addr: "8.8.8.8"})) - assert.Equal(country1, hub.OnLookupCountry(&Client{addr: "10.1.1.2"})) - assert.Equal(country2, hub.OnLookupCountry(&Client{addr: "10.2.1.2"})) - assert.Equal(strings.ToUpper(country3), hub.OnLookupCountry(&Client{addr: "192.168.10.20"})) + assert.Equal(geoip.Loopback, hub.LookupCountry("127.0.0.1")) + assert.Equal(geoip.UnknownCountry, hub.LookupCountry("8.8.8.8")) + assert.EqualValues(country1, hub.LookupCountry("10.1.1.2")) + assert.EqualValues(country2, hub.LookupCountry("10.2.1.2")) + assert.EqualValues(strings.ToUpper(country3), hub.LookupCountry("192.168.10.20")) } func TestDialoutStatus(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) _, _, _, hub, _, server := CreateBackendServerForTest(t) @@ -5498,25 +5046,16 @@ func TestDialoutStatus(t *testing.T) { defer internalClient.CloseWithBye() require.NoError(internalClient.SendHelloInternalWithFeatures([]string{"start-dialout"})) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() - _, err := internalClient.RunUntilHello(ctx) - require.NoError(err) + MustSucceed1(t, internalClient.RunUntilHello, ctx) roomId := "12345" - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) - require.NoError(client.SendHello(testDefaultUserId)) - - hello, err := client.RunUntilHello(ctx) - require.NoError(err) - - _, err = client.JoinRoom(ctx, roomId) - require.NoError(err) - - assert.NoError(client.RunUntilJoined(ctx, hello.Hello)) + MustSucceed2(t, client.JoinRoom, ctx, roomId) + client.RunUntilJoined(ctx, hello.Hello) callId := "call-123" @@ -5524,8 +5063,8 @@ func TestDialoutStatus(t *testing.T) { go func(client *TestClient) { defer close(stopped) - msg, err := client.RunUntilMessage(ctx) - if !assert.NoError(err) { + msg, ok := client.RunUntilMessage(ctx) + if !ok { return } @@ -5538,15 +5077,15 @@ func TestDialoutStatus(t *testing.T) { assert.Equal(roomId, msg.Internal.Dialout.RoomId) - response := &ClientMessage{ + response := &api.ClientMessage{ Id: msg.Id, Type: "internal", - Internal: &InternalClientMessage{ + Internal: &api.InternalClientMessage{ Type: "dialout", - Dialout: &DialoutInternalClientMessage{ + Dialout: &api.DialoutInternalClientMessage{ Type: "status", RoomId: msg.Internal.Dialout.RoomId, - Status: &DialoutStatusInternalClientMessage{ + Status: &api.DialoutStatusInternalClientMessage{ Status: "accepted", CallId: callId, }, @@ -5560,9 +5099,9 @@ func TestDialoutStatus(t *testing.T) { <-stopped }() - msg := &BackendServerRoomRequest{ + msg := &talk.BackendServerRoomRequest{ Type: "dialout", - Dialout: &BackendRoomDialoutRequest{ + Dialout: &talk.BackendRoomDialoutRequest{ Number: "+1234567890", }, } @@ -5576,7 +5115,7 @@ func TestDialoutStatus(t *testing.T) { assert.NoError(err) require.Equal(http.StatusOK, res.StatusCode, "Expected success, got %s", string(body)) - var response BackendServerRoomResponse + var response talk.BackendServerRoomResponse if assert.NoError(json.Unmarshal(body, &response)) { assert.Equal("dialout", response.Type) if assert.NotNil(response.Dialout) { @@ -5586,30 +5125,30 @@ func TestDialoutStatus(t *testing.T) { } key := "callstatus_" + callId - if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(checkMessageTransientSet(msg, key, map[string]interface{}{ + if msg, ok := client.RunUntilMessage(ctx); ok { + checkMessageTransientInitialOrSet(t, msg, key, api.StringMap{ "callid": callId, "status": "accepted", - }, nil)) + }) } - require.NoError(internalClient.SendInternalDialout(&DialoutInternalClientMessage{ + require.NoError(internalClient.SendInternalDialout(&api.DialoutInternalClientMessage{ RoomId: roomId, Type: "status", - Status: &DialoutStatusInternalClientMessage{ + Status: &api.DialoutStatusInternalClientMessage{ CallId: callId, Status: "ringing", }, })) - if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(checkMessageTransientSet(msg, key, map[string]interface{}{ + if msg, ok := client.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, key, api.StringMap{ "callid": callId, "status": "ringing", - }, map[string]interface{}{ + }, api.StringMap{ "callid": callId, "status": "accepted", - })) + }) } old := removeCallStatusTTL @@ -5619,42 +5158,41 @@ func TestDialoutStatus(t *testing.T) { removeCallStatusTTL = 500 * time.Millisecond clearedCause := "cleared-call" - require.NoError(internalClient.SendInternalDialout(&DialoutInternalClientMessage{ + require.NoError(internalClient.SendInternalDialout(&api.DialoutInternalClientMessage{ RoomId: roomId, Type: "status", - Status: &DialoutStatusInternalClientMessage{ + Status: &api.DialoutStatusInternalClientMessage{ CallId: callId, Status: "cleared", Cause: clearedCause, }, })) - if msg, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(checkMessageTransientSet(msg, key, map[string]interface{}{ + if msg, ok := client.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, key, api.StringMap{ "callid": callId, "status": "cleared", "cause": clearedCause, - }, map[string]interface{}{ + }, api.StringMap{ "callid": callId, "status": "ringing", - })) + }) } ctx2, cancel := context.WithTimeout(ctx, removeCallStatusTTL*2) defer cancel() - if msg, err := client.RunUntilMessage(ctx2); assert.NoError(err) { - assert.NoError(checkMessageTransientRemove(msg, key, map[string]interface{}{ + if msg, ok := client.RunUntilMessage(ctx2); ok { + checkMessageTransientRemove(t, msg, key, api.StringMap{ "callid": callId, "status": "cleared", "cause": clearedCause, - })) + }) } } func TestGracefulShutdownInitial(t *testing.T) { t.Parallel() - CatchLogForTest(t) hub, _, _, _ := CreateHubForTest(t) hub.ScheduleShutdown() @@ -5663,21 +5201,13 @@ func TestGracefulShutdownInitial(t *testing.T) { func TestGracefulShutdownOnBye(t *testing.T) { t.Parallel() - CatchLogForTest(t) - require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - - _, err := client.RunUntilHello(ctx) - require.NoError(err) + client, _ := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) hub.ScheduleShutdown() select { @@ -5697,21 +5227,13 @@ func TestGracefulShutdownOnBye(t *testing.T) { func TestGracefulShutdownOnExpiration(t *testing.T) { t.Parallel() - CatchLogForTest(t) - require := require.New(t) assert := assert.New(t) hub, _, _, server := CreateHubForTest(t) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - - require.NoError(client.SendHello(testDefaultUserId)) - - _, err := client.RunUntilHello(ctx) - require.NoError(err) + client, _ := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) hub.ScheduleShutdown() select { @@ -5727,7 +5249,7 @@ func TestGracefulShutdownOnExpiration(t *testing.T) { case <-time.After(100 * time.Millisecond): } - performHousekeeping(hub, time.Now().Add(sessionExpireDuration+time.Second)) + hub.performHousekeeping(time.Now().Add(sessionExpireDuration + time.Second)) select { case <-hub.ShutdownChannel(): @@ -5735,3 +5257,53 @@ func TestGracefulShutdownOnExpiration(t *testing.T) { assert.Fail("should have shutdown") } } + +func TestHubGetPublisherIdForSessionId(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + + mcu := sfutest.NewSFU(t) + hub.SetMcu(mcu) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + done := make(chan struct{}) + go func() { + defer close(done) + + if reply, err := hub.GetPublisherIdForSessionId(ctx, hello.Hello.SessionId, sfu.StreamTypeVideo); assert.NoError(err) && assert.NotNil(reply) { + assert.Equal("https://proxy.domain.invalid", reply.ProxyUrl) + assert.Equal("10.20.30.40", reply.Ip) + // The test-SFU doesn't support token creation. + assert.Empty(reply.ConnectToken) + assert.Empty(reply.PublisherToken) + + if session := hub.GetSessionByPublicId(hello.Hello.SessionId); assert.NotNil(session) { + if cs, ok := session.(*ClientSession); assert.True(ok) { + if pub := cs.GetPublisher(sfu.StreamTypeVideo); assert.NotNil(pub) { + assert.EqualValues(pub.PublisherId(), reply.PublisherId) + } + } + } + } + }() + + require.NoError(client.SendMessage(api.MessageClientMessageRecipient{ + Type: "session", + SessionId: hello.Hello.SessionId, + }, api.MessageClientMessageData{ + Type: "offer", + Sid: "54321", + RoomType: "video", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + })) + + <-done +} diff --git a/server/hub_transient_data_test.go b/server/hub_transient_data_test.go new file mode 100644 index 0000000..ab6e4dd --- /dev/null +++ b/server/hub_transient_data_test.go @@ -0,0 +1,383 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package server + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" +) + +func Test_TransientMessages(t *testing.T) { + t.Parallel() + for _, subtest := range clusteredTests { + t.Run(subtest, func(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + var hub1 *Hub + var hub2 *Hub + var server1 *httptest.Server + var server2 *httptest.Server + if isLocalTest(t) { + hub1, _, _, server1 = CreateHubForTest(t) + + hub2 = hub1 + server2 = server1 + } else { + hub1, hub2, server1, server2 = CreateClusteredHubsForTest(t) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + + require.NoError(client1.SetTransientData("foo", "bar", 0)) + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageError(t, msg, "not_in_room") + } + + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + // Give message processing some time. + time.Sleep(10 * time.Millisecond) + + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) + + session1 := hub1.GetSessionByPublicId(hello1.Hello.SessionId).(*ClientSession) + require.NotNil(session1, "Session %s does not exist", hello1.Hello.SessionId) + session2 := hub2.GetSessionByPublicId(hello2.Hello.SessionId).(*ClientSession) + require.NotNil(session2, "Session %s does not exist", hello2.Hello.SessionId) + + // Client 1 may modify transient data. + session1.SetPermissions([]api.Permission{api.PERMISSION_TRANSIENT_DATA}) + // Client 2 may not modify transient data. + session2.SetPermissions([]api.Permission{}) + + require.NoError(client2.SetTransientData("foo", "bar", 0)) + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageError(t, msg, "not_allowed") + } + + require.NoError(client1.SetTransientData("foo", "bar", 0)) + + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, "foo", "bar", nil) + } + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, "foo", "bar", nil) + } + + require.NoError(client2.RemoveTransientData("foo")) + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageError(t, msg, "not_allowed") + } + + // Setting the same value is ignored by the server. + require.NoError(client1.SetTransientData("foo", "bar", 0)) + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel2() + + client1.RunUntilErrorIs(ctx2, context.DeadlineExceeded) + + data := map[string]any{ + "hello": "world", + } + require.NoError(client1.SetTransientData("foo", data, 0)) + + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, "foo", data, "bar") + } + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, "foo", data, "bar") + } + + require.NoError(client1.RemoveTransientData("foo")) + + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageTransientRemove(t, msg, "foo", data) + } + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageTransientRemove(t, msg, "foo", data) + } + + // Removing a non-existing key is ignored by the server. + require.NoError(client1.RemoveTransientData("foo")) + ctx3, cancel3 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel3() + + client1.RunUntilErrorIs(ctx3, context.DeadlineExceeded) + + ttl := 200 * time.Millisecond + require.NoError(client1.SetTransientData("abc", data, ttl)) + setAt := time.Now() + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, "abc", data, nil) + } + + client1.CloseWithBye() + require.NoError(client1.WaitForClientRemoved(ctx)) + client2.RunUntilLeft(ctx, hello1.Hello) + + client3, hello3 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"3") + roomMsg = MustSucceed2(t, client3.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + client2.RunUntilJoined(ctx, hello3.Hello) + + _, ignored, ok := client3.RunUntilJoinedAndReturn(ctx, hello2.Hello, hello3.Hello) + require.True(ok) + + var msg *api.ServerMessage + if len(ignored) == 0 { + msg = MustSucceed1(t, client3.RunUntilMessage, ctx) + } else if len(ignored) == 1 { + msg = ignored[0] + } else { + require.LessOrEqual(len(ignored), 1, "Received too many messages: %+v", ignored) + } + + checkMessageTransientInitial(t, msg, api.StringMap{ + "abc": data, + }) + + client4, hello4 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"4") + roomMsg = MustSucceed2(t, client4.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + client2.RunUntilJoined(ctx, hello4.Hello) + client3.RunUntilJoined(ctx, hello4.Hello) + + _, ignored, ok = client4.RunUntilJoinedAndReturn(ctx, hello2.Hello, hello3.Hello, hello4.Hello) + require.True(ok) + + if len(ignored) == 0 { + msg = MustSucceed1(t, client4.RunUntilMessage, ctx) + } else if len(ignored) == 1 { + msg = ignored[0] + } else { + require.LessOrEqual(len(ignored), 1, "Received too many messages: %+v", ignored) + } + + checkMessageTransientInitial(t, msg, api.StringMap{ + "abc": data, + }) + + delta := time.Until(setAt.Add(ttl)) + if assert.Greater(delta, time.Duration(0), "test runner too slow?") { + time.Sleep(delta) + if msg, ok = client2.RunUntilMessage(ctx); ok { + checkMessageTransientRemove(t, msg, "abc", data) + } + } + }) + } +} + +func Test_TransientSessionData(t *testing.T) { + t.Parallel() + for _, subtest := range clusteredTests { + t.Run(subtest, func(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + var hub1 *Hub + var hub2 *Hub + var server1 *httptest.Server + var server2 *httptest.Server + if isLocalTest(t) { + hub1, _, _, server1 = CreateHubForTest(t) + + hub2 = hub1 + server2 = server1 + } else { + hub1, hub2, server1, server2 = CreateClusteredHubsForTest(t) + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client1, hello1 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server2, hub2, testDefaultUserId+"2") + + roomId := "test-room" + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) + + sessionKey1 := "sd:" + string(hello1.Hello.SessionId) + sessionKey2 := "sd:" + string(hello2.Hello.SessionId) + require.NotEqual(sessionKey1, sessionKey2) + + require.NoError(client1.SetTransientData(sessionKey2, "foo", 0)) + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageError(t, msg, "not_allowed") + } + require.NoError(client2.SetTransientData(sessionKey1, "bar", 0)) + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageError(t, msg, "not_allowed") + } + + require.NoError(client1.SetTransientData(sessionKey1, "foo", 0)) + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, sessionKey1, "foo", nil) + } + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, sessionKey1, "foo", nil) + } + + require.NoError(client2.RemoveTransientData(sessionKey1)) + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageError(t, msg, "not_allowed") + } + + require.NoError(client2.SetTransientData(sessionKey2, "bar", 0)) + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, sessionKey2, "bar", nil) + } + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, sessionKey2, "bar", nil) + } + + client1.CloseWithBye() + assert.NoError(client1.WaitForClientRemoved(ctx)) + + var messages []*api.ServerMessage + for range 2 { + if msg, ok := client2.RunUntilMessage(ctx); ok { + messages = append(messages, msg) + } + } + if assert.Len(messages, 2) { + if messages[0].Type == "transient" { + messages[0], messages[1] = messages[1], messages[0] + } + client2.checkMessageRoomLeaveSession(messages[0], hello1.Hello.SessionId) + checkMessageTransientRemove(t, messages[1], sessionKey1, "foo") + } + + client3, hello3 := NewTestClientWithHello(ctx, t, server1, hub1, testDefaultUserId+"3") + roomMsg = MustSucceed2(t, client3.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + _, ignored, ok := client3.RunUntilJoinedAndReturn(ctx, hello2.Hello, hello3.Hello) + require.True(ok) + + var msg *api.ServerMessage + if len(ignored) == 0 { + msg = MustSucceed1(t, client3.RunUntilMessage, ctx) + } else if len(ignored) == 1 { + msg = ignored[0] + } else { + require.LessOrEqual(len(ignored), 1, "Received too many messages: %+v", ignored) + } + + checkMessageTransientInitial(t, msg, api.StringMap{ + sessionKey2: "bar", + }) + + client2.CloseWithBye() + assert.NoError(client2.WaitForClientRemoved(ctx)) + + messages = nil + for range 2 { + if msg, ok := client3.RunUntilMessage(ctx); ok { + messages = append(messages, msg) + } + } + if assert.Len(messages, 2) { + if messages[0].Type == "transient" { + messages[0], messages[1] = messages[1], messages[0] + } + client3.checkMessageRoomLeaveSession(messages[0], hello2.Hello.SessionId) + checkMessageTransientRemove(t, messages[1], sessionKey2, "bar") + } + + // Internal clients may set transient data of any session. + client4 := NewTestClient(t, server1, hub1) + defer client4.CloseWithBye() + + require.NoError(client4.SendHelloInternal()) + hello4 := MustSucceed1(t, client4.RunUntilHello, ctx) + roomMsg = MustSucceed2(t, client4.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + _, ignored, ok = client4.RunUntilJoinedAndReturn(ctx, hello3.Hello, hello4.Hello) + require.True(ok) + + if len(ignored) == 0 { + msg = MustSucceed1(t, client4.RunUntilMessage, ctx) + } else if len(ignored) == 1 { + msg = ignored[0] + } else { + require.LessOrEqual(len(ignored), 1, "Received too many messages: %+v", ignored) + } + + if msgInCall, ok := checkMessageParticipantsInCall(t, msg); ok { + if assert.Len(msgInCall.Users, 1) { + assert.Equal(true, msgInCall.Users[0]["internal"]) + assert.EqualValues(hello4.Hello.SessionId, msgInCall.Users[0]["sessionId"]) + assert.EqualValues(FlagInCall|FlagWithAudio, msgInCall.Users[0]["inCall"]) + } + } + + client3.RunUntilJoined(ctx, hello4.Hello) + if msg, ok := client3.RunUntilMessage(ctx); ok { + if msgInCall, ok := checkMessageParticipantsInCall(t, msg); ok { + if assert.Len(msgInCall.Users, 1) { + assert.Equal(true, msgInCall.Users[0]["internal"]) + assert.EqualValues(hello4.Hello.SessionId, msgInCall.Users[0]["sessionId"]) + assert.EqualValues(FlagInCall|FlagWithAudio, msgInCall.Users[0]["inCall"]) + } + } + } + + sessionKey3 := string("sd:" + hello3.Hello.SessionId) + require.NoError(client4.SetTransientData(sessionKey3, "baz", 0)) + if msg, ok := client4.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, sessionKey3, "baz", nil) + } + + if msg, ok := client3.RunUntilMessage(ctx); ok { + checkMessageTransientSet(t, msg, sessionKey3, "baz", nil) + } + }) + } +} diff --git a/server/main.go b/server/main.go deleted file mode 100644 index e372bd9..0000000 --- a/server/main.go +++ /dev/null @@ -1,432 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package main - -import ( - "context" - "crypto/tls" - "errors" - "flag" - "fmt" - "log" - "net" - "net/http" - "net/http/pprof" - "os" - "os/signal" - "runtime" - runtimepprof "runtime/pprof" - "strings" - "sync" - "syscall" - "time" - - "github.com/dlintw/goconf" - "github.com/gorilla/mux" - "github.com/nats-io/nats.go" - - signaling "github.com/strukturag/nextcloud-spreed-signaling" -) - -var ( - version = "unreleased" - - configFlag = flag.String("config", "server.conf", "config file to use") - - cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") - - memprofile = flag.String("memprofile", "", "write memory profile to file") - - showVersion = flag.Bool("version", false, "show version and quit") -) - -const ( - defaultReadTimeout = 15 - defaultWriteTimeout = 30 - - initialMcuRetry = time.Second - maxMcuRetry = time.Second * 16 - - dnsMonitorInterval = time.Second -) - -func createListener(addr string) (net.Listener, error) { - if addr[0] == '/' { - os.Remove(addr) - return net.Listen("unix", addr) - } - - return net.Listen("tcp", addr) -} - -func createTLSListener(addr string, certFile, keyFile string) (net.Listener, error) { - cert, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return nil, err - } - config := tls.Config{ - Certificates: []tls.Certificate{cert}, - } - if addr[0] == '/' { - os.Remove(addr) - return tls.Listen("unix", addr, &config) - } - - return tls.Listen("tcp", addr, &config) -} - -type Listeners struct { - mu sync.Mutex - listeners []net.Listener -} - -func (l *Listeners) Add(listener net.Listener) { - l.mu.Lock() - defer l.mu.Unlock() - - l.listeners = append(l.listeners, listener) -} - -func (l *Listeners) Close() { - l.mu.Lock() - defer l.mu.Unlock() - - for _, listener := range l.listeners { - if err := listener.Close(); err != nil { - log.Printf("Error closing listener %s: %s", listener.Addr(), err) - } - } -} - -func main() { - log.SetFlags(log.Lshortfile) - flag.Parse() - - if *showVersion { - fmt.Printf("nextcloud-spreed-signaling version %s/%s\n", version, runtime.Version()) - os.Exit(0) - } - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt) - signal.Notify(sigChan, syscall.SIGHUP) - signal.Notify(sigChan, syscall.SIGUSR1) - - if *cpuprofile != "" { - f, err := os.Create(*cpuprofile) - if err != nil { - log.Fatal(err) - } - - if err := runtimepprof.StartCPUProfile(f); err != nil { - log.Fatalf("Error writing CPU profile to %s: %s", *cpuprofile, err) - } - log.Printf("Writing CPU profile to %s ...", *cpuprofile) - defer runtimepprof.StopCPUProfile() - } - - if *memprofile != "" { - f, err := os.Create(*memprofile) - if err != nil { - log.Fatal(err) - } - - defer func() { - log.Printf("Writing Memory profile to %s ...", *memprofile) - runtime.GC() - if err := runtimepprof.WriteHeapProfile(f); err != nil { - log.Printf("Error writing Memory profile to %s: %s", *memprofile, err) - } - }() - } - - log.Printf("Starting up version %s/%s as pid %d", version, runtime.Version(), os.Getpid()) - - config, err := goconf.ReadConfigFile(*configFlag) - if err != nil { - log.Fatal("Could not read configuration: ", err) - } - - cpus := runtime.NumCPU() - runtime.GOMAXPROCS(cpus) - log.Printf("Using a maximum of %d CPUs", cpus) - - signaling.RegisterStats() - - natsUrl, _ := signaling.GetStringOptionWithEnv(config, "nats", "url") - if natsUrl == "" { - natsUrl = nats.DefaultURL - } - - events, err := signaling.NewAsyncEvents(natsUrl) - if err != nil { - log.Fatal("Could not create async events client: ", err) - } - defer events.Close() - - dnsMonitor, err := signaling.NewDnsMonitor(dnsMonitorInterval) - if err != nil { - log.Fatal("Could not create DNS monitor: ", err) - } - if err := dnsMonitor.Start(); err != nil { - log.Fatal("Could not start DNS monitor: ", err) - } - defer dnsMonitor.Stop() - - etcdClient, err := signaling.NewEtcdClient(config, "mcu") - if err != nil { - log.Fatalf("Could not create etcd client: %s", err) - } - defer func() { - if err := etcdClient.Close(); err != nil { - log.Printf("Error while closing etcd client: %s", err) - } - }() - - rpcServer, err := signaling.NewGrpcServer(config, version) - if err != nil { - log.Fatalf("Could not create RPC server: %s", err) - } - go func() { - if err := rpcServer.Run(); err != nil { - log.Fatalf("Could not start RPC server: %s", err) - } - }() - defer rpcServer.Close() - - rpcClients, err := signaling.NewGrpcClients(config, etcdClient, dnsMonitor, version) - if err != nil { - log.Fatalf("Could not create RPC clients: %s", err) - } - defer rpcClients.Close() - - r := mux.NewRouter() - hub, err := signaling.NewHub(config, events, rpcServer, rpcClients, etcdClient, r, version) - if err != nil { - log.Fatal("Could not create hub: ", err) - } - - mcuUrl, _ := signaling.GetStringOptionWithEnv(config, "mcu", "url") - mcuType, _ := config.GetString("mcu", "type") - if mcuType == "" && mcuUrl != "" { - log.Printf("WARNING: Old-style MCU configuration detected with url but no type, defaulting to type %s", signaling.McuTypeJanus) - mcuType = signaling.McuTypeJanus - } else if mcuType == signaling.McuTypeJanus && mcuUrl == "" { - log.Printf("WARNING: Old-style MCU configuration detected with type but no url, disabling") - mcuType = "" - } - - if mcuType != "" { - var mcu signaling.Mcu - mcuRetry := initialMcuRetry - mcuRetryTimer := time.NewTimer(mcuRetry) - mcuTypeLoop: - for { - // Context should be cancelled on signals but need a way to differentiate later. - ctx := context.TODO() - switch mcuType { - case signaling.McuTypeJanus: - mcu, err = signaling.NewMcuJanus(ctx, mcuUrl, config) - signaling.UnregisterProxyMcuStats() - signaling.RegisterJanusMcuStats() - case signaling.McuTypeProxy: - mcu, err = signaling.NewMcuProxy(config, etcdClient, rpcClients, dnsMonitor) - signaling.UnregisterJanusMcuStats() - signaling.RegisterProxyMcuStats() - default: - log.Fatal("Unsupported MCU type: ", mcuType) - } - if err == nil { - err = mcu.Start(ctx) - if err != nil { - log.Printf("Could not create %s MCU: %s", mcuType, err) - } - } - if err == nil { - break - } - - log.Printf("Could not initialize %s MCU (%s) will retry in %s", mcuType, err, mcuRetry) - mcuRetryTimer.Reset(mcuRetry) - select { - case sig := <-sigChan: - switch sig { - case os.Interrupt: - log.Fatalf("Cancelled") - case syscall.SIGHUP: - log.Printf("Received SIGHUP, reloading %s", *configFlag) - if config, err = goconf.ReadConfigFile(*configFlag); err != nil { - log.Printf("Could not read configuration from %s: %s", *configFlag, err) - } else { - mcuUrl, _ = signaling.GetStringOptionWithEnv(config, "mcu", "url") - mcuType, _ = config.GetString("mcu", "type") - if mcuType == "" && mcuUrl != "" { - log.Printf("WARNING: Old-style MCU configuration detected with url but no type, defaulting to type %s", signaling.McuTypeJanus) - mcuType = signaling.McuTypeJanus - } else if mcuType == signaling.McuTypeJanus && mcuUrl == "" { - log.Printf("WARNING: Old-style MCU configuration detected with type but no url, disabling") - mcuType = "" - break mcuTypeLoop - } - } - } - case <-mcuRetryTimer.C: - // Retry connection - mcuRetry = mcuRetry * 2 - if mcuRetry > maxMcuRetry { - mcuRetry = maxMcuRetry - } - } - } - if mcu != nil { - defer mcu.Stop() - - log.Printf("Using %s MCU", mcuType) - hub.SetMcu(mcu) - } - } - - go hub.Run() - defer hub.Stop() - - server, err := signaling.NewBackendServer(config, hub, version) - if err != nil { - log.Fatal("Could not create backend server: ", err) - } - if err := server.Start(r); err != nil { - log.Fatal("Could not start backend server: ", err) - } - - if debug, _ := config.GetBool("app", "debug"); debug { - log.Println("Installing debug handlers in \"/debug/pprof\"") - r.Handle("/debug/pprof/", http.HandlerFunc(pprof.Index)) - r.Handle("/debug/pprof/cmdline", http.HandlerFunc(pprof.Cmdline)) - r.Handle("/debug/pprof/profile", http.HandlerFunc(pprof.Profile)) - r.Handle("/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol)) - r.Handle("/debug/pprof/trace", http.HandlerFunc(pprof.Trace)) - for _, profile := range runtimepprof.Profiles() { - name := profile.Name() - r.Handle("/debug/pprof/"+name, pprof.Handler(name)) - } - } - - var listeners Listeners - - if saddr, _ := signaling.GetStringOptionWithEnv(config, "https", "listen"); saddr != "" { - cert, _ := config.GetString("https", "certificate") - key, _ := config.GetString("https", "key") - if cert == "" || key == "" { - log.Fatal("Need a certificate and key for the HTTPS listener") - } - - readTimeout, _ := config.GetInt("https", "readtimeout") - if readTimeout <= 0 { - readTimeout = defaultReadTimeout - } - writeTimeout, _ := config.GetInt("https", "writetimeout") - if writeTimeout <= 0 { - writeTimeout = defaultWriteTimeout - } - for _, address := range strings.Split(saddr, " ") { - go func(address string) { - log.Println("Listening on", address) - listener, err := createTLSListener(address, cert, key) - if err != nil { - log.Fatal("Could not start listening: ", err) - } - srv := &http.Server{ - Handler: r, - - ReadTimeout: time.Duration(readTimeout) * time.Second, - WriteTimeout: time.Duration(writeTimeout) * time.Second, - } - listeners.Add(listener) - if err := srv.Serve(listener); err != nil { - if !hub.IsShutdownScheduled() || !errors.Is(err, net.ErrClosed) { - log.Fatal("Could not start server: ", err) - } - } - }(address) - } - } - - if addr, _ := signaling.GetStringOptionWithEnv(config, "http", "listen"); addr != "" { - readTimeout, _ := config.GetInt("http", "readtimeout") - if readTimeout <= 0 { - readTimeout = defaultReadTimeout - } - writeTimeout, _ := config.GetInt("http", "writetimeout") - if writeTimeout <= 0 { - writeTimeout = defaultWriteTimeout - } - - for _, address := range strings.Split(addr, " ") { - go func(address string) { - log.Println("Listening on", address) - listener, err := createListener(address) - if err != nil { - log.Fatal("Could not start listening: ", err) - } - srv := &http.Server{ - Handler: r, - Addr: addr, - - ReadTimeout: time.Duration(readTimeout) * time.Second, - WriteTimeout: time.Duration(writeTimeout) * time.Second, - } - listeners.Add(listener) - if err := srv.Serve(listener); err != nil { - if !hub.IsShutdownScheduled() || !errors.Is(err, net.ErrClosed) { - log.Fatal("Could not start server: ", err) - } - } - }(address) - } - } - -loop: - for { - select { - case sig := <-sigChan: - switch sig { - case os.Interrupt: - log.Println("Interrupted") - break loop - case syscall.SIGHUP: - log.Printf("Received SIGHUP, reloading %s", *configFlag) - if config, err := goconf.ReadConfigFile(*configFlag); err != nil { - log.Printf("Could not read configuration from %s: %s", *configFlag, err) - } else { - hub.Reload(config) - server.Reload(config) - } - case syscall.SIGUSR1: - log.Printf("Received SIGUSR1, scheduling server to shutdown") - hub.ScheduleShutdown() - listeners.Close() - } - case <-hub.ShutdownChannel(): - log.Printf("All clients disconnected, shutting down") - break loop - } - } -} diff --git a/remotesession.go b/server/remotesession.go similarity index 60% rename from remotesession.go rename to server/remotesession.go index 85c271f..9bd17bf 100644 --- a/remotesession.go +++ b/server/remotesession.go @@ -19,29 +19,35 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" "encoding/json" "errors" - "fmt" - "log" "sync/atomic" "time" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/client" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) type RemoteSession struct { + logger log.Logger hub *Hub - client *Client - remoteClient *GrpcClient - sessionId string + client *HubClient + remoteClient *grpc.Client + sessionId api.PublicSessionId - proxy atomic.Pointer[SessionProxy] + proxy atomic.Pointer[grpc.SessionProxy] } -func NewRemoteSession(hub *Hub, client *Client, remoteClient *GrpcClient, sessionId string) (*RemoteSession, error) { +func NewRemoteSession(hub *Hub, client *HubClient, remoteClient *grpc.Client, sessionId api.PublicSessionId) (*RemoteSession, error) { remoteSession := &RemoteSession{ + logger: hub.logger, hub: hub, client: client, remoteClient: remoteClient, @@ -62,7 +68,11 @@ func NewRemoteSession(hub *Hub, client *Client, remoteClient *GrpcClient, sessio return remoteSession, nil } -func (s *RemoteSession) Country() string { +func (s *RemoteSession) GetSessionId() api.PublicSessionId { + return s.sessionId +} + +func (s *RemoteSession) Country() geoip.Country { return s.client.Country() } @@ -78,18 +88,18 @@ func (s *RemoteSession) IsConnected() bool { return true } -func (s *RemoteSession) Start(message *ClientMessage) error { +func (s *RemoteSession) Start(message *api.ClientMessage) error { return s.sendMessage(message) } -func (s *RemoteSession) OnProxyMessage(msg *ServerSessionMessage) error { - var message *ServerMessage +func (s *RemoteSession) OnProxyMessage(msg *grpc.ServerSessionMessage) error { + var message *api.ServerMessage if err := json.Unmarshal(msg.Message, &message); err != nil { return err } if !s.client.SendMessage(message) { - return fmt.Errorf("could not send message to client") + return errors.New("could not send message to client") } return nil @@ -97,12 +107,12 @@ func (s *RemoteSession) OnProxyMessage(msg *ServerSessionMessage) error { func (s *RemoteSession) OnProxyClose(err error) { if err != nil { - log.Printf("Proxy connection for session %s to %s was closed with error: %s", s.sessionId, s.remoteClient.Target(), err) + s.logger.Printf("Proxy connection for session %s to %s was closed with error: %s", s.sessionId, s.remoteClient.Target(), err) } s.Close() } -func (s *RemoteSession) SendMessage(message WritableClientMessage) bool { +func (s *RemoteSession) SendMessage(message client.WritableClientMessage) bool { return s.sendMessage(message) == nil } @@ -112,13 +122,13 @@ func (s *RemoteSession) sendProxyMessage(message []byte) error { return errors.New("proxy already closed") } - msg := &ClientSessionMessage{ + msg := &grpc.ClientSessionMessage{ Message: message, } return proxy.Send(msg) } -func (s *RemoteSession) sendMessage(message interface{}) error { +func (s *RemoteSession) sendMessage(message any) error { data, err := json.Marshal(message) if err != nil { return err @@ -135,20 +145,25 @@ func (s *RemoteSession) Close() { s.client.Close() } -func (s *RemoteSession) OnLookupCountry(client HandlerClient) string { - return s.hub.OnLookupCountry(client) +func (s *RemoteSession) OnLookupCountry(addr string) geoip.Country { + return s.hub.LookupCountry(addr) } -func (s *RemoteSession) OnClosed(client HandlerClient) { +func (s *RemoteSession) OnClosed() { s.Close() } -func (s *RemoteSession) OnMessageReceived(client HandlerClient, message []byte) { +func (s *RemoteSession) OnMessageReceived(message []byte) { if err := s.sendProxyMessage(message); err != nil { - log.Printf("Error sending %s to the proxy for session %s: %s", string(message), s.sessionId, err) + s.logger.Printf("Error sending %s to the proxy for session %s: %s", string(message), s.sessionId, err) s.Close() } } -func (s *RemoteSession) OnRTTReceived(client HandlerClient, rtt time.Duration) { +func (s *RemoteSession) OnRTTReceived(rtt time.Duration) { + // Ignore +} + +func (s *RemoteSession) IsInRoom(id string) bool { + return s.client.IsInRoom(id) } diff --git a/room.go b/server/room.go similarity index 53% rename from room.go rename to server/room.go index e6e04fb..d4179c2 100644 --- a/room.go +++ b/server/room.go @@ -19,20 +19,29 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "bytes" "context" "encoding/json" + "errors" "fmt" - "log" + "maps" "net/url" "strconv" "sync" "time" "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) const ( @@ -61,33 +70,47 @@ func init() { type Room struct { id string + logger log.Logger hub *Hub - events AsyncEvents - backend *Backend + events events.AsyncEvents + backend *talk.Backend + // +checklocks:mu properties json.RawMessage - closer *Closer - mu *sync.RWMutex - sessions map[string]Session - + closer *internal.Closer + mu *sync.RWMutex + asyncCh events.AsyncChannel + // +checklocks:mu + sessions map[api.PublicSessionId]Session + // +checklocks:mu internalSessions map[*ClientSession]bool - virtualSessions map[*VirtualSession]bool - inCallSessions map[Session]bool - roomSessionData map[string]*RoomSessionData + // +checklocks:mu + virtualSessions map[*VirtualSession]bool + // +checklocks:mu + inCallSessions map[Session]bool + // +checklocks:mu + roomSessionData map[api.PublicSessionId]*talk.RoomSessionData + // +checklocks:mu statsRoomSessionsCurrent *prometheus.GaugeVec + // +checklocks:mu + statsCallSessionsCurrent *prometheus.GaugeVec + // +checklocks:mu + statsCallSessionsTotal *prometheus.CounterVec + // +checklocks:mu + statsCallRoomsTotal prometheus.Counter // Users currently in the room - users []map[string]interface{} + users []api.StringMap // Timestamps of last backend requests for the different types. lastRoomRequests map[string]int64 - transientData *TransientData + transientData *api.TransientData } -func getRoomIdForBackend(id string, backend *Backend) string { +func getRoomIdForBackend(id string, backend *talk.Backend) string { if id == "" { return "" } @@ -95,35 +118,45 @@ func getRoomIdForBackend(id string, backend *Backend) string { return backend.Id() + "|" + id } -func NewRoom(roomId string, properties json.RawMessage, hub *Hub, events AsyncEvents, backend *Backend) (*Room, error) { +func NewRoom(roomId string, properties json.RawMessage, hub *Hub, asyncEvents events.AsyncEvents, backend *talk.Backend) (*Room, error) { room := &Room{ id: roomId, + logger: hub.logger, hub: hub, - events: events, + events: asyncEvents, backend: backend, properties: properties, - closer: NewCloser(), + closer: internal.NewCloser(), mu: &sync.RWMutex{}, - sessions: make(map[string]Session), + asyncCh: make(events.AsyncChannel, events.DefaultAsyncChannelSize), + sessions: make(map[api.PublicSessionId]Session), internalSessions: make(map[*ClientSession]bool), virtualSessions: make(map[*VirtualSession]bool), inCallSessions: make(map[Session]bool), - roomSessionData: make(map[string]*RoomSessionData), + roomSessionData: make(map[api.PublicSessionId]*talk.RoomSessionData), statsRoomSessionsCurrent: statsRoomSessionsCurrent.MustCurryWith(prometheus.Labels{ "backend": backend.Id(), "room": roomId, }), + statsCallSessionsCurrent: statsCallSessionsCurrent.MustCurryWith(prometheus.Labels{ + "backend": backend.Id(), + "room": roomId, + }), + statsCallSessionsTotal: statsCallSessionsTotal.MustCurryWith(prometheus.Labels{ + "backend": backend.Id(), + }), + statsCallRoomsTotal: statsCallRoomsTotal.WithLabelValues(backend.Id()), lastRoomRequests: make(map[string]int64), - transientData: NewTransientData(), + transientData: api.NewTransientData(), } - if err := events.RegisterBackendRoomListener(roomId, backend, room); err != nil { + if err := asyncEvents.RegisterBackendRoomListener(roomId, backend, room); err != nil { return nil, err } @@ -142,7 +175,7 @@ func (r *Room) Properties() json.RawMessage { return r.properties } -func (r *Room) Backend() *Backend { +func (r *Room) Backend() *talk.Backend { return r.backend } @@ -168,6 +201,10 @@ func (r *Room) IsEqual(other *Room) bool { return b1.Id() == b2.Id() } +func (r *Room) AsyncChannel() events.AsyncChannel { + return r.asyncCh +} + func (r *Room) run() { ticker := time.NewTicker(updateActiveSessionsInterval) loop: @@ -175,6 +212,11 @@ loop: select { case <-r.closer.C: break loop + case msg := <-r.asyncCh: + r.processAsyncNatsMessage(msg) + for count := len(r.asyncCh); count > 0; count-- { + r.processAsyncNatsMessage(<-r.asyncCh) + } case <-ticker.C: r.publishActiveSessions() } @@ -186,50 +228,65 @@ func (r *Room) doClose() { } func (r *Room) unsubscribeBackend() { - r.events.UnregisterBackendRoomListener(r.id, r.backend, r) + if err := r.events.UnregisterBackendRoomListener(r.id, r.backend, r); err != nil && !errors.Is(err, nats.ErrConnectionClosed) { + r.logger.Printf("Error unsubscribing room listener in %s: %s", r.id, err) + } } func (r *Room) Close() []Session { r.hub.removeRoom(r) r.doClose() r.mu.Lock() + defer r.mu.Unlock() r.unsubscribeBackend() result := make([]Session, 0, len(r.sessions)) for _, s := range r.sessions { result = append(result, s) } r.sessions = nil - r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": HelloClientTypeClient}) - r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": HelloClientTypeInternal}) - r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": HelloClientTypeVirtual}) - r.mu.Unlock() + r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeClient)}) + r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeFederation)}) + r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeInternal)}) + r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeVirtual)}) + r.clearInCallStats() return result } -func (r *Room) ProcessBackendRoomRequest(message *AsyncMessage) { +func (r *Room) processAsyncNatsMessage(msg *nats.Msg) { + var message events.AsyncMessage + if err := nats.Decode(msg, &message); err != nil { + r.logger.Printf("Could not decode NATS message %+v: %s", msg, err) + return + } + + r.processAsyncMessage(&message) +} + +func (r *Room) processAsyncMessage(message *events.AsyncMessage) { switch message.Type { case "room": r.processBackendRoomRequestRoom(message.Room) case "asyncroom": r.processBackendRoomRequestAsyncRoom(message.AsyncRoom) default: - log.Printf("Unsupported backend room request with type %s in %s: %+v", message.Type, r.id, message) + r.logger.Printf("Unsupported backend room request with type %s in %s: %+v", message.Type, r.id, message) } } -func (r *Room) processBackendRoomRequestRoom(message *BackendServerRoomRequest) { +func (r *Room) processBackendRoomRequestRoom(message *talk.BackendServerRoomRequest) { received := message.ReceivedTime if last, found := r.lastRoomRequests[message.Type]; found && last > received { if msg, err := json.Marshal(message); err == nil { - log.Printf("Ignore old backend room request for %s: %s", r.Id(), string(msg)) + r.logger.Printf("Ignore old backend room request for %s: %s", r.Id(), string(msg)) } else { - log.Printf("Ignore old backend room request for %s: %+v", r.Id(), message) + r.logger.Printf("Ignore old backend room request for %s: %+v", r.Id(), message) } return } r.lastRoomRequests[message.Type] = received - message.room = r + message.RoomId = r.Id() + message.Backend = r.Backend() switch message.Type { case "update": r.hub.roomUpdated <- message @@ -246,50 +303,55 @@ func (r *Room) processBackendRoomRequestRoom(message *BackendServerRoomRequest) r.publishSwitchTo(message.SwitchTo) case "transient": switch message.Transient.Action { - case TransientActionSet: - r.SetTransientDataTTL(message.Transient.Key, message.Transient.Value, message.Transient.TTL) - case TransientActionDelete: - r.RemoveTransientData(message.Transient.Key) + case talk.TransientActionSet: + if message.Transient.TTL == 0 { + r.doSetTransientData(message.Transient.Key, message.Transient.Value) + } else { + r.doSetTransientDataTTL(message.Transient.Key, message.Transient.Value, message.Transient.TTL) + } + case talk.TransientActionDelete: + r.doRemoveTransientData(message.Transient.Key) default: - log.Printf("Unsupported transient action in room %s: %+v", r.Id(), message.Transient) + r.logger.Printf("Unsupported transient action in room %s: %+v", r.Id(), message.Transient) } default: - log.Printf("Unsupported backend room request with type %s in %s: %+v", message.Type, r.Id(), message) + r.logger.Printf("Unsupported backend room request with type %s in %s: %+v", message.Type, r.Id(), message) } } -func (r *Room) processBackendRoomRequestAsyncRoom(message *AsyncRoomMessage) { +func (r *Room) processBackendRoomRequestAsyncRoom(message *events.AsyncRoomMessage) { switch message.Type { case "sessionjoined": r.notifySessionJoined(message.SessionId) - if message.ClientType == HelloClientTypeInternal { + if message.ClientType == api.HelloClientTypeInternal { r.publishUsersChangedWithInternal() } default: - log.Printf("Unsupported async room request with type %s in %s: %+v", message.Type, r.Id(), message) + r.logger.Printf("Unsupported async room request with type %s in %s: %+v", message.Type, r.Id(), message) } } func (r *Room) AddSession(session Session, sessionData json.RawMessage) { - var roomSessionData *RoomSessionData + var roomSessionData *talk.RoomSessionData if len(sessionData) > 0 { - roomSessionData = &RoomSessionData{} + roomSessionData = &talk.RoomSessionData{} if err := json.Unmarshal(sessionData, roomSessionData); err != nil { - log.Printf("Error decoding room session data \"%s\": %s", string(sessionData), err) + r.logger.Printf("Error decoding room session data \"%s\": %s", string(sessionData), err) roomSessionData = nil } } sid := session.PublicId() r.mu.Lock() + isFirst := len(r.sessions) == 0 _, found := r.sessions[sid] r.sessions[sid] = session if !found { - r.statsRoomSessionsCurrent.With(prometheus.Labels{"clienttype": session.ClientType()}).Inc() + r.statsRoomSessionsCurrent.With(prometheus.Labels{"clienttype": string(session.ClientType())}).Inc() } var publishUsersChanged bool switch session.ClientType() { - case HelloClientTypeInternal: + case api.HelloClientTypeInternal: clientSession, ok := session.(*ClientSession) if !ok { delete(r.sessions, sid) @@ -297,7 +359,7 @@ func (r *Room) AddSession(session Session, sessionData json.RawMessage) { panic(fmt.Sprintf("Expected a client session, got %v (%T)", session, session)) } r.internalSessions[clientSession] = true - case HelloClientTypeVirtual: + case api.HelloClientTypeVirtual: virtualSession, ok := session.(*VirtualSession) if !ok { delete(r.sessions, sid) @@ -309,7 +371,7 @@ func (r *Room) AddSession(session Session, sessionData json.RawMessage) { } if roomSessionData != nil { r.roomSessionData[sid] = roomSessionData - log.Printf("Session %s sent room session data %+v", session.PublicId(), roomSessionData) + r.logger.Printf("Session %s sent room session data %+v", session.PublicId(), roomSessionData) } r.mu.Unlock() if !found { @@ -323,22 +385,25 @@ func (r *Room) AddSession(session Session, sessionData json.RawMessage) { if clientSession, ok := session.(*ClientSession); ok { r.transientData.AddListener(clientSession) } + if isFirst { + r.fetchInitialTransientData() + } } // Trigger notifications that the session joined. - if err := r.events.PublishBackendRoomMessage(r.id, r.backend, &AsyncMessage{ + if err := r.events.PublishBackendRoomMessage(r.id, r.backend, &events.AsyncMessage{ Type: "asyncroom", - AsyncRoom: &AsyncRoomMessage{ + AsyncRoom: &events.AsyncRoomMessage{ Type: "sessionjoined", SessionId: sid, ClientType: session.ClientType(), }, }); err != nil { - log.Printf("Error publishing joined event for session %s: %s", sid, err) + r.logger.Printf("Error publishing joined event for session %s: %s", sid, err) } } -func (r *Room) getOtherSessions(ignoreSessionId string) (Session, []Session) { +func (r *Room) getOtherSessions(ignoreSessionId api.PublicSessionId) (Session, []Session) { r.mu.Lock() defer r.mu.Unlock() @@ -354,19 +419,19 @@ func (r *Room) getOtherSessions(ignoreSessionId string) (Session, []Session) { return r.sessions[ignoreSessionId], sessions } -func (r *Room) notifySessionJoined(sessionId string) { +func (r *Room) notifySessionJoined(sessionId api.PublicSessionId) { session, sessions := r.getOtherSessions(sessionId) if len(sessions) == 0 { return } - if session != nil && session.ClientType() != HelloClientTypeClient { + if session != nil && session.ClientType() != api.HelloClientTypeClient { session = nil } - events := make([]*EventServerMessageSessionEntry, 0, len(sessions)) + joinEvents := make([]api.EventServerMessageSessionEntry, 0, len(sessions)) for _, s := range sessions { - entry := &EventServerMessageSessionEntry{ + entry := api.EventServerMessageSessionEntry{ SessionId: s.PublicId(), UserId: s.UserId(), User: s.UserData(), @@ -374,25 +439,25 @@ func (r *Room) notifySessionJoined(sessionId string) { if s, ok := s.(*ClientSession); ok { entry.Features = s.GetFeatures() entry.RoomSessionId = s.RoomSessionId() - entry.Federated = s.ClientType() == HelloClientTypeFederation + entry.Federated = s.ClientType() == api.HelloClientTypeFederation } - events = append(events, entry) + joinEvents = append(joinEvents, entry) } - msg := &ServerMessage{ + msg := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "room", Type: "join", - Join: events, + Join: joinEvents, }, } - if err := r.events.PublishSessionMessage(sessionId, r.backend, &AsyncMessage{ + if err := r.events.PublishSessionMessage(sessionId, r.backend, &events.AsyncMessage{ Type: "message", Message: msg, }); err != nil { - log.Printf("Error publishing joined events to session %s: %s", sessionId, err) + r.logger.Printf("Error publishing joined events to session %s: %s", sessionId, err) } // Notify about initial flags of virtual sessions. @@ -407,12 +472,12 @@ func (r *Room) notifySessionJoined(sessionId string) { continue } - msg := &ServerMessage{ + msg := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "participants", Type: "flags", - Flags: &RoomFlagsServerMessage{ + Flags: &api.RoomFlagsServerMessage{ RoomId: r.id, SessionId: vsess.PublicId(), Flags: vsess.Flags(), @@ -420,27 +485,80 @@ func (r *Room) notifySessionJoined(sessionId string) { }, } - if err := r.events.PublishSessionMessage(sessionId, r.backend, &AsyncMessage{ + if err := r.events.PublishSessionMessage(sessionId, r.backend, &events.AsyncMessage{ Type: "message", Message: msg, }); err != nil { - log.Printf("Error publishing initial flags to session %s: %s", sessionId, err) + r.logger.Printf("Error publishing initial flags to session %s: %s", sessionId, err) } } } func (r *Room) HasSession(session Session) bool { r.mu.RLock() + defer r.mu.RUnlock() + _, result := r.sessions[session.PublicId()] - r.mu.RUnlock() return result } func (r *Room) IsSessionInCall(session Session) bool { r.mu.RLock() - _, result := r.inCallSessions[session] - r.mu.RUnlock() - return result + defer r.mu.RUnlock() + + return r.inCallSessions[session] +} + +// +checklocks:r.mu +func (r *Room) clearInCallStats() { + r.statsCallSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeClient)}) + r.statsCallSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeFederation)}) + r.statsCallSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeInternal)}) + r.statsCallSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeVirtual)}) +} + +func (r *Room) addSessionToCall(session Session) bool { + r.mu.Lock() + defer r.mu.Unlock() + + return r.addSessionToCallLocked(session) +} + +// +checklocks:r.mu +func (r *Room) addSessionToCallLocked(session Session) bool { + if r.inCallSessions[session] { + return false + } + + if len(r.inCallSessions) == 0 { + r.statsCallRoomsTotal.Inc() + } + r.inCallSessions[session] = true + r.statsCallSessionsCurrent.WithLabelValues(string(session.ClientType())).Inc() + r.statsCallSessionsTotal.WithLabelValues(string(session.ClientType())).Inc() + return true +} + +func (r *Room) removeSessionFromCall(session Session) bool { + r.mu.Lock() + defer r.mu.Unlock() + + return r.removeSessionFromCallLocked(session) +} + +// +checklocks:r.mu +func (r *Room) removeSessionFromCallLocked(session Session) bool { + if !r.inCallSessions[session] { + return false + } + + delete(r.inCallSessions, session) + if len(r.inCallSessions) == 0 { + r.clearInCallStats() + } else { + r.statsCallSessionsCurrent.WithLabelValues(string(session.ClientType())).Dec() + } + return true } // Returns "true" if there are still clients in the room. @@ -452,14 +570,14 @@ func (r *Room) RemoveSession(session Session) bool { } sid := session.PublicId() - r.statsRoomSessionsCurrent.With(prometheus.Labels{"clienttype": session.ClientType()}).Dec() + r.statsRoomSessionsCurrent.With(prometheus.Labels{"clienttype": string(session.ClientType())}).Dec() delete(r.sessions, sid) if virtualSession, ok := session.(*VirtualSession); ok { delete(r.virtualSessions, virtualSession) // Handle case where virtual session was also sent by Nextcloud. - users := make([]map[string]interface{}, 0, len(r.users)) + users := make([]api.StringMap, 0, len(r.users)) for _, u := range r.users { - if u["sessionId"] != sid { + if value, found := api.GetStringMapString[api.PublicSessionId](u, "sessionId"); !found || value != sid { users = append(users, u) } } @@ -471,28 +589,34 @@ func (r *Room) RemoveSession(session Session) bool { delete(r.internalSessions, clientSession) r.transientData.RemoveListener(clientSession) } - delete(r.inCallSessions, session) + r.removeSessionFromCallLocked(session) delete(r.roomSessionData, sid) if len(r.sessions) > 0 { r.mu.Unlock() + if err := r.RemoveTransientData(api.TransientSessionDataPrefix + string(sid)); err != nil { + r.logger.Printf("Error removing transient data for session %s", sid) + } r.PublishSessionLeft(session) return true } r.hub.removeRoom(r) - r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": HelloClientTypeClient}) - r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": HelloClientTypeInternal}) - r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": HelloClientTypeVirtual}) + r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeClient)}) + r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeInternal)}) + r.statsRoomSessionsCurrent.Delete(prometheus.Labels{"clienttype": string(api.HelloClientTypeVirtual)}) r.unsubscribeBackend() r.doClose() r.mu.Unlock() + if err := r.RemoveTransientData(api.TransientSessionDataPrefix + string(sid)); err != nil { + r.logger.Printf("Error removing transient data for session %s", sid) + } // Still need to publish an event so sessions on other servers get notified. r.PublishSessionLeft(session) return false } -func (r *Room) publish(message *ServerMessage) error { - return r.events.PublishRoomMessage(r.id, r.backend, &AsyncMessage{ +func (r *Room) publish(message *api.ServerMessage) error { + return r.events.PublishRoomMessage(r.id, r.backend, &events.AsyncMessage{ Type: "message", Message: message, }) @@ -508,25 +632,25 @@ func (r *Room) UpdateProperties(properties json.RawMessage) { } r.properties = properties - message := &ServerMessage{ + message := &api.ServerMessage{ Type: "room", - Room: &RoomServerMessage{ + Room: &api.RoomServerMessage{ RoomId: r.id, Properties: r.properties, }, } if err := r.publish(message); err != nil { - log.Printf("Could not publish update properties message in room %s: %s", r.Id(), err) + r.logger.Printf("Could not publish update properties message in room %s: %s", r.Id(), err) } } -func (r *Room) GetRoomSessionData(session Session) *RoomSessionData { +func (r *Room) GetRoomSessionData(session Session) *talk.RoomSessionData { r.mu.RLock() defer r.mu.RUnlock() return r.roomSessionData[session.PublicId()] } -func (r *Room) PublishSessionJoined(session Session, sessionData *RoomSessionData) { +func (r *Room) PublishSessionJoined(session Session, sessionData *talk.RoomSessionData) { sessionId := session.PublicId() if sessionId == "" { return @@ -537,12 +661,12 @@ func (r *Room) PublishSessionJoined(session Session, sessionData *RoomSessionDat userid = sessionData.UserId } - message := &ServerMessage{ + message := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "room", Type: "join", - Join: []*EventServerMessageSessionEntry{ + Join: []api.EventServerMessageSessionEntry{ { SessionId: sessionId, UserId: userid, @@ -554,10 +678,10 @@ func (r *Room) PublishSessionJoined(session Session, sessionData *RoomSessionDat if session, ok := session.(*ClientSession); ok { message.Event.Join[0].Features = session.GetFeatures() message.Event.Join[0].RoomSessionId = session.RoomSessionId() - message.Event.Join[0].Federated = session.ClientType() == HelloClientTypeFederation + message.Event.Join[0].Federated = session.ClientType() == api.HelloClientTypeFederation } if err := r.publish(message); err != nil { - log.Printf("Could not publish session joined message in room %s: %s", r.Id(), err) + r.logger.Printf("Could not publish session joined message in room %s: %s", r.Id(), err) } } @@ -567,70 +691,65 @@ func (r *Room) PublishSessionLeft(session Session) { return } - message := &ServerMessage{ + message := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "room", Type: "leave", - Leave: []string{ + Leave: []api.PublicSessionId{ sessionId, }, }, } if err := r.publish(message); err != nil { - log.Printf("Could not publish session left message in room %s: %s", r.Id(), err) + r.logger.Printf("Could not publish session left message in room %s: %s", r.Id(), err) } - if session.ClientType() == HelloClientTypeInternal { + if session.ClientType() == api.HelloClientTypeInternal { r.publishUsersChangedWithInternal() } } -func (r *Room) getClusteredInternalSessionsRLocked() (internal map[string]*InternalSessionData, virtual map[string]*VirtualSessionData) { +// +checklocksread:r.mu +func (r *Room) getClusteredInternalSessionsRLocked() (internal map[api.PublicSessionId]*grpc.InternalSessionData, virtual map[api.PublicSessionId]*grpc.VirtualSessionData) { if r.hub.rpcClients == nil { return nil, nil } r.mu.RUnlock() defer r.mu.RLock() - ctx, cancel := context.WithTimeout(context.Background(), time.Second) + ctx := log.NewLoggerContext(context.Background(), r.logger) + ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() var mu sync.Mutex var wg sync.WaitGroup for _, client := range r.hub.rpcClients.GetClients() { - wg.Add(1) - go func(c *GrpcClient) { - defer wg.Done() - - clientInternal, clientVirtual, err := c.GetInternalSessions(ctx, r.Id(), r.Backend()) + wg.Go(func() { + clientInternal, clientVirtual, err := client.GetInternalSessions(ctx, r.Id(), r.Backend().Urls()) if err != nil { - log.Printf("Received error while getting internal sessions for %s@%s from %s: %s", r.Id(), r.Backend().Id(), c.Target(), err) + r.logger.Printf("Received error while getting internal sessions for %s@%s from %s: %s", r.Id(), r.Backend().Id(), client.Target(), err) return } mu.Lock() defer mu.Unlock() if internal == nil { - internal = make(map[string]*InternalSessionData, len(clientInternal)) - } - for sid, s := range clientInternal { - internal[sid] = s + internal = make(map[api.PublicSessionId]*grpc.InternalSessionData, len(clientInternal)) } + maps.Copy(internal, clientInternal) if virtual == nil { - virtual = make(map[string]*VirtualSessionData, len(clientVirtual)) + virtual = make(map[api.PublicSessionId]*grpc.VirtualSessionData, len(clientVirtual)) } - for sid, s := range clientVirtual { - virtual[sid] = s - } - }(client) + maps.Copy(virtual, clientVirtual) + }) } wg.Wait() return } -func (r *Room) addInternalSessions(users []map[string]interface{}) []map[string]interface{} { +func (r *Room) addInternalSessions(users []api.StringMap) []api.StringMap { now := time.Now().Unix() r.mu.RLock() defer r.mu.RUnlock() @@ -645,36 +764,34 @@ func (r *Room) addInternalSessions(users []map[string]interface{}) []map[string] return users } - skipSession := make(map[string]bool) + skipSession := make(map[api.PublicSessionId]bool) for _, user := range users { - sessionid, found := user["sessionId"] + sessionid, found := api.GetStringMapString[api.PublicSessionId](user, "sessionId") if !found || sessionid == "" { continue } if userid, found := user["userId"]; !found || userid == "" { - if roomSessionData, found := r.roomSessionData[sessionid.(string)]; found { + if roomSessionData, found := r.roomSessionData[sessionid]; found { user["userId"] = roomSessionData.UserId - } else if sid, ok := sessionid.(string); ok { - if entry, found := clusteredVirtualSessions[sid]; found { - user["virtual"] = true - user["inCall"] = entry.GetInCall() - skipSession[sid] = true - } else { - for session := range r.virtualSessions { - if session.PublicId() == sid { - user["virtual"] = true - user["inCall"] = session.GetInCall() - skipSession[sid] = true - break - } + } else if entry, found := clusteredVirtualSessions[sessionid]; found { + user["virtual"] = true + user["inCall"] = entry.GetInCall() + skipSession[sessionid] = true + } else { + for session := range r.virtualSessions { + if session.PublicId() == sessionid { + user["virtual"] = true + user["inCall"] = session.GetInCall() + skipSession[sessionid] = true + break } } } } } for session := range r.internalSessions { - u := map[string]interface{}{ + u := api.StringMap{ "inCall": session.GetInCall(), "sessionId": session.PublicId(), "lastPing": now, @@ -686,7 +803,7 @@ func (r *Room) addInternalSessions(users []map[string]interface{}) []map[string] users = append(users, u) } for _, session := range clusteredInternalSessions { - u := map[string]interface{}{ + u := api.StringMap{ "inCall": session.GetInCall(), "sessionId": session.GetSessionId(), "lastPing": now, @@ -703,7 +820,7 @@ func (r *Room) addInternalSessions(users []map[string]interface{}) []map[string] continue } skipSession[sid] = true - users = append(users, map[string]interface{}{ + users = append(users, api.StringMap{ "inCall": session.GetInCall(), "sessionId": sid, "lastPing": now, @@ -715,7 +832,7 @@ func (r *Room) addInternalSessions(users []map[string]interface{}) []map[string] continue } - users = append(users, map[string]interface{}{ + users = append(users, api.StringMap{ "inCall": session.GetInCall(), "sessionId": sid, "lastPing": now, @@ -725,14 +842,14 @@ func (r *Room) addInternalSessions(users []map[string]interface{}) []map[string] return users } -func (r *Room) filterPermissions(users []map[string]interface{}) []map[string]interface{} { +func (r *Room) filterPermissions(users []api.StringMap) []api.StringMap { for _, user := range users { delete(user, "permissions") } return users } -func IsInCall(value interface{}) (bool, bool) { +func IsInCall(value any) (bool, bool) { switch value := value.(type) { case bool: return value, true @@ -752,7 +869,7 @@ func IsInCall(value interface{}) (bool, bool) { } } -func (r *Room) PublishUsersInCallChanged(changed []map[string]interface{}, users []map[string]interface{}) { +func (r *Room) PublishUsersInCallChanged(changed []api.StringMap, users []api.StringMap) { r.users = users for _, user := range changed { inCallInterface, found := user["inCall"] @@ -764,35 +881,25 @@ func (r *Room) PublishUsersInCallChanged(changed []map[string]interface{}, users continue } - sessionIdInterface, found := user["sessionId"] + sessionId, found := api.GetStringMapString[api.PublicSessionId](user, "sessionId") if !found { - sessionIdInterface, found = user["sessionid"] + sessionId, found = api.GetStringMapString[api.PublicSessionId](user, "sessionid") if !found { continue } } - sessionId, ok := sessionIdInterface.(string) - if !ok { - continue - } - session := r.hub.GetSessionByPublicId(sessionId) if session == nil { continue } if inCall { - r.mu.Lock() - if !r.inCallSessions[session] { - r.inCallSessions[session] = true - log.Printf("Session %s joined call %s", session.PublicId(), r.id) + if r.addSessionToCall(session) { + r.logger.Printf("Session %s joined call %s", session.PublicId(), r.id) } - r.mu.Unlock() } else { - r.mu.Lock() - delete(r.inCallSessions, session) - r.mu.Unlock() + r.removeSessionFromCall(session) if clientSession, ok := session.(*ClientSession); ok { clientSession.LeaveCall() } @@ -802,12 +909,12 @@ func (r *Room) PublishUsersInCallChanged(changed []map[string]interface{}, users changed = r.filterPermissions(changed) users = r.filterPermissions(users) - message := &ServerMessage{ + message := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "participants", Type: "update", - Update: &RoomEventServerMessage{ + Update: &api.RoomEventServerMessage{ RoomId: r.id, Changed: changed, Users: r.addInternalSessions(users), @@ -815,7 +922,7 @@ func (r *Room) PublishUsersInCallChanged(changed []map[string]interface{}, users }, } if err := r.publish(message); err != nil { - log.Printf("Could not publish incall message in room %s: %s", r.Id(), err) + r.logger.Printf("Could not publish incall message in room %s: %s", r.Id(), err) } } @@ -826,20 +933,19 @@ func (r *Room) PublishUsersInCallChangedAll(inCall int) { var notify []*ClientSession if inCall&FlagInCall != 0 { // All connected sessions join the call. - var joined []string + var joined []api.PublicSessionId for _, session := range r.sessions { clientSession, ok := session.(*ClientSession) if !ok { continue } - if session.ClientType() == HelloClientTypeInternal || - session.ClientType() == HelloClientTypeFederation { + if session.ClientType() == api.HelloClientTypeInternal || + session.ClientType() == api.HelloClientTypeFederation { continue } - if !r.inCallSessions[session] { - r.inCallSessions[session] = true + if r.addSessionToCallLocked(session) { joined = append(joined, session.PublicId()) } notify = append(notify, clientSession) @@ -849,7 +955,7 @@ func (r *Room) PublishUsersInCallChangedAll(inCall int) { return } - log.Printf("Sessions %v joined call %s", joined, r.id) + r.logger.Printf("Sessions %v joined call %s", joined, r.id) } else if len(r.inCallSessions) > 0 { // Perform actual leaving asynchronously. ch := make(chan *ClientSession, 1) @@ -879,7 +985,8 @@ func (r *Room) PublishUsersInCallChangedAll(inCall int) { } } close(ch) - r.inCallSessions = make(map[Session]bool) + clear(r.inCallSessions) + r.clearInCallStats() } else { // All sessions already left the call, no need to notify. return @@ -887,12 +994,12 @@ func (r *Room) PublishUsersInCallChangedAll(inCall int) { inCallMsg := json.RawMessage(strconv.FormatInt(int64(inCall), 10)) - message := &ServerMessage{ + message := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "participants", Type: "update", - Update: &RoomEventServerMessage{ + Update: &api.RoomEventServerMessage{ RoomId: r.id, InCall: inCallMsg, All: true, @@ -902,21 +1009,21 @@ func (r *Room) PublishUsersInCallChangedAll(inCall int) { for _, session := range notify { if !session.SendMessage(message) { - log.Printf("Could not send incall message from room %s to %s", r.Id(), session.PublicId()) + r.logger.Printf("Could not send incall message from room %s to %s", r.Id(), session.PublicId()) } } } -func (r *Room) PublishUsersChanged(changed []map[string]interface{}, users []map[string]interface{}) { +func (r *Room) PublishUsersChanged(changed []api.StringMap, users []api.StringMap) { changed = r.filterPermissions(changed) users = r.filterPermissions(users) - message := &ServerMessage{ + message := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "participants", Type: "update", - Update: &RoomEventServerMessage{ + Update: &api.RoomEventServerMessage{ RoomId: r.id, Changed: changed, Users: r.addInternalSessions(users), @@ -924,19 +1031,19 @@ func (r *Room) PublishUsersChanged(changed []map[string]interface{}, users []map }, } if err := r.publish(message); err != nil { - log.Printf("Could not publish users changed message in room %s: %s", r.Id(), err) + r.logger.Printf("Could not publish users changed message in room %s: %s", r.Id(), err) } } -func (r *Room) getParticipantsUpdateMessage(users []map[string]interface{}) *ServerMessage { +func (r *Room) getParticipantsUpdateMessage(users []api.StringMap) *api.ServerMessage { users = r.filterPermissions(users) - message := &ServerMessage{ + message := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "participants", Type: "update", - Update: &RoomEventServerMessage{ + Update: &api.RoomEventServerMessage{ RoomId: r.id, Users: r.addInternalSessions(users), }, @@ -955,7 +1062,7 @@ func (r *Room) NotifySessionResumed(session *ClientSession) { } func (r *Room) NotifySessionChanged(session Session, flags SessionChangeFlag) { - if flags&SessionChangeFlags != 0 && session.ClientType() == HelloClientTypeVirtual { + if flags&SessionChangeFlags != 0 && session.ClientType() == api.HelloClientTypeVirtual { // Only notify if a virtual session has changed. if virtual, ok := session.(*VirtualSession); ok { r.publishSessionFlagsChanged(virtual) @@ -964,14 +1071,8 @@ func (r *Room) NotifySessionChanged(session Session, flags SessionChangeFlag) { if flags&SessionChangeInCall != 0 { joinLeave := 0 - if clientSession, ok := session.(*ClientSession); ok { - if clientSession.GetInCall()&FlagInCall != 0 { - joinLeave = 1 - } else { - joinLeave = 2 - } - } else if virtual, ok := session.(*VirtualSession); ok { - if virtual.GetInCall()&FlagInCall != 0 { + if session, ok := session.(SessionWithInCall); ok { + if session.GetInCall()&FlagInCall != 0 { joinLeave = 1 } else { joinLeave = 2 @@ -979,17 +1080,13 @@ func (r *Room) NotifySessionChanged(session Session, flags SessionChangeFlag) { } if joinLeave != 0 { - if joinLeave == 1 { - r.mu.Lock() - if !r.inCallSessions[session] { - r.inCallSessions[session] = true - log.Printf("Session %s joined call %s", session.PublicId(), r.id) + switch joinLeave { + case 1: + if r.addSessionToCall(session) { + r.logger.Printf("Session %s joined call %s", session.PublicId(), r.id) } - r.mu.Unlock() - } else if joinLeave == 2 { - r.mu.Lock() - delete(r.inCallSessions, session) - r.mu.Unlock() + case 2: + r.removeSessionFromCall(session) if clientSession, ok := session.(*ClientSession); ok { clientSession.LeaveCall() } @@ -1008,17 +1105,17 @@ func (r *Room) publishUsersChangedWithInternal() { } if err := r.publish(message); err != nil { - log.Printf("Could not publish users changed message in room %s: %s", r.Id(), err) + r.logger.Printf("Could not publish users changed message in room %s: %s", r.Id(), err) } } func (r *Room) publishSessionFlagsChanged(session *VirtualSession) { - message := &ServerMessage{ + message := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "participants", Type: "flags", - Flags: &RoomFlagsServerMessage{ + Flags: &api.RoomFlagsServerMessage{ RoomId: r.id, SessionId: session.PublicId(), Flags: session.Flags(), @@ -1026,7 +1123,7 @@ func (r *Room) publishSessionFlagsChanged(session *VirtualSession) { }, } if err := r.publish(message); err != nil { - log.Printf("Could not publish flags changed message in room %s: %s", r.Id(), err) + r.logger.Printf("Could not publish flags changed message in room %s: %s", r.Id(), err) } } @@ -1034,7 +1131,7 @@ func (r *Room) publishActiveSessions() (int, *sync.WaitGroup) { r.mu.RLock() defer r.mu.RUnlock() - entries := make(map[string][]BackendPingEntry) + entries := make(map[string][]talk.BackendPingEntry) urls := make(map[string]*url.URL) for _, session := range r.sessions { u := session.BackendUrl() @@ -1042,7 +1139,21 @@ func (r *Room) publishActiveSessions() (int, *sync.WaitGroup) { continue } - var sid string + u += PathToOcsSignalingBackend + parsed, err := url.Parse(u) + if err != nil { + r.logger.Printf("Could not parse backend url %s: %s", u, err) + continue + } + + var changed bool + if parsed, changed = internal.CanonicalizeUrl(parsed); changed { + u = parsed.String() + } + + parsedBackendUrl := parsed + + var sid api.RoomSessionId var uid string switch sess := session.(type) { case *ClientSession: @@ -1051,7 +1162,7 @@ func (r *Room) publishActiveSessions() (int, *sync.WaitGroup) { uid = sess.AuthUserId() case *VirtualSession: // Use our internal generated session id (will be added to Nextcloud). - sid = sess.PublicId() + sid = api.RoomSessionId(sess.PublicId()) uid = sess.UserId() default: continue @@ -1061,15 +1172,14 @@ func (r *Room) publishActiveSessions() (int, *sync.WaitGroup) { } e, found := entries[u] if !found { - p := session.ParsedBackendUrl() - if p == nil { + if parsedBackendUrl == nil { // Should not happen, invalid URLs should get rejected earlier. continue } - urls[u] = p + urls[u] = parsedBackendUrl } - entries[u] = append(e, BackendPingEntry{ + entries[u] = append(e, talk.BackendPingEntry{ SessionId: sid, UserId: uid, }) @@ -1079,106 +1189,101 @@ func (r *Room) publishActiveSessions() (int, *sync.WaitGroup) { return 0, &wg } var count int + ctx := log.NewLoggerContext(context.Background(), r.logger) for u, e := range entries { wg.Add(1) count += len(e) - go func(url *url.URL, entries []BackendPingEntry) { + go func(url *url.URL, entries []talk.BackendPingEntry) { defer wg.Done() - ctx, cancel := context.WithTimeout(context.Background(), r.hub.backendTimeout) + sendCtx, cancel := context.WithTimeout(ctx, r.hub.backendTimeout) defer cancel() - if err := r.hub.roomPing.SendPings(ctx, r.id, url, entries); err != nil { - log.Printf("Error pinging room %s for active entries %+v: %s", r.id, entries, err) + if err := r.hub.roomPing.SendPings(sendCtx, r.id, url, entries); err != nil { + r.logger.Printf("Error pinging room %s for active entries %+v: %s", r.id, entries, err) } }(urls[u], e) } return count, &wg } -func (r *Room) publishRoomMessage(message *BackendRoomMessageRequest) { +func (r *Room) publishRoomMessage(message *talk.BackendRoomMessageRequest) { if message == nil || len(message.Data) == 0 { return } - msg := &ServerMessage{ + msg := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "room", Type: "message", - Message: &RoomEventMessage{ + Message: &api.RoomEventMessage{ RoomId: r.id, Data: message.Data, }, }, } if err := r.publish(msg); err != nil { - log.Printf("Could not publish room message in room %s: %s", r.Id(), err) + r.logger.Printf("Could not publish room message in room %s: %s", r.Id(), err) } } -func (r *Room) publishSwitchTo(message *BackendRoomSwitchToMessageRequest) { +func (r *Room) publishSwitchTo(message *talk.BackendRoomSwitchToMessageRequest) { var wg sync.WaitGroup if len(message.SessionsList) > 0 { - msg := &ServerMessage{ + msg := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "room", Type: "switchto", - SwitchTo: &EventServerMessageSwitchTo{ + SwitchTo: &api.EventServerMessageSwitchTo{ RoomId: message.RoomId, }, }, } for _, sessionId := range message.SessionsList { - wg.Add(1) - go func(sessionId string) { - defer wg.Done() - - if err := r.events.PublishSessionMessage(sessionId, r.backend, &AsyncMessage{ + wg.Go(func() { + if err := r.events.PublishSessionMessage(sessionId, r.backend, &events.AsyncMessage{ Type: "message", Message: msg, }); err != nil { - log.Printf("Error publishing switchto event to session %s: %s", sessionId, err) + r.logger.Printf("Error publishing switchto event to session %s: %s", sessionId, err) } - }(sessionId) + }) } } if len(message.SessionsMap) > 0 { for sessionId, details := range message.SessionsMap { - wg.Add(1) - go func(sessionId string, details json.RawMessage) { - defer wg.Done() - - msg := &ServerMessage{ + wg.Go(func() { + msg := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "room", Type: "switchto", - SwitchTo: &EventServerMessageSwitchTo{ + SwitchTo: &api.EventServerMessageSwitchTo{ RoomId: message.RoomId, Details: details, }, }, } - if err := r.events.PublishSessionMessage(sessionId, r.backend, &AsyncMessage{ + if err := r.events.PublishSessionMessage(sessionId, r.backend, &events.AsyncMessage{ Type: "message", Message: msg, }); err != nil { - log.Printf("Error publishing switchto event to session %s: %s", sessionId, err) + r.logger.Printf("Error publishing switchto event to session %s: %s", sessionId, err) } - }(sessionId, details) + }) } } wg.Wait() } func (r *Room) notifyInternalRoomDeleted() { - msg := &ServerMessage{ + msg := &api.ServerMessage{ Type: "event", - Event: &EventServerMessage{ + Event: &api.EventServerMessage{ Target: "room", Type: "delete", }, @@ -1191,14 +1296,107 @@ func (r *Room) notifyInternalRoomDeleted() { } } -func (r *Room) SetTransientData(key string, value interface{}) { +func (r *Room) SetTransientData(key string, value any) error { + if value == nil { + return r.RemoveTransientData(key) + } + + return r.events.PublishBackendRoomMessage(r.Id(), r.Backend(), &events.AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "transient", + Transient: &talk.BackendRoomTransientRequest{ + Action: talk.TransientActionSet, + Key: key, + Value: value, + }, + }, + }) +} + +func (r *Room) doSetTransientData(key string, value any) { r.transientData.Set(key, value) } -func (r *Room) SetTransientDataTTL(key string, value interface{}, ttl time.Duration) { +func (r *Room) SetTransientDataTTL(key string, value any, ttl time.Duration) error { + if value == nil { + return r.RemoveTransientData(key) + } else if ttl == 0 { + return r.SetTransientData(key, value) + } + + return r.events.PublishBackendRoomMessage(r.Id(), r.Backend(), &events.AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "transient", + Transient: &talk.BackendRoomTransientRequest{ + Action: talk.TransientActionSet, + Key: key, + Value: value, + TTL: ttl, + }, + }, + }) +} + +func (r *Room) doSetTransientDataTTL(key string, value any, ttl time.Duration) { r.transientData.SetTTL(key, value, ttl) } -func (r *Room) RemoveTransientData(key string) { +func (r *Room) RemoveTransientData(key string) error { + return r.events.PublishBackendRoomMessage(r.Id(), r.Backend(), &events.AsyncMessage{ + Type: "room", + Room: &talk.BackendServerRoomRequest{ + Type: "transient", + Transient: &talk.BackendRoomTransientRequest{ + Action: talk.TransientActionDelete, + Key: key, + }, + }, + }) +} + +func (r *Room) doRemoveTransientData(key string) { r.transientData.Remove(key) } + +func (r *Room) fetchInitialTransientData() { + if r.hub.rpcClients == nil { + return + } + + ctx := log.NewLoggerContext(context.Background(), r.logger) + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + var wg sync.WaitGroup + var mu sync.Mutex + // +checklocks:mu + var initial api.TransientDataEntries + for _, client := range r.hub.rpcClients.GetClients() { + wg.Go(func() { + data, err := client.GetTransientData(ctx, r.Id(), r.Backend()) + if err != nil { + r.logger.Printf("Received error while getting transient data for %s@%s from %s: %s", r.Id(), r.Backend().Id(), client.Target(), err) + return + } else if len(data) == 0 { + return + } + + r.logger.Printf("Received initial transient data %+v from %s", data, client.Target()) + mu.Lock() + defer mu.Unlock() + if initial == nil { + initial = make(api.TransientDataEntries) + } + maps.Copy(initial, data) + }) + } + wg.Wait() + + mu.Lock() + defer mu.Unlock() + if len(initial) > 0 { + r.transientData.SetInitial(initial) + } +} diff --git a/room_ping.go b/server/room_ping.go similarity index 61% rename from room_ping.go rename to server/room_ping.go index 5902068..c657364 100644 --- a/room_ping.go +++ b/server/room_ping.go @@ -19,32 +19,36 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" - "log" "net/url" + "slices" "sync" "time" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) type pingEntries struct { url *url.URL - entries map[string][]BackendPingEntry + entries map[string][]talk.BackendPingEntry } -func newPingEntries(url *url.URL, roomId string, entries []BackendPingEntry) *pingEntries { +func newPingEntries(url *url.URL, roomId string, entries []talk.BackendPingEntry) *pingEntries { return &pingEntries{ url: url, - entries: map[string][]BackendPingEntry{ + entries: map[string][]talk.BackendPingEntry{ roomId: entries, }, } } -func (e *pingEntries) Add(roomId string, entries []BackendPingEntry) { +func (e *pingEntries) Add(roomId string, entries []talk.BackendPingEntry) { if existing, found := e.entries[roomId]; found { e.entries[roomId] = append(existing, entries...) } else { @@ -64,19 +68,19 @@ func (e *pingEntries) RemoveRoom(roomId string) { // and sent out batched every "updateActiveSessionsInterval" seconds. type RoomPing struct { mu sync.Mutex - closer *Closer + closer *internal.Closer - backend *BackendClient - capabilities *Capabilities + hub *Hub + backend *talk.BackendClient + // +checklocks:mu entries map[string]*pingEntries } -func NewRoomPing(backend *BackendClient, capabilities *Capabilities) (*RoomPing, error) { +func NewRoomPing(backend *talk.BackendClient) (*RoomPing, error) { result := &RoomPing{ - closer: NewCloser(), - backend: backend, - capabilities: capabilities, + closer: internal.NewCloser(), + backend: backend, } return result, nil @@ -98,7 +102,7 @@ loop: case <-p.closer.C: break loop case <-ticker.C: - p.publishActiveSessions() + p.publishActiveSessions(context.Background()) } } } @@ -112,80 +116,73 @@ func (p *RoomPing) getAndClearEntries() map[string]*pingEntries { return entries } -func (p *RoomPing) publishEntries(entries *pingEntries, timeout time.Duration) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) +func (p *RoomPing) publishEntries(ctx context.Context, entries *pingEntries, timeout time.Duration) { + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - limit, _, found := p.capabilities.GetIntegerConfig(ctx, entries.url, ConfigGroupSignaling, ConfigKeySessionPingLimit) + limit, _, found := p.backend.GetIntegerConfig(ctx, entries.url, talk.ConfigGroupSignaling, talk.ConfigKeySessionPingLimit) if !found || limit <= 0 { // Limit disabled while waiting for the next iteration, fallback to sending // one request per room. + logger := log.LoggerFromContext(ctx) for roomId, e := range entries.entries { - ctx2, cancel2 := context.WithTimeout(context.Background(), timeout) + ctx2, cancel2 := context.WithTimeout(context.WithoutCancel(ctx), timeout) defer cancel2() if err := p.sendPingsDirect(ctx2, roomId, entries.url, e); err != nil { - log.Printf("Error pinging room %s for active entries %+v: %s", roomId, e, err) + logger.Printf("Error pinging room %s for active entries %+v: %s", roomId, e, err) } } return } - var allEntries []BackendPingEntry + var allEntries []talk.BackendPingEntry for _, e := range entries.entries { allEntries = append(allEntries, e...) } - p.sendPingsCombined(entries.url, allEntries, limit, timeout) + p.sendPingsCombined(ctx, entries.url, allEntries, limit, timeout) } -func (p *RoomPing) publishActiveSessions() { +func (p *RoomPing) publishActiveSessions(ctx context.Context) { var timeout time.Duration - if p.backend.hub != nil { - timeout = p.backend.hub.backendTimeout + if p.hub != nil { + timeout = p.hub.backendTimeout } else { // Running from tests. timeout = time.Second * time.Duration(defaultBackendTimeoutSeconds) } entries := p.getAndClearEntries() var wg sync.WaitGroup - wg.Add(len(entries)) for _, e := range entries { - go func(e *pingEntries) { - defer wg.Done() - p.publishEntries(e, timeout) - }(e) + wg.Go(func() { + p.publishEntries(ctx, e, timeout) + }) } wg.Wait() } -func (p *RoomPing) sendPingsDirect(ctx context.Context, roomId string, url *url.URL, entries []BackendPingEntry) error { - request := NewBackendClientPingRequest(roomId, entries) - var response BackendClientResponse +func (p *RoomPing) sendPingsDirect(ctx context.Context, roomId string, url *url.URL, entries []talk.BackendPingEntry) error { + request := talk.NewBackendClientPingRequest(roomId, entries) + var response talk.BackendClientResponse return p.backend.PerformJSONRequest(ctx, url, request, &response) } -func (p *RoomPing) sendPingsCombined(url *url.URL, entries []BackendPingEntry, limit int, timeout time.Duration) { - total := len(entries) - for idx := 0; idx < total; idx += limit { - end := idx + limit - if end > total { - end = total - } - tosend := entries[idx:end] - - ctx, cancel := context.WithTimeout(context.Background(), timeout) +func (p *RoomPing) sendPingsCombined(ctx context.Context, url *url.URL, entries []talk.BackendPingEntry, limit int, timeout time.Duration) { + logger := log.LoggerFromContext(ctx) + for tosend := range slices.Chunk(entries, limit) { + subCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - request := NewBackendClientPingRequest("", tosend) - var response BackendClientResponse - if err := p.backend.PerformJSONRequest(ctx, url, request, &response); err != nil { - log.Printf("Error sending combined ping session entries %+v to %s: %s", tosend, url, err) + request := talk.NewBackendClientPingRequest("", tosend) + var response talk.BackendClientResponse + if err := p.backend.PerformJSONRequest(subCtx, url, request, &response); err != nil { + logger.Printf("Error sending combined ping session entries %+v to %s: %s", tosend, url, err) } } } -func (p *RoomPing) SendPings(ctx context.Context, roomId string, url *url.URL, entries []BackendPingEntry) error { - limit, _, found := p.capabilities.GetIntegerConfig(ctx, url, ConfigGroupSignaling, ConfigKeySessionPingLimit) +func (p *RoomPing) SendPings(ctx context.Context, roomId string, url *url.URL, entries []talk.BackendPingEntry) error { + limit, _, found := p.backend.GetIntegerConfig(ctx, url, talk.ConfigGroupSignaling, talk.ConfigKeySessionPingLimit) if !found || limit <= 0 { // Old-style Nextcloud or session limit not configured. Perform one request // per room. Don't queue to avoid sending all ping requests to old-style diff --git a/room_ping_test.go b/server/room_ping_test.go similarity index 69% rename from room_ping_test.go rename to server/room_ping_test.go index 0bc775d..9572895 100644 --- a/room_ping_test.go +++ b/server/room_ping_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" @@ -30,9 +30,13 @@ import ( "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) -func NewRoomPingForTest(t *testing.T) (*url.URL, *RoomPing) { +func NewRoomPingForTest(ctx context.Context, t *testing.T) (*url.URL, *RoomPing) { require := require.New(t) r := mux.NewRouter() registerBackendHandler(t, r) @@ -45,30 +49,32 @@ func NewRoomPingForTest(t *testing.T) (*url.URL, *RoomPing) { config, err := getTestConfig(server) require.NoError(err) - backend, err := NewBackendClient(config, 1, "0.0", nil) + backend, err := talk.NewBackendClient(ctx, config, 1, "0.0", nil) require.NoError(err) - p, err := NewRoomPing(backend, backend.capabilities) + p, err := NewRoomPing(backend) require.NoError(err) - u, err := url.Parse(server.URL) + u, err := url.Parse(server.URL + "/" + PathToOcsSignalingBackend) require.NoError(err) return u, p } func TestSingleRoomPing(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) - u, ping := NewRoomPingForTest(t) + u, ping := NewRoomPingForTest(ctx, t) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() room1 := &Room{ id: "sample-room-1", } - entries1 := []BackendPingEntry{ + entries1 := []talk.BackendPingEntry{ { UserId: "foo", SessionId: "123", @@ -83,7 +89,7 @@ func TestSingleRoomPing(t *testing.T) { room2 := &Room{ id: "sample-room-2", } - entries2 := []BackendPingEntry{ + entries2 := []talk.BackendPingEntry{ { UserId: "bar", SessionId: "456", @@ -95,22 +101,24 @@ func TestSingleRoomPing(t *testing.T) { } clearPingRequests(t) - ping.publishActiveSessions() + ping.publishActiveSessions(ctx) assert.Empty(getPingRequests(t)) } func TestMultiRoomPing(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) - u, ping := NewRoomPingForTest(t) + u, ping := NewRoomPingForTest(ctx, t) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() room1 := &Room{ id: "sample-room-1", } - entries1 := []BackendPingEntry{ + entries1 := []talk.BackendPingEntry{ { UserId: "foo", SessionId: "123", @@ -122,7 +130,7 @@ func TestMultiRoomPing(t *testing.T) { room2 := &Room{ id: "sample-room-2", } - entries2 := []BackendPingEntry{ + entries2 := []talk.BackendPingEntry{ { UserId: "bar", SessionId: "456", @@ -131,24 +139,26 @@ func TestMultiRoomPing(t *testing.T) { assert.NoError(ping.SendPings(ctx, room2.Id(), u, entries2)) assert.Empty(getPingRequests(t)) - ping.publishActiveSessions() + ping.publishActiveSessions(ctx) if requests := getPingRequests(t); assert.Len(requests, 1) { assert.Len(requests[0].Ping.Entries, 2) } } func TestMultiRoomPing_Separate(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) - u, ping := NewRoomPingForTest(t) + u, ping := NewRoomPingForTest(ctx, t) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() room1 := &Room{ id: "sample-room-1", } - entries1 := []BackendPingEntry{ + entries1 := []talk.BackendPingEntry{ { UserId: "foo", SessionId: "123", @@ -156,7 +166,7 @@ func TestMultiRoomPing_Separate(t *testing.T) { } assert.NoError(ping.SendPings(ctx, room1.Id(), u, entries1)) assert.Empty(getPingRequests(t)) - entries2 := []BackendPingEntry{ + entries2 := []talk.BackendPingEntry{ { UserId: "bar", SessionId: "456", @@ -165,24 +175,26 @@ func TestMultiRoomPing_Separate(t *testing.T) { assert.NoError(ping.SendPings(ctx, room1.Id(), u, entries2)) assert.Empty(getPingRequests(t)) - ping.publishActiveSessions() + ping.publishActiveSessions(ctx) if requests := getPingRequests(t); assert.Len(requests, 1) { assert.Len(requests[0].Ping.Entries, 2) } } func TestMultiRoomPing_DeleteRoom(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) - u, ping := NewRoomPingForTest(t) + u, ping := NewRoomPingForTest(ctx, t) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() room1 := &Room{ id: "sample-room-1", } - entries1 := []BackendPingEntry{ + entries1 := []talk.BackendPingEntry{ { UserId: "foo", SessionId: "123", @@ -194,7 +206,7 @@ func TestMultiRoomPing_DeleteRoom(t *testing.T) { room2 := &Room{ id: "sample-room-2", } - entries2 := []BackendPingEntry{ + entries2 := []talk.BackendPingEntry{ { UserId: "bar", SessionId: "456", @@ -205,7 +217,7 @@ func TestMultiRoomPing_DeleteRoom(t *testing.T) { ping.DeleteRoom(room2.Id()) - ping.publishActiveSessions() + ping.publishActiveSessions(ctx) if requests := getPingRequests(t); assert.Len(requests, 1) { assert.Len(requests[0].Ping.Entries, 1) } diff --git a/server/room_stats_prometheus.go b/server/room_stats_prometheus.go new file mode 100644 index 0000000..098ee25 --- /dev/null +++ b/server/room_stats_prometheus.go @@ -0,0 +1,66 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package server + +import ( + "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" +) + +var ( + statsRoomSessionsCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "room", + Name: "sessions", + Help: "The current number of sessions in a room", + }, []string{"backend", "room", "clienttype"}) + statsCallSessionsCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "call", + Name: "sessions", + Help: "The current number of sessions in a call", + }, []string{"backend", "room", "clienttype"}) + statsCallSessionsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "call", + Name: "sessions_total", + Help: "The total number of sessions in a call", + }, []string{"backend", "clienttype"}) + statsCallRoomsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "call", + Name: "rooms_total", + Help: "The total number of rooms with an active call", + }, []string{"backend"}) + + roomStats = []prometheus.Collector{ + statsRoomSessionsCurrent, + statsCallSessionsCurrent, + statsCallSessionsTotal, + statsCallRoomsTotal, + } +) + +func RegisterRoomStats() { + metrics.RegisterAll(roomStats...) +} diff --git a/server/room_test.go b/server/room_test.go new file mode 100644 index 0000000..3944512 --- /dev/null +++ b/server/room_test.go @@ -0,0 +1,590 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +func TestRoom_InCallFlag(t *testing.T) { + t.Parallel() + type Testcase struct { + Value any + InCall bool + Valid bool + } + tests := []Testcase{ + {nil, false, false}, + {"a", false, false}, + {true, true, true}, + {false, false, true}, + {0, false, true}, + {FlagDisconnected, false, true}, + {1, true, true}, + {FlagInCall, true, true}, + {2, false, true}, + {FlagWithAudio, false, true}, + {3, true, true}, + {FlagInCall | FlagWithAudio, true, true}, + {4, false, true}, + {FlagWithVideo, false, true}, + {5, true, true}, + {FlagInCall | FlagWithVideo, true, true}, + {1.1, true, true}, + {json.Number("1"), true, true}, + {json.Number("1.1"), false, false}, + } + for _, test := range tests { + inCall, ok := IsInCall(test.Value) + if test.Valid { + assert.True(t, ok, "%+v should be valid", test.Value) + } else { + assert.False(t, ok, "%+v should not be valid", test.Value) + } + assert.Equal(t, test.InCall, inCall, "conversion failed for %+v", test.Value) + } +} + +func TestRoom_Update(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + require := require.New(t) + assert := assert.New(t) + hub, _, router, server := CreateHubForTest(t) + + config, err := getTestConfig(server) + require.NoError(err) + b, err := NewBackendServer(ctx, config, hub, "no-version") + require.NoError(err) + require.NoError(b.Start(router)) + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + // We will receive a "joined" event. + assert.True(client.RunUntilJoined(ctx, hello.Hello)) + + // Simulate backend request from Nextcloud to update the room. + roomProperties := json.RawMessage("{\"foo\":\"bar\"}") + msg := &talk.BackendServerRoomRequest{ + Type: "update", + Update: &talk.BackendRoomUpdateRequest{ + UserIds: []string{ + testDefaultUserId, + }, + Properties: roomProperties, + }, + } + + data, err := json.Marshal(msg) + require.NoError(err) + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + require.NoError(err) + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + assert.NoError(err) + assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) + + // The client receives a roomlist update and a changed room event. The + // ordering is not defined because messages are sent by asynchronous event + // handlers. + message1, _ := client.RunUntilMessage(ctx) + message2, _ := client.RunUntilMessage(ctx) + + if message1.Type != "event" { + checkMessageRoomId(t, message1, roomId) + if msg, ok := checkMessageRoomlistUpdate(t, message2); ok { + assert.Equal(roomId, msg.RoomId) + assert.Equal(string(roomProperties), string(msg.Properties)) + } + } else if msg, ok := checkMessageRoomlistUpdate(t, message1); ok { + assert.Equal(roomId, msg.RoomId) + assert.Equal(string(roomProperties), string(msg.Properties)) + checkMessageRoomId(t, message2, roomId) + } + + // Allow up to 100 milliseconds for asynchronous event processing. + ctx2, cancel2 := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel2() + +loop: + for { + select { + case <-ctx2.Done(): + break loop + default: + // The internal room has been updated with the new properties. + if room := hub.getRoom(roomId); room == nil { + err = fmt.Errorf("Room %s not found in hub", roomId) + } else if len(room.Properties()) == 0 || !bytes.Equal(room.Properties(), roomProperties) { + err = fmt.Errorf("Expected room properties %s, got %+v", string(roomProperties), room.Properties()) + } else { + err = nil + } + } + if err == nil { + break + } + + time.Sleep(time.Millisecond) + } + + assert.NoError(err) +} + +func TestRoom_Delete(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + require := require.New(t) + assert := assert.New(t) + hub, _, router, server := CreateHubForTest(t) + + config, err := getTestConfig(server) + require.NoError(err) + b, err := NewBackendServer(ctx, config, hub, "no-version") + require.NoError(err) + require.NoError(b.Start(router)) + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + // We will receive a "joined" event. + assert.True(client.RunUntilJoined(ctx, hello.Hello)) + + // Simulate backend request from Nextcloud to update the room. + msg := &talk.BackendServerRoomRequest{ + Type: "delete", + Delete: &talk.BackendRoomDeleteRequest{ + UserIds: []string{ + testDefaultUserId, + }, + }, + } + + data, err := json.Marshal(msg) + require.NoError(err) + res, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data) + require.NoError(err) + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + assert.NoError(err) + assert.Equal(http.StatusOK, res.StatusCode, "Expected successful request, got %s", string(body)) + + // The client is no longer invited to the room and leaves it. The ordering + // of messages is not defined as they get published through events and handled + // by asynchronous channels. + if message1, ok := client.RunUntilMessage(ctx); ok && message1.Type != "event" { + // Ordering should be "leave room", "disinvited". + checkMessageRoomId(t, message1, "") + if message2, ok := client.RunUntilMessage(ctx); ok { + checkMessageRoomlistDisinvite(t, message2) + } + } else { + // Ordering should be "disinvited", "leave room". + checkMessageRoomlistDisinvite(t, message1) + // The connection should get closed after the "disinvited". + // However due to the asynchronous processing, the "leave room" message might be received before. + if message2, ok := client.RunUntilMessageOrClosed(ctx); ok && message2 != nil { + checkMessageRoomId(t, message2, "") + client.RunUntilClosed(ctx) + } + } + + // Allow up to 100 milliseconds for asynchronous event processing. + ctx2, cancel2 := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel2() + +loop: + for { + select { + case <-ctx2.Done(): + err = ctx2.Err() + break loop + default: + // The internal room has been updated with the new properties. + hub.ru.Lock() + _, found := hub.rooms[roomId] + hub.ru.Unlock() + + if found { + err = fmt.Errorf("Room %s still found in hub", roomId) + } else { + err = nil + } + } + if err == nil { + break + } + + time.Sleep(time.Millisecond) + } + + assert.NoError(err) +} + +func TestRoom_RoomJoinFeatures(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + require := require.New(t) + assert := assert.New(t) + hub, _, router, server := CreateHubForTest(t) + + config, err := getTestConfig(server) + require.NoError(err) + b, err := NewBackendServer(ctx, config, hub, "no-version") + require.NoError(err) + require.NoError(b.Start(router)) + + client := NewTestClient(t, server, hub) + defer client.CloseWithBye() + + features := []string{"one", "two", "three"} + require.NoError(client.SendHelloClientWithFeatures(testDefaultUserId, features)) + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + hello := MustSucceed1(t, client.RunUntilHello, ctx) + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + if message, ok := client.RunUntilMessage(ctx); ok { + if client.checkMessageJoinedSession(message, hello.Hello.SessionId, testDefaultUserId) { + assert.EqualValues(fmt.Sprintf("%s-%s", roomId, hello.Hello.SessionId), message.Event.Join[0].RoomSessionId) + assert.Equal(features, message.Event.Join[0].Features) + } + } +} + +func TestRoom_RoomSessionData(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + require := require.New(t) + assert := assert.New(t) + hub, _, router, server := CreateHubForTest(t) + + config, err := getTestConfig(server) + require.NoError(err) + b, err := NewBackendServer(ctx, config, hub, "no-version") + require.NoError(err) + require.NoError(b.Start(router)) + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + client, hello := NewTestClientWithHello(ctx, t, server, hub, authAnonymousUserId) + + // Join room by id. + roomId := "test-room-with-sessiondata" + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + // We will receive a "joined" event with the userid from the room session data. + expected := "userid-from-sessiondata" + if message, ok := client.RunUntilMessage(ctx); ok { + if client.checkMessageJoinedSession(message, hello.Hello.SessionId, expected) { + assert.EqualValues(fmt.Sprintf("%s-%s", roomId, hello.Hello.SessionId), message.Event.Join[0].RoomSessionId) + } + } + + session := hub.GetSessionByPublicId(hello.Hello.SessionId) + require.NotNil(session, "Could not find session %s", hello.Hello.SessionId) + assert.Equal(expected, session.UserId()) + + room := hub.getRoom(roomId) + assert.NotNil(room, "Room not found") + + entries, wg := room.publishActiveSessions() + assert.Equal(1, entries) + wg.Wait() +} + +func TestRoom_InCall(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + require := require.New(t) + assert := assert.New(t) + hub, _, router, server := CreateHubForTest(t) + + config, err := getTestConfig(server) + require.NoError(err) + b, err := NewBackendServer(ctx, config, hub, "no-version") + require.NoError(err) + require.NoError(b.Start(router)) + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + client1.RunUntilJoined(ctx, hello1.Hello) + + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) + + client1.RunUntilJoined(ctx, hello2.Hello) + + msg1 := &talk.BackendServerRoomRequest{ + Type: "incall", + InCall: &talk.BackendRoomInCallRequest{ + InCall: json.RawMessage(strconv.FormatInt(FlagInCall, 10)), + Changed: []api.StringMap{ + { + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), + "inCall": json.RawMessage(strconv.FormatInt(FlagInCall, 10)), + }, + }, + Users: []api.StringMap{ + { + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), + "inCall": json.RawMessage(strconv.FormatInt(FlagInCall, 10)), + }, + { + "sessionId": fmt.Sprintf("%s-%s", roomId, hello2.Hello.SessionId), + "inCall": json.RawMessage(strconv.FormatInt(0, 10)), + }, + }, + }, + } + + data1, err := json.Marshal(msg1) + require.NoError(err) + res1, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data1) + require.NoError(err) + defer res1.Body.Close() + body1, err := io.ReadAll(res1.Body) + assert.NoError(err) + assert.Equal(http.StatusOK, res1.StatusCode, "Expected successful request, got %s", string(body1)) + + if msg, ok := client1.RunUntilMessage(ctx); ok { + if message, ok := checkMessageParticipantsInCall(t, msg); ok { + assert.Equal(roomId, message.RoomId) + if assert.Len(message.Users, 2) { + assert.EqualValues(hello1.Hello.SessionId, message.Users[0]["sessionId"]) + assert.EqualValues(FlagInCall, message.Users[0]["inCall"]) + assert.EqualValues(hello2.Hello.SessionId, message.Users[1]["sessionId"]) + assert.EqualValues(0, message.Users[1]["inCall"]) + } + } + } + + if msg, ok := client2.RunUntilMessage(ctx); ok { + if message, ok := checkMessageParticipantsInCall(t, msg); ok { + assert.Equal(roomId, message.RoomId) + if assert.Len(message.Users, 2) { + assert.EqualValues(hello1.Hello.SessionId, message.Users[0]["sessionId"]) + assert.EqualValues(FlagInCall, message.Users[0]["inCall"]) + assert.EqualValues(hello2.Hello.SessionId, message.Users[1]["sessionId"]) + assert.EqualValues(0, message.Users[1]["inCall"]) + } + } + } + + msg2 := &talk.BackendServerRoomRequest{ + Type: "incall", + InCall: &talk.BackendRoomInCallRequest{ + InCall: json.RawMessage(strconv.FormatInt(0, 10)), + Changed: []api.StringMap{ + { + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), + "inCall": json.RawMessage(strconv.FormatInt(0, 10)), + }, + }, + Users: []api.StringMap{ + { + "sessionId": fmt.Sprintf("%s-%s", roomId, hello1.Hello.SessionId), + "inCall": json.RawMessage(strconv.FormatInt(0, 10)), + }, + { + "sessionId": fmt.Sprintf("%s-%s", roomId, hello2.Hello.SessionId), + "inCall": json.RawMessage(strconv.FormatInt(0, 10)), + }, + }, + }, + } + + data2, err := json.Marshal(msg2) + require.NoError(err) + res2, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data2) + require.NoError(err) + defer res2.Body.Close() + body2, err := io.ReadAll(res2.Body) + assert.NoError(err) + assert.Equal(http.StatusOK, res2.StatusCode, "Expected successful request, got %s", string(body2)) + + if msg, ok := client1.RunUntilMessage(ctx); ok { + if message, ok := checkMessageParticipantsInCall(t, msg); ok { + assert.Equal(roomId, message.RoomId) + if assert.Len(message.Users, 2) { + assert.EqualValues(hello1.Hello.SessionId, message.Users[0]["sessionId"]) + assert.EqualValues(0, message.Users[0]["inCall"]) + assert.EqualValues(hello2.Hello.SessionId, message.Users[1]["sessionId"]) + assert.EqualValues(0, message.Users[1]["inCall"]) + } + } + } + + if msg, ok := client2.RunUntilMessage(ctx); ok { + if message, ok := checkMessageParticipantsInCall(t, msg); ok { + assert.Equal(roomId, message.RoomId) + if assert.Len(message.Users, 2) { + assert.EqualValues(hello1.Hello.SessionId, message.Users[0]["sessionId"]) + assert.EqualValues(0, message.Users[0]["inCall"]) + assert.EqualValues(hello2.Hello.SessionId, message.Users[1]["sessionId"]) + assert.EqualValues(0, message.Users[1]["inCall"]) + } + } + } +} + +func TestRoom_InCallAll(t *testing.T) { + t.Parallel() + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + require := require.New(t) + assert := assert.New(t) + hub, _, router, server := CreateHubForTest(t) + + config, err := getTestConfig(server) + require.NoError(err) + b, err := NewBackendServer(ctx, config, hub, "no-version") + require.NoError(err) + require.NoError(b.Start(router)) + + ctx, cancel := context.WithTimeout(ctx, testTimeout) + defer cancel() + + client1, hello1 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"1") + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") + + // Join room by id. + roomId := "test-room" + roomMsg := MustSucceed2(t, client1.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + client1.RunUntilJoined(ctx, hello1.Hello) + + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) + + client1.RunUntilJoined(ctx, hello2.Hello) + + // Simulate backend request from Nextcloud to update the "inCall" flag of all participants. + msg1 := &talk.BackendServerRoomRequest{ + Type: "incall", + InCall: &talk.BackendRoomInCallRequest{ + All: true, + InCall: json.RawMessage(strconv.FormatInt(FlagInCall, 10)), + }, + } + + data1, err := json.Marshal(msg1) + require.NoError(err) + res1, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data1) + require.NoError(err) + defer res1.Body.Close() + body1, err := io.ReadAll(res1.Body) + assert.NoError(err) + assert.Equal(http.StatusOK, res1.StatusCode, "Expected successful request, got %s", string(body1)) + + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageInCallAll(t, msg, roomId, FlagInCall) + } + + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageInCallAll(t, msg, roomId, FlagInCall) + } + + // Simulate backend request from Nextcloud to update the "inCall" flag of all participants. + msg2 := &talk.BackendServerRoomRequest{ + Type: "incall", + InCall: &talk.BackendRoomInCallRequest{ + All: true, + InCall: json.RawMessage(strconv.FormatInt(0, 10)), + }, + } + + data2, err := json.Marshal(msg2) + require.NoError(err) + res2, err := performBackendRequest(server.URL+"/api/v1/room/"+roomId, data2) + require.NoError(err) + defer res2.Body.Close() + body2, err := io.ReadAll(res2.Body) + assert.NoError(err) + assert.Equal(http.StatusOK, res2.StatusCode, "Expected successful request, got %s", string(body2)) + + if msg, ok := client1.RunUntilMessage(ctx); ok { + checkMessageInCallAll(t, msg, roomId, 0) + } + + if msg, ok := client2.RunUntilMessage(ctx); ok { + checkMessageInCallAll(t, msg, roomId, 0) + } +} diff --git a/roomsessions.go b/server/roomsessions.go similarity index 69% rename from roomsessions.go rename to server/roomsessions.go index b984463..9692655 100644 --- a/roomsessions.go +++ b/server/roomsessions.go @@ -19,21 +19,23 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" - "fmt" + "errors" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" ) var ( - ErrNoSuchRoomSession = fmt.Errorf("unknown room session id") + ErrNoSuchRoomSession = errors.New("unknown room session id") ) type RoomSessions interface { - SetRoomSession(session Session, roomSessionId string) error + SetRoomSession(session Session, roomSessionId api.RoomSessionId) error DeleteRoomSession(session Session) - GetSessionId(roomSessionId string) (string, error) - LookupSessionId(ctx context.Context, roomSessionId string, disconnectReason string) (string, error) + GetSessionId(roomSessionId api.RoomSessionId) (api.PublicSessionId, error) + LookupSessionId(ctx context.Context, roomSessionId api.RoomSessionId, disconnectReason string) (api.PublicSessionId, error) } diff --git a/roomsessions_builtin.go b/server/roomsessions_builtin.go similarity index 70% rename from roomsessions_builtin.go rename to server/roomsessions_builtin.go index 926fe9f..fd1adcd 100644 --- a/roomsessions_builtin.go +++ b/server/roomsessions_builtin.go @@ -19,34 +19,39 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" "errors" - "log" "sync" "sync/atomic" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) type BuiltinRoomSessions struct { - sessionIdToRoomSession map[string]string - roomSessionToSessionid map[string]string - mu sync.RWMutex + mu sync.RWMutex + // +checklocks:mu + sessionIdToRoomSession map[api.PublicSessionId]api.RoomSessionId + // +checklocks:mu + roomSessionToSessionid map[api.RoomSessionId]api.PublicSessionId - clients *GrpcClients + clients *grpc.Clients } -func NewBuiltinRoomSessions(clients *GrpcClients) (RoomSessions, error) { +func NewBuiltinRoomSessions(clients *grpc.Clients) (RoomSessions, error) { return &BuiltinRoomSessions{ - sessionIdToRoomSession: make(map[string]string), - roomSessionToSessionid: make(map[string]string), + sessionIdToRoomSession: make(map[api.PublicSessionId]api.RoomSessionId), + roomSessionToSessionid: make(map[api.RoomSessionId]api.PublicSessionId), clients: clients, }, nil } -func (r *BuiltinRoomSessions) SetRoomSession(session Session, roomSessionId string) error { +func (r *BuiltinRoomSessions) SetRoomSession(session Session, roomSessionId api.RoomSessionId) error { if roomSessionId == "" { r.DeleteRoomSession(session) return nil @@ -84,7 +89,7 @@ func (r *BuiltinRoomSessions) DeleteRoomSession(session Session) { } } -func (r *BuiltinRoomSessions) GetSessionId(roomSessionId string) (string, error) { +func (r *BuiltinRoomSessions) GetSessionId(roomSessionId api.RoomSessionId) (api.PublicSessionId, error) { r.mu.RLock() defer r.mu.RUnlock() sid, found := r.roomSessionToSessionid[roomSessionId] @@ -95,7 +100,7 @@ func (r *BuiltinRoomSessions) GetSessionId(roomSessionId string) (string, error) return sid, nil } -func (r *BuiltinRoomSessions) LookupSessionId(ctx context.Context, roomSessionId string, disconnectReason string) (string, error) { +func (r *BuiltinRoomSessions) LookupSessionId(ctx context.Context, roomSessionId api.RoomSessionId, disconnectReason string) (api.PublicSessionId, error) { sid, err := r.GetSessionId(roomSessionId) if err == nil { return sid, nil @@ -115,25 +120,23 @@ func (r *BuiltinRoomSessions) LookupSessionId(ctx context.Context, roomSessionId var wg sync.WaitGroup var result atomic.Value + logger := log.LoggerFromContext(ctx) for _, client := range clients { - wg.Add(1) - go func(client *GrpcClient) { - defer wg.Done() - + wg.Go(func() { sid, err := client.LookupSessionId(lookupctx, roomSessionId, disconnectReason) if errors.Is(err, context.Canceled) { return } else if err != nil { - log.Printf("Received error while checking for room session id %s on %s: %s", roomSessionId, client.Target(), err) + logger.Printf("Received error while checking for room session id %s on %s: %s", roomSessionId, client.Target(), err) return } else if sid == "" { - log.Printf("Received empty session id for room session id %s from %s", roomSessionId, client.Target()) + logger.Printf("Received empty session id for room session id %s from %s", roomSessionId, client.Target()) return } cancel() // Cancel pending RPC calls. result.Store(sid) - }(client) + }) } wg.Wait() @@ -142,5 +145,5 @@ func (r *BuiltinRoomSessions) LookupSessionId(ctx context.Context, roomSessionId return "", ErrNoSuchRoomSession } - return value.(string), nil + return value.(api.PublicSessionId), nil } diff --git a/roomsessions_builtin_test.go b/server/roomsessions_builtin_test.go similarity index 97% rename from roomsessions_builtin_test.go rename to server/roomsessions_builtin_test.go index c69e346..1d84bf6 100644 --- a/roomsessions_builtin_test.go +++ b/server/roomsessions_builtin_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "testing" @@ -28,6 +28,7 @@ import ( ) func TestBuiltinRoomSessions(t *testing.T) { + t.Parallel() sessions, err := NewBuiltinRoomSessions(nil) require.NoError(t, err) diff --git a/roomsessions_test.go b/server/roomsessions_test.go similarity index 72% rename from roomsessions_test.go rename to server/roomsessions_test.go index 6501819..d8ee8b0 100644 --- a/roomsessions_test.go +++ b/server/roomsessions_test.go @@ -19,39 +19,44 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" "encoding/json" "net/url" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/session" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) type DummySession struct { - publicId string + publicId api.PublicSessionId } func (s *DummySession) Context() context.Context { return context.Background() } -func (s *DummySession) PrivateId() string { +func (s *DummySession) PrivateId() api.PrivateSessionId { return "" } -func (s *DummySession) PublicId() string { +func (s *DummySession) PublicId() api.PublicSessionId { return s.publicId } -func (s *DummySession) ClientType() string { +func (s *DummySession) ClientType() api.ClientType { return "" } -func (s *DummySession) Data() *SessionIdData { +func (s *DummySession) Data() *session.SessionIdData { return nil } @@ -63,11 +68,11 @@ func (s *DummySession) UserData() json.RawMessage { return nil } -func (s *DummySession) ParsedUserData() (map[string]interface{}, error) { +func (s *DummySession) ParsedUserData() (api.StringMap, error) { return nil, nil } -func (s *DummySession) Backend() *Backend { +func (s *DummySession) Backend() *talk.Backend { return nil } @@ -79,13 +84,17 @@ func (s *DummySession) ParsedBackendUrl() *url.URL { return nil } -func (s *DummySession) SetRoom(room *Room) { +func (s *DummySession) SetRoom(room *Room, joinTime time.Time) { } func (s *DummySession) GetRoom() *Room { return nil } +func (s *DummySession) IsInRoom(id string) bool { + return false +} + func (s *DummySession) LeaveRoom(notify bool) *Room { return nil } @@ -93,19 +102,19 @@ func (s *DummySession) LeaveRoom(notify bool) *Room { func (s *DummySession) Close() { } -func (s *DummySession) HasPermission(permission Permission) bool { +func (s *DummySession) HasPermission(permission api.Permission) bool { return false } -func (s *DummySession) SendError(e *Error) bool { +func (s *DummySession) SendError(e *api.Error) bool { return false } -func (s *DummySession) SendMessage(message *ServerMessage) bool { +func (s *DummySession) SendMessage(message *api.ServerMessage) bool { return false } -func checkSession(t *testing.T, sessions RoomSessions, sessionId string, roomSessionId string) Session { +func checkSession(t *testing.T, sessions RoomSessions, sessionId api.PublicSessionId, roomSessionId api.RoomSessionId) Session { session := &DummySession{ publicId: sessionId, } @@ -119,7 +128,7 @@ func checkSession(t *testing.T, sessions RoomSessions, sessionId string, roomSes func testRoomSessions(t *testing.T, sessions RoomSessions) { assert := assert.New(t) if sid, err := sessions.GetSessionId("unknown"); err == nil { - assert.Fail("Expected error about invalid room session, got session id %s", sid) + assert.Fail("Expected error about invalid room session", "got session id %s", sid) } else { assert.ErrorIs(err, ErrNoSuchRoomSession) } @@ -133,7 +142,7 @@ func testRoomSessions(t *testing.T, sessions RoomSessions) { sessions.DeleteRoomSession(s1) if sid, err := sessions.GetSessionId("room1"); err == nil { - assert.Fail("Expected error about invalid room session, got session id %s", sid) + assert.Fail("Expected error about invalid room session", "got session id %s", sid) } else { assert.ErrorIs(err, ErrNoSuchRoomSession) } @@ -150,7 +159,7 @@ func testRoomSessions(t *testing.T, sessions RoomSessions) { assert.NoError(sessions.SetRoomSession(s2, "room-session2")) if sid, err := sessions.GetSessionId("room-session"); err == nil { - assert.Fail("Expected error about invalid room session, got session id %s", sid) + assert.Fail("Expected error about invalid room session", "got session id %s", sid) } else { assert.ErrorIs(err, ErrNoSuchRoomSession) } diff --git a/session.go b/server/session.go similarity index 51% rename from session.go rename to server/session.go index d08b8ec..0a76ed3 100644 --- a/session.go +++ b/server/session.go @@ -19,68 +19,59 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" "encoding/json" "net/url" "sync" -) + "time" -type Permission string - -var ( - PERMISSION_MAY_PUBLISH_MEDIA Permission = "publish-media" - PERMISSION_MAY_PUBLISH_AUDIO Permission = "publish-audio" - PERMISSION_MAY_PUBLISH_VIDEO Permission = "publish-video" - PERMISSION_MAY_PUBLISH_SCREEN Permission = "publish-screen" - PERMISSION_MAY_CONTROL Permission = "control" - PERMISSION_TRANSIENT_DATA Permission = "transient-data" - PERMISSION_HIDE_DISPLAYNAMES Permission = "hide-displaynames" - - // DefaultPermissionOverrides contains permission overrides for users where - // no permissions have been set by the server. If a permission is not set in - // this map, it's assumed the user has that permission. - DefaultPermissionOverrides = map[Permission]bool{ - PERMISSION_HIDE_DISPLAYNAMES: false, - } + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/session" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) type Session interface { Context() context.Context - PrivateId() string - PublicId() string - ClientType() string - Data() *SessionIdData + PrivateId() api.PrivateSessionId + PublicId() api.PublicSessionId + ClientType() api.ClientType + Data() *session.SessionIdData UserId() string UserData() json.RawMessage - ParsedUserData() (map[string]interface{}, error) + ParsedUserData() (api.StringMap, error) - Backend() *Backend + Backend() *talk.Backend BackendUrl() string ParsedBackendUrl() *url.URL - SetRoom(room *Room) + SetRoom(room *Room, joinTime time.Time) GetRoom() *Room LeaveRoom(notify bool) *Room + IsInRoom(id string) bool Close() - HasPermission(permission Permission) bool + HasPermission(permission api.Permission) bool - SendError(e *Error) bool - SendMessage(message *ServerMessage) bool + SendError(e *api.Error) bool + SendMessage(message *api.ServerMessage) bool } -func parseUserData(data json.RawMessage) func() (map[string]interface{}, error) { - return sync.OnceValues(func() (map[string]interface{}, error) { +type SessionWithInCall interface { + GetInCall() int +} + +func parseUserData(data json.RawMessage) func() (api.StringMap, error) { + return sync.OnceValues(func() (api.StringMap, error) { if len(data) == 0 { return nil, nil } - var m map[string]interface{} + var m api.StringMap if err := json.Unmarshal(data, &m); err != nil { return nil, err } diff --git a/session_test.go b/server/session_test.go similarity index 73% rename from session_test.go rename to server/session_test.go index b157c80..5cc677e 100644 --- a/session_test.go +++ b/server/session_test.go @@ -19,20 +19,28 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" ) -func assertSessionHasPermission(t *testing.T, session Session, permission Permission) { +func assertSessionHasPermission(t *testing.T, session Session, permission api.Permission) { t.Helper() assert.True(t, session.HasPermission(permission), "Session %s doesn't have permission %s", session.PublicId(), permission) + if cs, ok := session.(*ClientSession); ok { + assert.True(t, cs.HasAnyPermission(permission), "Session %s doesn't have permission %s", session.PublicId(), permission) + } } -func assertSessionHasNotPermission(t *testing.T, session Session, permission Permission) { +func assertSessionHasNotPermission(t *testing.T, session Session, permission api.Permission) { t.Helper() assert.False(t, session.HasPermission(permission), "Session %s has permission %s but shouldn't", session.PublicId(), permission) + if cs, ok := session.(*ClientSession); ok { + assert.False(t, cs.HasAnyPermission(permission), "Session %s has permission %s but shouldn't", session.PublicId(), permission) + } } diff --git a/stats_prometheus.go b/server/stats_prometheus.go similarity index 76% rename from stats_prometheus.go rename to server/stats_prometheus.go index 93af6af..9b6b973 100644 --- a/stats_prometheus.go +++ b/server/stats_prometheus.go @@ -19,10 +19,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" ) var ( @@ -38,22 +40,6 @@ var ( } ) -func registerAll(cs ...prometheus.Collector) { - for _, c := range cs { - if err := prometheus.DefaultRegisterer.Register(c); err != nil { - if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { - panic(err) - } - } - } -} - -func unregisterAll(cs ...prometheus.Collector) { - for _, c := range cs { - prometheus.Unregister(c) - } -} - func RegisterStats() { - registerAll(signalingStats...) + metrics.RegisterAll(signalingStats...) } diff --git a/server/testclient_test.go b/server/testclient_test.go new file mode 100644 index 0000000..62d55cd --- /dev/null +++ b/server/testclient_test.go @@ -0,0 +1,1157 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package server + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/session" +) + +var ( + testBackendSecret = []byte("secret") + testInternalSecret = []byte("internal-secret") + + ErrNoMessageReceived = errors.New("no message was received by the server") + + testClientDialer = websocket.Dialer{ + WriteBufferPool: &sync.Pool{}, + } +) + +type TestBackendClientAuthParams struct { + UserId string `json:"userid"` +} + +func getWebsocketUrl(url string) string { + if url, found := strings.CutPrefix(url, "http://"); found { + return "ws://" + url + "/spreed" + } else if url, found := strings.CutPrefix(url, "https://"); found { + return "wss://" + url + "/spreed" + } else { + panic("Unsupported URL: " + url) + } +} + +func getPubliceSessionIdData(h *Hub, publicId api.PublicSessionId) *session.SessionIdData { + decodedPublic := h.decodePublicSessionId(publicId) + if decodedPublic == nil { + panic("invalid public session id") + } + return decodedPublic +} + +func checkMessageType(t *testing.T, message *api.ServerMessage, expectedType string) bool { + assert := assert.New(t) + if !assert.NotNil(message, "no message received") { + return false + } + + failed := !assert.Equal(expectedType, message.Type, "invalid message type in %+v", message) + + switch message.Type { + case "hello": + if !assert.NotNil(message.Hello, "hello missing in %+v", message) { + failed = true + } + case "message": + if assert.NotNil(message.Message, "message missing in %+v", message) { + if !assert.NotEmpty(message.Message.Data, "message %+v has no data", message) { + failed = true + } + } else { + failed = true + } + case "room": + if !assert.NotNil(message.Room, "room missing in %+v", message) { + failed = true + } + case "event": + if !assert.NotNil(message.Event, "event missing in %+v", message) { + failed = true + } + case "transient": + if !assert.NotNil(message.TransientData, "transient data missing in %+v", message) { + failed = true + } + } + return !failed +} + +func checkMessageSender(t *testing.T, hub *Hub, sender *api.MessageServerMessageSender, senderType string, hello *api.HelloServerMessage) bool { + assert := assert.New(t) + return assert.Equal(senderType, sender.Type, "invalid sender type in %+v", sender) && + assert.Equal(hello.SessionId, sender.SessionId, "invalid session id, expectd %+v, got %+v in %+v", + getPubliceSessionIdData(hub, hello.SessionId), + getPubliceSessionIdData(hub, sender.SessionId), + sender, + ) && + assert.Equal(hello.UserId, sender.UserId, "invalid userid in %+v", sender) +} + +func checkReceiveClientMessageWithSenderAndRecipient(ctx context.Context, t *testing.T, client *TestClient, senderType string, hello *api.HelloServerMessage, payload any, sender **api.MessageServerMessageSender, recipient **api.MessageClientMessageRecipient) bool { + assert := assert.New(t) + message, ok := client.RunUntilMessage(ctx) + if !ok || + !checkMessageType(t, message, "message") || + !checkMessageSender(t, client.hub, message.Message.Sender, senderType, hello) || + !assert.NoError(json.Unmarshal(message.Message.Data, payload)) { + return false + } + + if sender != nil { + *sender = message.Message.Sender + } + if recipient != nil { + *recipient = message.Message.Recipient + } + return true +} + +func checkReceiveClientMessageWithSender(ctx context.Context, t *testing.T, client *TestClient, senderType string, hello *api.HelloServerMessage, payload any, sender **api.MessageServerMessageSender) bool { + return checkReceiveClientMessageWithSenderAndRecipient(ctx, t, client, senderType, hello, payload, sender, nil) +} + +func checkReceiveClientMessage(ctx context.Context, t *testing.T, client *TestClient, senderType string, hello *api.HelloServerMessage, payload any) bool { + return checkReceiveClientMessageWithSenderAndRecipient(ctx, t, client, senderType, hello, payload, nil, nil) +} + +func checkReceiveClientControlWithSenderAndRecipient(ctx context.Context, t *testing.T, client *TestClient, senderType string, hello *api.HelloServerMessage, payload any, sender **api.MessageServerMessageSender, recipient **api.MessageClientMessageRecipient) bool { + assert := assert.New(t) + message, ok := client.RunUntilMessage(ctx) + if !ok || + !checkMessageType(t, message, "control") || + !checkMessageSender(t, client.hub, message.Control.Sender, senderType, hello) || + !assert.NoError(json.Unmarshal(message.Control.Data, payload)) { + return false + } + + if sender != nil { + *sender = message.Control.Sender + } + if recipient != nil { + *recipient = message.Control.Recipient + } + return true +} + +func checkReceiveClientControlWithSender(ctx context.Context, t *testing.T, client *TestClient, senderType string, hello *api.HelloServerMessage, payload any, sender **api.MessageServerMessageSender) bool { // nolint + return checkReceiveClientControlWithSenderAndRecipient(ctx, t, client, senderType, hello, payload, sender, nil) +} + +func checkReceiveClientControl(ctx context.Context, t *testing.T, client *TestClient, senderType string, hello *api.HelloServerMessage, payload any) bool { + return checkReceiveClientControlWithSenderAndRecipient(ctx, t, client, senderType, hello, payload, nil, nil) +} + +func checkReceiveClientEvent(ctx context.Context, t *testing.T, client *TestClient, eventType string, msg **api.EventServerMessage) bool { + assert := assert.New(t) + message, ok := client.RunUntilMessage(ctx) + if !ok || + !checkMessageType(t, message, "event") || + !assert.Equal(eventType, message.Event.Type, "invalid event type in %+v", message) { + return false + } + + if msg != nil { + *msg = message.Event + } + return true +} + +type TestClient struct { + t *testing.T + assert *assert.Assertions + require *require.Assertions + hub *Hub + server *httptest.Server + + mu sync.Mutex + // +checklocks:mu + conn *websocket.Conn + localAddr net.Addr + + messageChan chan []byte + readErrorChan chan error + + publicId api.PublicSessionId +} + +func NewTestClientContext(ctx context.Context, t *testing.T, server *httptest.Server, hub *Hub) *TestClient { + // Reference "hub" to prevent compiler error. + conn, _, err := testClientDialer.DialContext(ctx, getWebsocketUrl(server.URL), nil) + require.NoError(t, err) + + messageChan := make(chan []byte) + readErrorChan := make(chan error, 1) + + closing := make(chan struct{}) + closed := make(chan struct{}) + go func() { + defer close(closed) + for { + messageType, data, err := conn.ReadMessage() + if err != nil { + readErrorChan <- err + return + } else if !assert.Equal(t, websocket.TextMessage, messageType) { + return + } + + select { + case messageChan <- data: + case <-closing: + return + } + } + }() + t.Cleanup(func() { + close(closing) + <-closed + }) + + return &TestClient{ + t: t, + assert: assert.New(t), + require: require.New(t), + hub: hub, + server: server, + + conn: conn, + localAddr: conn.LocalAddr(), + + messageChan: messageChan, + readErrorChan: readErrorChan, + } +} + +func NewTestClient(t *testing.T, server *httptest.Server, hub *Hub) *TestClient { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + client := NewTestClientContext(ctx, t, server, hub) + if msg, ok := client.RunUntilMessage(ctx); ok { + assert.Equal(t, "welcome", msg.Type, "invalid initial message type in %+v", msg) + } + return client +} + +func NewTestClientWithHello(ctx context.Context, t *testing.T, server *httptest.Server, hub *Hub, userId string) (*TestClient, *api.ServerMessage) { + client := NewTestClient(t, server, hub) + t.Cleanup(func() { + client.CloseWithBye() + }) + + require.NoError(t, client.SendHello(userId)) + hello := MustSucceed1(t, client.RunUntilHello, ctx) + return client, hello +} + +func (c *TestClient) CloseWithBye() { + c.SendBye() // nolint + c.Close() +} + +func (c *TestClient) Close() { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.conn.WriteMessage(websocket.CloseMessage, []byte{}); err == websocket.ErrCloseSent { + // Already closed + return + } + + // Wait a bit for close message to be processed. + time.Sleep(100 * time.Millisecond) + c.conn.Close() + + // Drain any entries in the channels to terminate the read goroutine. +loop: + for { + select { + case <-c.readErrorChan: + case <-c.messageChan: + default: + break loop + } + } +} + +func (c *TestClient) WaitForClientRemoved(ctx context.Context) error { + c.hub.mu.Lock() + defer c.hub.mu.Unlock() + for { + found := false + for _, client := range c.hub.clients { + if cc, ok := client.(*HubClient); ok { + conn := cc.GetConn() + if conn != nil && conn.RemoteAddr().String() == c.localAddr.String() { + found = true + break + } + } + } + if !found { + break + } + + c.hub.mu.Unlock() + select { + case <-ctx.Done(): + c.hub.mu.Lock() + return ctx.Err() + default: + time.Sleep(time.Millisecond) + } + c.hub.mu.Lock() + } + return nil +} + +func (c *TestClient) WaitForSessionRemoved(ctx context.Context, sessionId api.PublicSessionId) error { + data := c.hub.decodePublicSessionId(sessionId) + if data == nil { + return errors.New("Invalid session id passed") + } + + c.hub.mu.Lock() + defer c.hub.mu.Unlock() + for { + _, found := c.hub.sessions[data.Sid] + if !found { + break + } + + c.hub.mu.Unlock() + select { + case <-ctx.Done(): + c.hub.mu.Lock() + return ctx.Err() + default: + time.Sleep(time.Millisecond) + } + c.hub.mu.Lock() + } + return nil +} + +func (c *TestClient) WriteJSON(data any) error { + if !strings.Contains(c.t.Name(), "HelloUnsupportedVersion") { + if msg, ok := data.(*api.ClientMessage); ok { + if err := msg.CheckValid(); err != nil { + return err + } + } + } + + c.mu.Lock() + defer c.mu.Unlock() + return c.conn.WriteJSON(data) +} + +func (c *TestClient) EnsuerWriteJSON(data any) { + require.NoError(c.t, c.WriteJSON(data), "Could not write JSON %+v", data) +} + +func (c *TestClient) SendHello(userid string) error { + return c.SendHelloV1(userid) +} + +func (c *TestClient) SendHelloV1(userid string) error { + params := TestBackendClientAuthParams{ + UserId: userid, + } + return c.SendHelloParams(c.server.URL, api.HelloVersionV1, "", nil, params) +} + +func (c *TestClient) SendHelloV2(userid string) error { + return c.SendHelloV2WithFeatures(userid, nil) +} + +func (c *TestClient) SendHelloV2WithFeatures(userid string, features []string) error { + now := time.Now() + return c.SendHelloV2WithTimesAndFeatures(userid, now, now.Add(time.Minute), features) +} + +func (c *TestClient) CreateHelloV2TokenWithUserdata(userid string, issuedAt time.Time, expiresAt time.Time, userdata api.StringMap) (string, error) { + data, err := json.Marshal(userdata) + if err != nil { + return "", err + } + + claims := &api.HelloV2TokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: c.server.URL, + Subject: userid, + }, + UserData: data, + } + if !issuedAt.IsZero() { + claims.IssuedAt = jwt.NewNumericDate(issuedAt) + } + if !expiresAt.IsZero() { + claims.ExpiresAt = jwt.NewNumericDate(expiresAt) + } + + var token *jwt.Token + if strings.Contains(c.t.Name(), "ECDSA") { + token = jwt.NewWithClaims(jwt.SigningMethodES256, claims) + } else if strings.Contains(c.t.Name(), "Ed25519") { + token = jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + } else { + token = jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + } + private := getPrivateAuthToken(c.t) + return token.SignedString(private) +} + +func (c *TestClient) CreateHelloV2Token(userid string, issuedAt time.Time, expiresAt time.Time) (string, error) { + userdata := api.StringMap{ + "displayname": "Displayname " + userid, + } + + return c.CreateHelloV2TokenWithUserdata(userid, issuedAt, expiresAt, userdata) +} + +func (c *TestClient) SendHelloV2WithTimes(userid string, issuedAt time.Time, expiresAt time.Time) error { + return c.SendHelloV2WithTimesAndFeatures(userid, issuedAt, expiresAt, nil) +} + +func (c *TestClient) SendHelloV2WithTimesAndFeatures(userid string, issuedAt time.Time, expiresAt time.Time, features []string) error { + tokenString, err := c.CreateHelloV2Token(userid, issuedAt, expiresAt) + require.NoError(c.t, err) + + params := api.HelloV2AuthParams{ + Token: tokenString, + } + return c.SendHelloParams(c.server.URL, api.HelloVersionV2, "", features, params) +} + +func (c *TestClient) SendHelloResume(resumeId api.PrivateSessionId) error { + hello := &api.ClientMessage{ + Id: "1234", + Type: "hello", + Hello: &api.HelloClientMessage{ + Version: api.HelloVersionV1, + ResumeId: resumeId, + }, + } + return c.WriteJSON(hello) +} + +func (c *TestClient) SendHelloClient(userid string) error { + return c.SendHelloClientWithFeatures(userid, nil) +} + +func (c *TestClient) SendHelloClientWithFeatures(userid string, features []string) error { + params := TestBackendClientAuthParams{ + UserId: userid, + } + return c.SendHelloParams(c.server.URL, api.HelloVersionV1, "client", features, params) +} + +func (c *TestClient) SendHelloInternal() error { + return c.SendHelloInternalWithFeatures(nil) +} + +func (c *TestClient) SendHelloInternalWithFeatures(features []string) error { + random := internal.RandomString(48) + mac := hmac.New(sha256.New, testInternalSecret) + mac.Write([]byte(random)) // nolint + token := hex.EncodeToString(mac.Sum(nil)) + backend := c.server.URL + + params := api.ClientTypeInternalAuthParams{ + Random: random, + Token: token, + Backend: backend, + } + return c.SendHelloParams("", api.HelloVersionV1, "internal", features, params) +} + +func (c *TestClient) SendHelloParams(url string, version string, clientType api.ClientType, features []string, params any) error { + data, err := json.Marshal(params) + require.NoError(c.t, err) + + hello := &api.ClientMessage{ + Id: "1234", + Type: "hello", + Hello: &api.HelloClientMessage{ + Version: version, + Features: features, + Auth: &api.HelloClientMessageAuth{ + Type: clientType, + Url: url, + Params: data, + }, + }, + } + return c.WriteJSON(hello) +} + +func (c *TestClient) SendBye() error { + hello := &api.ClientMessage{ + Id: "9876", + Type: "bye", + Bye: &api.ByeClientMessage{}, + } + return c.WriteJSON(hello) +} + +func (c *TestClient) SendMessage(recipient api.MessageClientMessageRecipient, data any) error { + payload, err := json.Marshal(data) + require.NoError(c.t, err) + + message := &api.ClientMessage{ + Id: "abcd", + Type: "message", + Message: &api.MessageClientMessage{ + Recipient: recipient, + Data: payload, + }, + } + return c.WriteJSON(message) +} + +func (c *TestClient) SendControl(recipient api.MessageClientMessageRecipient, data any) error { + payload, err := json.Marshal(data) + require.NoError(c.t, err) + + message := &api.ClientMessage{ + Id: "abcd", + Type: "control", + Control: &api.ControlClientMessage{ + MessageClientMessage: api.MessageClientMessage{ + Recipient: recipient, + Data: payload, + }, + }, + } + return c.WriteJSON(message) +} + +func (c *TestClient) SendInternalAddSession(msg *api.AddSessionInternalClientMessage) error { + message := &api.ClientMessage{ + Id: "abcd", + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "addsession", + AddSession: msg, + }, + } + return c.WriteJSON(message) +} + +func (c *TestClient) SendInternalUpdateSession(msg *api.UpdateSessionInternalClientMessage) error { + message := &api.ClientMessage{ + Id: "abcd", + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "updatesession", + UpdateSession: msg, + }, + } + return c.WriteJSON(message) +} + +func (c *TestClient) SendInternalRemoveSession(msg *api.RemoveSessionInternalClientMessage) error { + message := &api.ClientMessage{ + Id: "abcd", + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "removesession", + RemoveSession: msg, + }, + } + return c.WriteJSON(message) +} + +func (c *TestClient) SendInternalDialout(msg *api.DialoutInternalClientMessage) error { + message := &api.ClientMessage{ + Id: "abcd", + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "dialout", + Dialout: msg, + }, + } + return c.WriteJSON(message) +} + +func (c *TestClient) SetTransientData(key string, value any, ttl time.Duration) error { + payload, err := json.Marshal(value) + require.NoError(c.t, err) + + message := &api.ClientMessage{ + Id: "efgh", + Type: "transient", + TransientData: &api.TransientDataClientMessage{ + Type: "set", + Key: key, + Value: payload, + TTL: ttl, + }, + } + return c.WriteJSON(message) +} + +func (c *TestClient) RemoveTransientData(key string) error { + message := &api.ClientMessage{ + Id: "ijkl", + Type: "transient", + TransientData: &api.TransientDataClientMessage{ + Type: "remove", + Key: key, + }, + } + return c.WriteJSON(message) +} + +func (c *TestClient) GetPendingMessages(ctx context.Context) ([]*api.ServerMessage, error) { + var result []*api.ServerMessage + select { + case err := <-c.readErrorChan: + return nil, err + case msg := <-c.messageChan: + var m api.ServerMessage + if err := json.Unmarshal(msg, &m); err != nil { + return nil, err + } + result = append(result, &m) + n := len(c.messageChan) + for range n { + var m api.ServerMessage + msg = <-c.messageChan + if err := json.Unmarshal(msg, &m); err != nil { + return nil, err + } + result = append(result, &m) + } + case <-ctx.Done(): + return nil, ctx.Err() + } + return result, nil +} + +func (c *TestClient) RunUntilClosed(ctx context.Context) bool { + select { + case err := <-c.readErrorChan: + if c.assert.Error(err) && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + return true + } + + c.assert.NoError(err, "Received unexpected error") + case msg := <-c.messageChan: + var m api.ServerMessage + if err := json.Unmarshal(msg, &m); c.assert.NoError(err, "error decoding received message") { + c.assert.Fail("Server should have closed the connection", "received %+v", m) + } + case <-ctx.Done(): + c.assert.NoError(ctx.Err(), "error while waiting for closed connection") + } + return false +} + +func (c *TestClient) RunUntilErrorIs(ctx context.Context, targets ...error) bool { + var err error + select { + case err = <-c.readErrorChan: + case msg := <-c.messageChan: + var m api.ServerMessage + if err := json.Unmarshal(msg, &m); c.assert.NoError(err, "error decoding received message") { + c.assert.Fail("received message", "expected one of errors %+v, got message %+v", targets, m) + } + return false + case <-ctx.Done(): + err = ctx.Err() + } + + if c.assert.Error(err, "expected one of errors %+v", targets) { + for _, t := range targets { + if errors.Is(err, t) { + return true + } + } + + c.assert.Fail("invalid error", "expected one of errors %+v, got %s", targets, err) + } + + return false +} + +func (c *TestClient) RunUntilMessage(ctx context.Context) (*api.ServerMessage, bool) { + select { + case err := <-c.readErrorChan: + c.assert.NoError(err, "error reading while waiting for message") + return nil, false + case msg := <-c.messageChan: + var m api.ServerMessage + if err := json.Unmarshal(msg, &m); c.assert.NoError(err, "error decoding received message") { + return &m, true + } + case <-ctx.Done(): + c.assert.NoError(ctx.Err(), "error while waiting for message") + } + return nil, false +} + +func (c *TestClient) RunUntilMessageOrClosed(ctx context.Context) (*api.ServerMessage, bool) { + select { + case err := <-c.readErrorChan: + if c.assert.Error(err) && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + return nil, true + } + + c.assert.NoError(err, "Received unexpected error") + return nil, false + case msg := <-c.messageChan: + var m api.ServerMessage + if err := json.Unmarshal(msg, &m); c.assert.NoError(err, "error decoding received message") { + return &m, true + } + case <-ctx.Done(): + c.assert.NoError(ctx.Err(), "error while waiting for message") + } + return nil, false +} + +func (c *TestClient) RunUntilError(ctx context.Context, code string) (*api.Error, bool) { + message, ok := c.RunUntilMessage(ctx) + if !ok || + !checkMessageType(c.t, message, "error") || + !c.assert.Equal(code, message.Error.Code, "invalid error code in %+v", message) { + return nil, false + } + + return message.Error, true +} + +func (c *TestClient) RunUntilHello(ctx context.Context) (*api.ServerMessage, bool) { + message, ok := c.RunUntilMessage(ctx) + if !ok || + !checkMessageType(c.t, message, "hello") { + return nil, false + } + + c.publicId = message.Hello.SessionId + return message, true +} + +func (c *TestClient) JoinRoom(ctx context.Context, roomId string) (*api.ServerMessage, bool) { + return c.JoinRoomWithRoomSession(ctx, roomId, api.RoomSessionId(fmt.Sprintf("%s-%s", roomId, c.publicId))) +} + +func (c *TestClient) JoinRoomWithRoomSession(ctx context.Context, roomId string, roomSessionId api.RoomSessionId) (message *api.ServerMessage, ok bool) { + msg := &api.ClientMessage{ + Id: "ABCD", + Type: "room", + Room: &api.RoomClientMessage{ + RoomId: roomId, + SessionId: roomSessionId, + }, + } + if err := c.WriteJSON(msg); !c.assert.NoError(err) { + return nil, false + } + + if message, ok = c.RunUntilMessage(ctx); !ok || + !checkMessageType(c.t, message, "room") || + !c.assert.Equal(msg.Id, message.Id, "invalid message id in %+v", message) { + return nil, false + } + + return message, true +} + +func checkMessageRoomId(t *testing.T, message *api.ServerMessage, roomId string) bool { + return checkMessageType(t, message, "room") && + assert.Equal(t, roomId, message.Room.RoomId, "invalid room id in %+v", message) +} + +func (c *TestClient) RunUntilRoom(ctx context.Context, roomId string) bool { + message, ok := c.RunUntilMessage(ctx) + return ok && checkMessageRoomId(c.t, message, roomId) +} + +func (c *TestClient) checkMessageJoined(message *api.ServerMessage, hello *api.HelloServerMessage) bool { + return c.checkMessageJoinedSession(message, hello.SessionId, hello.UserId) +} + +func (c *TestClient) checkSingleMessageJoined(message *api.ServerMessage) bool { + return checkMessageType(c.t, message, "event") && + c.assert.Equal("room", message.Event.Target, "invalid event target in %+v", message) && + c.assert.Equal("join", message.Event.Type, "invalid event type in %+v", message) && + c.assert.Len(message.Event.Join, 1, "invalid number of join event entries in %+v", message) +} + +func (c *TestClient) checkMessageJoinedSession(message *api.ServerMessage, sessionId api.PublicSessionId, userId string) bool { + if !c.checkSingleMessageJoined(message) { + return false + } + + failed := false + evt := message.Event.Join[0] + if sessionId != "" { + if !c.assert.Equal(sessionId, evt.SessionId, "invalid join session id: expected %+v, got %+v in %+v", + getPubliceSessionIdData(c.hub, sessionId), + getPubliceSessionIdData(c.hub, evt.SessionId), + message, + ) { + failed = true + } + } + if !c.assert.Equal(userId, evt.UserId, "invalid user id in %+v", evt) { + failed = true + } + return !failed +} + +func (c *TestClient) RunUntilJoinedAndReturn(ctx context.Context, hello ...*api.HelloServerMessage) ([]api.EventServerMessageSessionEntry, []*api.ServerMessage, bool) { + received := make([]api.EventServerMessageSessionEntry, len(hello)) + var ignored []*api.ServerMessage + hellos := make(map[*api.HelloServerMessage]int, len(hello)) + for idx, h := range hello { + hellos[h] = idx + } + for len(hellos) > 0 { + message, ok := c.RunUntilMessage(ctx) + if !ok { + return nil, nil, false + } + + if message.Type != "event" || message.Event == nil { + ignored = append(ignored, message) + continue + } else if message.Event.Target != "room" || message.Event.Type != "join" { + ignored = append(ignored, message) + continue + } + + if !checkMessageType(c.t, message, "event") { + continue + } + + for len(message.Event.Join) > 0 { + found := false + loop: + for h, idx := range hellos { + for idx2, evt := range message.Event.Join { + if evt.SessionId == h.SessionId && evt.UserId == h.UserId { + received[idx] = evt + delete(hellos, h) + message.Event.Join = append(message.Event.Join[:idx2], message.Event.Join[idx2+1:]...) + found = true + break loop + } + } + } + if !c.assert.True(found, "expected one of the passed hello sessions, got %+v", message.Event.Join) { + break + } + } + } + return received, ignored, true +} + +func (c *TestClient) RunUntilJoined(ctx context.Context, hello ...*api.HelloServerMessage) bool { + _, unexpected, ok := c.RunUntilJoinedAndReturn(ctx, hello...) + return ok && c.assert.Empty(unexpected, "Received unexpected messages: %+v", unexpected) +} + +func (c *TestClient) checkMessageRoomLeave(message *api.ServerMessage, hello *api.HelloServerMessage) bool { + return c.checkMessageRoomLeaveSession(message, hello.SessionId) +} + +func (c *TestClient) checkMessageRoomLeaveSession(message *api.ServerMessage, sessionId api.PublicSessionId) bool { + return checkMessageType(c.t, message, "event") && + c.assert.Equal("room", message.Event.Target, "invalid target in %+v", message) && + c.assert.Equal("leave", message.Event.Type, "invalid event type in %+v", message) && + c.assert.Len(message.Event.Leave, 1, "invalid number of leave event entries: %+v", message.Event) && + c.assert.Equal(sessionId, message.Event.Leave[0], "invalid leave session: expected %+v, got %+v in %+v", + getPubliceSessionIdData(c.hub, sessionId), + getPubliceSessionIdData(c.hub, message.Event.Leave[0]), + message, + ) +} + +func (c *TestClient) RunUntilLeft(ctx context.Context, hello *api.HelloServerMessage) bool { + message, ok := c.RunUntilMessage(ctx) + return ok && c.checkMessageRoomLeave(message, hello) +} + +func checkMessageRoomlistUpdate(t *testing.T, message *api.ServerMessage) (*api.RoomEventServerMessage, bool) { + assert := assert.New(t) + if !checkMessageType(t, message, "event") || + !assert.Equal("roomlist", message.Event.Target, "invalid event target in %+v", message) || + !assert.Equal("update", message.Event.Type, "invalid event type in %+v", message) || + !assert.NotNil(message.Event.Update, "update missing in %+v", message) { + return nil, false + } + + return message.Event.Update, true +} + +func (c *TestClient) RunUntilRoomlistUpdate(ctx context.Context) (*api.RoomEventServerMessage, bool) { + message, ok := c.RunUntilMessage(ctx) + if !ok { + return nil, false + } + + return checkMessageRoomlistUpdate(c.t, message) +} + +func checkMessageRoomlistDisinvite(t *testing.T, message *api.ServerMessage) (*api.RoomDisinviteEventServerMessage, bool) { + assert := assert.New(t) + if !checkMessageType(t, message, "event") || + !assert.Equal("roomlist", message.Event.Target, "invalid event target in %+v", message) || + !assert.Equal("disinvite", message.Event.Type, "invalid event type in %+v", message) || + !assert.NotNil(message.Event.Disinvite, "disinvite missing in %+v", message) { + return nil, false + } + + return message.Event.Disinvite, true +} + +func (c *TestClient) RunUntilRoomlistDisinvite(ctx context.Context) (*api.RoomDisinviteEventServerMessage, bool) { + message, ok := c.RunUntilMessage(ctx) + if !ok { + return nil, false + } + + return checkMessageRoomlistDisinvite(c.t, message) +} + +func checkMessageParticipantsInCall(t *testing.T, message *api.ServerMessage) (*api.RoomEventServerMessage, bool) { + assert := assert.New(t) + if !checkMessageType(t, message, "event") || + !assert.Equal("participants", message.Event.Target, "invalid event target in %+v", message) || + !assert.Equal("update", message.Event.Type, "invalid event type in %+v", message) || + !assert.NotNil(message.Event.Update, "update missing in %+v", message) { + return nil, false + } + + return message.Event.Update, true +} + +func checkMessageParticipantFlags(t *testing.T, message *api.ServerMessage) (*api.RoomFlagsServerMessage, bool) { + assert := assert.New(t) + if !checkMessageType(t, message, "event") || + !assert.Equal("participants", message.Event.Target, "invalid event target in %+v", message) || + !assert.Equal("flags", message.Event.Type, "invalid event type in %+v", message) || + !assert.NotNil(message.Event.Flags, "flags missing in %+v", message) { + return nil, false + } + + return message.Event.Flags, true +} + +func checkMessageRoomMessage(t *testing.T, message *api.ServerMessage) (*api.RoomEventMessage, bool) { + assert := assert.New(t) + if !checkMessageType(t, message, "event") || + !assert.Equal("room", message.Event.Target, "invalid event target in %+v", message) || + !assert.Equal("message", message.Event.Type, "invalid event type in %+v", message) || + !assert.NotNil(message.Event.Message, "message missing in %+v", message) { + return nil, false + } + + return message.Event.Message, true +} + +func (c *TestClient) RunUntilRoomMessage(ctx context.Context) (*api.RoomEventMessage, bool) { + message, ok := c.RunUntilMessage(ctx) + if !ok { + return nil, false + } + + return checkMessageRoomMessage(c.t, message) +} + +func checkMessageError(t *testing.T, message *api.ServerMessage, msgid string) bool { + return checkMessageType(t, message, "error") && + assert.Equal(t, msgid, message.Error.Code, "invalid error code in %+v", message) +} + +func (c *TestClient) RunUntilOffer(ctx context.Context, offer string) bool { + message, ok := c.RunUntilMessage(ctx) + if !ok || !checkMessageType(c.t, message, "message") { + return false + } + + var data api.StringMap + if err := json.Unmarshal(message.Message.Data, &data); !c.assert.NoError(err) { + return false + } + + if dt, ok := api.GetStringMapEntry[string](data, "type"); !c.assert.True(ok, "no/invalid type in %+v", data) || + !c.assert.Equal("offer", dt, "invalid data type in %+v", data) { + return false + } + + if payload, ok := api.ConvertStringMap(data["payload"]); !c.assert.True(ok, "not a string map, got %+v", data["payload"]) { + return false + } else { + if pt, ok := api.GetStringMapEntry[string](payload, "type"); !c.assert.True(ok, "no/invalid type in payload %+v", payload) || + !c.assert.Equal("offer", pt, "invalid payload type in %+v", payload) { + return false + } + if sdp, ok := api.GetStringMapEntry[string](payload, "sdp"); !c.assert.True(ok, "no/invalid sdp in payload %+v", payload) || + !c.assert.Equal(offer, sdp, "invalid payload offer") { + return false + } + } + + return true +} + +func (c *TestClient) RunUntilAnswer(ctx context.Context, answer string) bool { + return c.RunUntilAnswerFromSender(ctx, answer, nil) +} + +func (c *TestClient) RunUntilAnswerFromSender(ctx context.Context, answer string, sender *api.MessageServerMessageSender) bool { + message, ok := c.RunUntilMessage(ctx) + if !ok || !checkMessageType(c.t, message, "message") { + return false + } + + if sender != nil { + if !checkMessageSender(c.t, c.hub, message.Message.Sender, sender.Type, &api.HelloServerMessage{ + SessionId: sender.SessionId, + UserId: sender.UserId, + }) { + return false + } + } + + var data api.StringMap + if err := json.Unmarshal(message.Message.Data, &data); !c.assert.NoError(err) { + return false + } + + if dt, ok := api.GetStringMapEntry[string](data, "type"); !c.assert.True(ok, "no/invalid type in %+v", data) || + !c.assert.Equal("answer", dt, "invalid data type in %+v", data) { + return false + } + + if payload, ok := api.ConvertStringMap(data["payload"]); !c.assert.True(ok, "not a string map, got %+v", data["payload"]) { + return false + } else { + if pt, ok := api.GetStringMapEntry[string](payload, "type"); !c.assert.True(ok, "no/invalid type in payload %+v", payload) || + !c.assert.Equal("answer", pt, "invalid payload type in %+v", payload) { + return false + } + if sdp, ok := api.GetStringMapEntry[string](payload, "sdp"); !c.assert.True(ok, "no/invalid sdp in payload %+v", payload) || + !c.assert.Equal(answer, sdp, "invalid payload answer") { + return false + } + } + + return true +} + +func checkMessageTransientInitialOrSet(t *testing.T, message *api.ServerMessage, key string, value any) bool { + assert := assert.New(t) + return checkMessageType(t, message, "transient") && + assert.True(message.TransientData.Type == "initial" || message.TransientData.Type == "set", "invalid message type in %+v", message) && + assert.Equal(key, message.TransientData.Key, "invalid key in %+v", message) && + assert.EqualValues(value, message.TransientData.Value, "invalid value in %+v", message) && + assert.Nil(message.TransientData.OldValue, "invalid old value in %+v", message) +} + +func checkMessageTransientSet(t *testing.T, message *api.ServerMessage, key string, value any, oldValue any) bool { + assert := assert.New(t) + return checkMessageType(t, message, "transient") && + assert.Equal("set", message.TransientData.Type, "invalid message type in %+v", message) && + assert.Equal(key, message.TransientData.Key, "invalid key in %+v", message) && + assert.EqualValues(value, message.TransientData.Value, "invalid value in %+v", message) && + assert.EqualValues(oldValue, message.TransientData.OldValue, "invalid old value in %+v", message) +} + +func checkMessageTransientRemove(t *testing.T, message *api.ServerMessage, key string, oldValue any) bool { + assert := assert.New(t) + return checkMessageType(t, message, "transient") && + assert.Equal("remove", message.TransientData.Type, "invalid message type in %+v", message) && + assert.Equal(key, message.TransientData.Key, "invalid key in %+v", message) && + assert.EqualValues(oldValue, message.TransientData.OldValue, "invalid old value in %+v", message) +} + +func checkMessageTransientInitial(t *testing.T, message *api.ServerMessage, data api.StringMap) bool { + assert := assert.New(t) + return checkMessageType(t, message, "transient") && + assert.Equal("initial", message.TransientData.Type, "invalid message type in %+v", message) && + assert.Equal(data, message.TransientData.Data, "invalid initial data in %+v", message) +} + +func checkMessageInCallAll(t *testing.T, message *api.ServerMessage, roomId string, inCall int) bool { + assert := assert.New(t) + return checkMessageType(t, message, "event") && + assert.Equal("update", message.Event.Type, "invalid event type, got %+v", message.Event) && + assert.Equal("participants", message.Event.Target, "invalid event target, got %+v", message.Event) && + assert.Equal(roomId, message.Event.Update.RoomId, "invalid event update room id, got %+v", message.Event) && + assert.True(message.Event.Update.All, "expected participants update event for all, got %+v", message.Event) && + assert.EqualValues(strconv.FormatInt(int64(inCall), 10), message.Event.Update.InCall, "expected incall flags %d, got %+v", inCall, message.Event.Update) +} + +func checkMessageSwitchTo(t *testing.T, message *api.ServerMessage, roomId string, details json.RawMessage) (*api.EventServerMessageSwitchTo, bool) { + assert := assert.New(t) + if !checkMessageType(t, message, "event") || + !assert.Equal("switchto", message.Event.Type, "invalid event type, got %+v", message.Event) || + !assert.Equal("room", message.Event.Target, "invalid event target, got %+v", message.Event) || + !assert.Equal(roomId, message.Event.SwitchTo.RoomId, "invalid event switchto room id, got %+v", message.Event) { + return nil, false + } + if details != nil { + if !assert.NotEmpty(message.Event.SwitchTo.Details, "details missing in %+v", message) || + !assert.Equal(details, message.Event.SwitchTo.Details, "invalid details, got %+v", message.Event) { + return nil, false + } + } else if assert.Empty(message.Event.SwitchTo.Details, "expected no details in %+v", message) { + return nil, false + } + return message.Event.SwitchTo, true +} + +func (c *TestClient) RunUntilSwitchTo(ctx context.Context, roomId string, details json.RawMessage) (*api.EventServerMessageSwitchTo, bool) { + message, ok := c.RunUntilMessage(ctx) + if !ok { + return nil, false + } + + return checkMessageSwitchTo(c.t, message, roomId, details) +} diff --git a/server/testutils_test.go b/server/testutils_test.go new file mode 100644 index 0000000..6360d06 --- /dev/null +++ b/server/testutils_test.go @@ -0,0 +1,83 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package server + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" +) + +func WaitForUsersJoined(ctx context.Context, t *testing.T, client1 *TestClient, hello1 *api.ServerMessage, client2 *TestClient, hello2 *api.ServerMessage) { + t.Helper() + // We will receive "joined" events for all clients. The ordering is not + // defined as messages are processed and sent by asynchronous event handlers. + client1.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) + client2.RunUntilJoined(ctx, hello1.Hello, hello2.Hello) +} + +func MustSucceed1[T any, A1 any](t *testing.T, f func(a1 A1) (T, bool), a1 A1) T { + t.Helper() + result, ok := f(a1) + if !ok { + t.FailNow() + } + return result +} + +func MustSucceed2[T any, A1 any, A2 any](t *testing.T, f func(a1 A1, a2 A2) (T, bool), a1 A1, a2 A2) T { + t.Helper() + result, ok := f(a1, a2) + if !ok { + t.FailNow() + } + return result +} + +func MustSucceed3[T any, A1 any, A2 any, A3 any](t *testing.T, f func(a1 A1, a2 A2, a3 A3) (T, bool), a1 A1, a2 A2, a3 A3) T { + t.Helper() + result, ok := f(a1, a2, a3) + if !ok { + t.FailNow() + } + return result +} + +func AssertEqualSerialized(t *testing.T, expected any, actual any, msgAndArgs ...any) bool { + t.Helper() + + e, err := json.MarshalIndent(expected, "", " ") + if !assert.NoError(t, err) { + return false + } + + a, err := json.MarshalIndent(actual, "", " ") + if !assert.NoError(t, err) { + return false + } + + return assert.Equal(t, string(a), string(e), msgAndArgs...) +} diff --git a/virtualsession.go b/server/virtualsession.go similarity index 51% rename from virtualsession.go rename to server/virtualsession.go index cfbd435..bb484a9 100644 --- a/virtualsession.go +++ b/server/virtualsession.go @@ -19,14 +19,23 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package server import ( "context" "encoding/json" - "log" + "errors" "net/url" "sync/atomic" + "time" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async/events" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/nats" + "github.com/strukturag/nextcloud-spreed-signaling/v2/session" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" ) const ( @@ -36,40 +45,53 @@ const ( ) type VirtualSession struct { + logger log.Logger hub *Hub session *ClientSession - privateId string - publicId string - data *SessionIdData + privateId api.PrivateSessionId + publicId api.PublicSessionId + data *session.SessionIdData + ctx context.Context + closeFunc context.CancelFunc room atomic.Pointer[Room] - sessionId string + sessionId api.PublicSessionId userId string userData json.RawMessage - inCall Flags - flags Flags - options *AddSessionOptions + inCall internal.Flags + flags internal.Flags + options *api.AddSessionOptions - parseUserData func() (map[string]interface{}, error) + parseUserData func() (api.StringMap, error) + + asyncCh events.AsyncChannel } -func GetVirtualSessionId(session Session, sessionId string) string { +func GetVirtualSessionId(session Session, sessionId api.PublicSessionId) api.PublicSessionId { return session.PublicId() + "|" + sessionId } -func NewVirtualSession(session *ClientSession, privateId string, publicId string, data *SessionIdData, msg *AddSessionInternalClientMessage) (*VirtualSession, error) { +func NewVirtualSession(session *ClientSession, privateId api.PrivateSessionId, publicId api.PublicSessionId, data *session.SessionIdData, msg *api.AddSessionInternalClientMessage) (*VirtualSession, error) { + ctx := log.NewLoggerContext(session.Context(), session.hub.logger) + ctx, closeFunc := context.WithCancel(ctx) + result := &VirtualSession{ + logger: session.hub.logger, hub: session.hub, session: session, privateId: privateId, publicId: publicId, data: data, + ctx: ctx, + closeFunc: closeFunc, sessionId: msg.SessionId, userId: msg.UserId, userData: msg.User, parseUserData: parseUserData(msg.User), options: msg.Options, + + asyncCh: make(events.AsyncChannel, events.DefaultAsyncChannelSize), } if err := session.events.RegisterSessionListener(publicId, session.Backend(), result); err != nil { @@ -78,30 +100,31 @@ func NewVirtualSession(session *ClientSession, privateId string, publicId string if msg.InCall != nil { result.SetInCall(*msg.InCall) - } else if !session.HasFeature(ClientFeatureInternalInCall) { + } else if !session.HasFeature(api.ClientFeatureInternalInCall) { result.SetInCall(FlagInCall | FlagWithPhone) } if msg.Flags != 0 { result.SetFlags(msg.Flags) } + go result.run() return result, nil } func (s *VirtualSession) Context() context.Context { - return s.session.Context() + return s.ctx } -func (s *VirtualSession) PrivateId() string { +func (s *VirtualSession) PrivateId() api.PrivateSessionId { return s.privateId } -func (s *VirtualSession) PublicId() string { +func (s *VirtualSession) PublicId() api.PublicSessionId { return s.publicId } -func (s *VirtualSession) ClientType() string { - return HelloClientTypeVirtual +func (s *VirtualSession) ClientType() api.ClientType { + return api.HelloClientTypeVirtual } func (s *VirtualSession) GetInCall() int { @@ -116,11 +139,11 @@ func (s *VirtualSession) SetInCall(inCall int) bool { return s.inCall.Set(uint32(inCall)) } -func (s *VirtualSession) Data() *SessionIdData { +func (s *VirtualSession) Data() *session.SessionIdData { return s.data } -func (s *VirtualSession) Backend() *Backend { +func (s *VirtualSession) Backend() *talk.Backend { return s.session.Backend() } @@ -132,6 +155,10 @@ func (s *VirtualSession) ParsedBackendUrl() *url.URL { return s.session.ParsedBackendUrl() } +func (s *VirtualSession) ParsedBackendOcsUrl() *url.URL { + return s.session.ParsedBackendOcsUrl() +} + func (s *VirtualSession) UserId() string { return s.userId } @@ -140,15 +167,15 @@ func (s *VirtualSession) UserData() json.RawMessage { return s.userData } -func (s *VirtualSession) ParsedUserData() (map[string]interface{}, error) { +func (s *VirtualSession) ParsedUserData() (api.StringMap, error) { return s.parseUserData() } -func (s *VirtualSession) SetRoom(room *Room) { +func (s *VirtualSession) SetRoom(room *Room, joinTime time.Time) { s.room.Store(room) if room != nil { - if err := s.hub.roomSessions.SetRoomSession(s, s.PublicId()); err != nil { - log.Printf("Error adding virtual room session %s: %s", s.PublicId(), err) + if err := s.hub.roomSessions.SetRoomSession(s, api.RoomSessionId(s.PublicId())); err != nil { + s.logger.Printf("Error adding virtual room session %s: %s", s.PublicId(), err) } } else { s.hub.roomSessions.DeleteRoomSession(s) @@ -159,49 +186,75 @@ func (s *VirtualSession) GetRoom() *Room { return s.room.Load() } +func (s *VirtualSession) IsInRoom(id string) bool { + room := s.GetRoom() + return room != nil && room.Id() == id +} + func (s *VirtualSession) LeaveRoom(notify bool) *Room { room := s.GetRoom() if room == nil { return nil } - s.SetRoom(nil) + s.SetRoom(nil, time.Time{}) room.RemoveSession(s) return room } +func (s *VirtualSession) AsyncChannel() events.AsyncChannel { + return s.asyncCh +} + +func (s *VirtualSession) run() { + for { + select { + case <-s.ctx.Done(): + return + case msg := <-s.asyncCh: + s.processAsyncNatsMessage(msg) + for count := len(s.asyncCh); count > 0; count-- { + s.processAsyncNatsMessage(<-s.asyncCh) + } + } + } +} + func (s *VirtualSession) Close() { s.CloseWithFeedback(nil, nil) } -func (s *VirtualSession) CloseWithFeedback(session Session, message *ClientMessage) { +func (s *VirtualSession) CloseWithFeedback(session Session, message *api.ClientMessage) { + s.closeFunc() + room := s.GetRoom() s.session.RemoveVirtualSession(s) removed := s.session.hub.removeSession(s) if removed && room != nil { go s.notifyBackendRemoved(room, session, message) } - s.session.events.UnregisterSessionListener(s.PublicId(), s.session.Backend(), s) + if err := s.session.events.UnregisterSessionListener(s.PublicId(), s.session.Backend(), s); err != nil && !errors.Is(err, nats.ErrConnectionClosed) { + s.logger.Printf("Error unsubscribing listener for session %s: %s", s.publicId, err) + } } -func (s *VirtualSession) notifyBackendRemoved(room *Room, session Session, message *ClientMessage) { - ctx, cancel := context.WithTimeout(context.Background(), s.hub.backendTimeout) +func (s *VirtualSession) notifyBackendRemoved(room *Room, session Session, message *api.ClientMessage) { + ctx := log.NewLoggerContext(context.Background(), s.logger) + ctx, cancel := context.WithTimeout(ctx, s.hub.backendTimeout) defer cancel() - if options := s.Options(); options != nil { - request := NewBackendClientRoomRequest(room.Id(), s.UserId(), s.PublicId()) + if options := s.Options(); options != nil && options.ActorId != "" && options.ActorType != "" { + request := talk.NewBackendClientRoomRequest(room.Id(), s.UserId(), api.RoomSessionId(s.PublicId())) request.Room.Action = "leave" - if options != nil { - request.Room.ActorId = options.ActorId - request.Room.ActorType = options.ActorType - } + request.Room.ActorId = options.ActorId + request.Room.ActorType = options.ActorType - var response BackendClientResponse - if err := s.hub.backend.PerformJSONRequest(ctx, s.ParsedBackendUrl(), request, &response); err != nil { + var response talk.BackendClientResponse + if err := s.hub.backend.PerformJSONRequest(ctx, s.ParsedBackendOcsUrl(), request, &response); err != nil { virtualSessionId := GetVirtualSessionId(s.session, s.PublicId()) - log.Printf("Could not leave virtual session %s at backend %s: %s", virtualSessionId, s.BackendUrl(), err) + s.logger.Printf("Could not leave virtual session %s at backend %s: %s", virtualSessionId, s.BackendUrl(), err) if session != nil && message != nil { - reply := message.NewErrorServerMessage(NewError("remove_failed", "Could not remove virtual session from backend.")) + reply := message.NewErrorServerMessage(api.NewError("remove_failed", "Could not remove virtual session from backend.")) session.SendMessage(reply) } return @@ -210,30 +263,30 @@ func (s *VirtualSession) notifyBackendRemoved(room *Room, session Session, messa if response.Type == "error" { virtualSessionId := GetVirtualSessionId(s.session, s.PublicId()) if session != nil && message != nil && (response.Error == nil || response.Error.Code != "no_such_room") { - log.Printf("Could not leave virtual session %s at backend %s: %+v", virtualSessionId, s.BackendUrl(), response.Error) - reply := message.NewErrorServerMessage(NewError("remove_failed", response.Error.Error())) + s.logger.Printf("Could not leave virtual session %s at backend %s: %+v", virtualSessionId, s.BackendUrl(), response.Error) + reply := message.NewErrorServerMessage(api.NewError("remove_failed", response.Error.Error())) session.SendMessage(reply) } return } } else { - request := NewBackendClientSessionRequest(room.Id(), "remove", s.PublicId(), &AddSessionInternalClientMessage{ + request := talk.NewBackendClientSessionRequest(room.Id(), "remove", s.PublicId(), &api.AddSessionInternalClientMessage{ UserId: s.userId, User: s.userData, }) - var response BackendClientSessionResponse - err := s.hub.backend.PerformJSONRequest(ctx, s.ParsedBackendUrl(), request, &response) + var response talk.BackendClientSessionResponse + err := s.hub.backend.PerformJSONRequest(ctx, s.ParsedBackendOcsUrl(), request, &response) if err != nil { - log.Printf("Could not remove virtual session %s from backend %s: %s", s.PublicId(), s.BackendUrl(), err) + s.logger.Printf("Could not remove virtual session %s from backend %s: %s", s.PublicId(), s.BackendUrl(), err) if session != nil && message != nil { - reply := message.NewErrorServerMessage(NewError("remove_failed", "Could not remove virtual session from backend.")) + reply := message.NewErrorServerMessage(api.NewError("remove_failed", "Could not remove virtual session from backend.")) session.SendMessage(reply) } } } } -func (s *VirtualSession) HasPermission(permission Permission) bool { +func (s *VirtualSession) HasPermission(permission api.Permission) bool { return true } @@ -241,7 +294,7 @@ func (s *VirtualSession) Session() *ClientSession { return s.session } -func (s *VirtualSession) SessionId() string { +func (s *VirtualSession) SessionId() api.PublicSessionId { return s.sessionId } @@ -261,11 +314,21 @@ func (s *VirtualSession) Flags() uint32 { return s.flags.Get() } -func (s *VirtualSession) Options() *AddSessionOptions { +func (s *VirtualSession) Options() *api.AddSessionOptions { return s.options } -func (s *VirtualSession) ProcessAsyncSessionMessage(message *AsyncMessage) { +func (s *VirtualSession) processAsyncNatsMessage(msg *nats.Msg) { + var message events.AsyncMessage + if err := nats.Decode(msg, &message); err != nil { + s.logger.Printf("Could not decode NATS message %+v: %s", msg, err) + return + } + + s.processAsyncMessage(&message) +} + +func (s *VirtualSession) processAsyncMessage(message *events.AsyncMessage) { if message.Type == "message" && message.Message != nil { switch message.Message.Type { case "message": @@ -274,12 +337,12 @@ func (s *VirtualSession) ProcessAsyncSessionMessage(message *AsyncMessage) { message.Message.Message.Recipient.Type == "session" && message.Message.Message.Recipient.SessionId == s.PublicId() { // The client should see his session id as recipient. - message.Message.Message.Recipient = &MessageClientMessageRecipient{ + message.Message.Message.Recipient = &api.MessageClientMessageRecipient{ Type: "session", SessionId: s.SessionId(), UserId: s.UserId(), } - s.session.ProcessAsyncSessionMessage(message) + s.session.processAsyncMessage(message) } case "event": if room := s.GetRoom(); room != nil && @@ -287,8 +350,8 @@ func (s *VirtualSession) ProcessAsyncSessionMessage(message *AsyncMessage) { message.Message.Event.Type == "disinvite" && message.Message.Event.Disinvite != nil && message.Message.Event.Disinvite.RoomId == room.Id() { - log.Printf("Virtual session %s was disinvited from room %s, hanging up", s.PublicId(), room.Id()) - payload := map[string]interface{}{ + s.logger.Printf("Virtual session %s was disinvited from room %s, hanging up", s.PublicId(), room.Id()) + payload := api.StringMap{ "type": "hangup", "hangup": map[string]string{ "reason": "disinvited", @@ -296,17 +359,17 @@ func (s *VirtualSession) ProcessAsyncSessionMessage(message *AsyncMessage) { } data, err := json.Marshal(payload) if err != nil { - log.Printf("could not marshal control payload %+v: %s", payload, err) + s.logger.Printf("could not marshal control payload %+v: %s", payload, err) return } - s.session.ProcessAsyncSessionMessage(&AsyncMessage{ + s.session.processAsyncMessage(&events.AsyncMessage{ Type: "message", SendTime: message.SendTime, - Message: &ServerMessage{ + Message: &api.ServerMessage{ Type: "control", - Control: &ControlServerMessage{ - Recipient: &MessageClientMessageRecipient{ + Control: &api.ControlServerMessage{ + Recipient: &api.MessageClientMessageRecipient{ Type: "session", SessionId: s.SessionId(), UserId: s.UserId(), @@ -322,21 +385,21 @@ func (s *VirtualSession) ProcessAsyncSessionMessage(message *AsyncMessage) { message.Message.Control.Recipient.Type == "session" && message.Message.Control.Recipient.SessionId == s.PublicId() { // The client should see his session id as recipient. - message.Message.Control.Recipient = &MessageClientMessageRecipient{ + message.Message.Control.Recipient = &api.MessageClientMessageRecipient{ Type: "session", SessionId: s.SessionId(), UserId: s.UserId(), } - s.session.ProcessAsyncSessionMessage(message) + s.session.processAsyncMessage(message) } } } } -func (s *VirtualSession) SendError(e *Error) bool { +func (s *VirtualSession) SendError(e *api.Error) bool { return s.session.SendError(e) } -func (s *VirtualSession) SendMessage(message *ServerMessage) bool { +func (s *VirtualSession) SendMessage(message *api.ServerMessage) bool { return s.session.SendMessage(message) } diff --git a/server/virtualsession_test.go b/server/virtualsession_test.go new file mode 100644 index 0000000..85181bb --- /dev/null +++ b/server/virtualsession_test.go @@ -0,0 +1,704 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package server + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +func TestVirtualSession(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + + roomId := "the-room-id" + emptyProperties := json.RawMessage("{}") + backend := talk.NewCompatBackend(nil) + room, err := hub.CreateRoom(roomId, emptyProperties, backend) + require.NoError(err) + defer room.Close() + + clientInternal := NewTestClient(t, server, hub) + defer clientInternal.CloseWithBye() + require.NoError(clientInternal.SendHelloInternal()) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + if hello, ok := clientInternal.RunUntilHello(ctx); ok { + assert.Empty(hello.Hello.UserId) + assert.NotEmpty(hello.Hello.SessionId) + assert.NotEmpty(hello.Hello.ResumeId) + } + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + client.RunUntilJoined(ctx, hello.Hello) + + internalSessionId := api.PublicSessionId("session1") + userId := "user1" + msgAdd := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "addsession", + AddSession: &api.AddSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ + SessionId: internalSessionId, + RoomId: roomId, + }, + UserId: userId, + Flags: FLAG_MUTED_SPEAKING, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgAdd)) + + msg1 := MustSucceed1(t, client.RunUntilMessage, ctx) + // The public session id will be generated by the server, so don't check for it. + require.True(client.checkMessageJoinedSession(msg1, "", userId)) + sessionId := msg1.Event.Join[0].SessionId + session := hub.GetSessionByPublicId(sessionId) + if assert.NotNil(session, "Could not get virtual session %s", sessionId) { + assert.Equal(api.HelloClientTypeVirtual, session.ClientType()) + sid := session.(*VirtualSession).SessionId() + assert.Equal(internalSessionId, sid) + } + + // Also a participants update event will be triggered for the virtual user. + msg2 := MustSucceed1(t, client.RunUntilMessage, ctx) + msg3 := MustSucceed1(t, client.RunUntilMessage, ctx) + if msg2.Type == "event" && msg3.Type == "event" && msg2.Event.Type == "flags" { + // The order is not specified, could be "participants" before "flags" or vice versa. + // Ensure consistent order for checks below ("participants", "flags"). + t.Logf("Switching messages order") + msg2, msg3 = msg3, msg2 + } + + if updateMsg, ok := checkMessageParticipantsInCall(t, msg2); ok { + assert.Equal(roomId, updateMsg.RoomId) + if assert.Len(updateMsg.Users, 1) { + assert.EqualValues(sessionId, updateMsg.Users[0]["sessionId"]) + assert.Equal(true, updateMsg.Users[0]["virtual"]) + assert.EqualValues((FlagInCall | FlagWithPhone), updateMsg.Users[0]["inCall"]) + } + } + + if flagsMsg, ok := checkMessageParticipantFlags(t, msg3); ok { + assert.Equal(roomId, flagsMsg.RoomId) + assert.Equal(sessionId, flagsMsg.SessionId) + assert.EqualValues(FLAG_MUTED_SPEAKING, flagsMsg.Flags) + } + + newFlags := uint32(FLAG_TALKING) + msgFlags := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "updatesession", + UpdateSession: &api.UpdateSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ + SessionId: internalSessionId, + RoomId: roomId, + }, + Flags: &newFlags, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgFlags)) + + msg4 := MustSucceed1(t, client.RunUntilMessage, ctx) + if flagsMsg, ok := checkMessageParticipantFlags(t, msg4); ok { + assert.Equal(roomId, flagsMsg.RoomId) + assert.Equal(sessionId, flagsMsg.SessionId) + assert.Equal(newFlags, flagsMsg.Flags) + } + + // A new client will receive the initial flags of the virtual session. + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + gotFlags := false + var receivedMessages []*api.ServerMessage + for !gotFlags { + messages, err := client2.GetPendingMessages(ctx) + if err != nil { + assert.NoError(err) + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + break + } + } + + receivedMessages = append(receivedMessages, messages...) + for _, msg := range messages { + if msg.Type != "event" || msg.Event.Target != "participants" || msg.Event.Type != "flags" { + continue + } + + if assert.Equal(roomId, msg.Event.Flags.RoomId) && + assert.Equal(sessionId, msg.Event.Flags.SessionId) && + assert.Equal(newFlags, msg.Event.Flags.Flags) { + gotFlags = true + break + } + } + } + assert.True(gotFlags, "Didn't receive initial flags in %+v", receivedMessages) + + // Ignore "join" messages from second client + client.RunUntilJoined(ctx, hello2.Hello) + + // When sending to a virtual session, the message is sent to the actual + // client and contains a "Recipient" block with the internal session id. + recipient := api.MessageClientMessageRecipient{ + Type: "session", + SessionId: sessionId, + } + + data := "from-client-to-virtual" + require.NoError(client.SendMessage(recipient, data)) + + msg2 = MustSucceed1(t, clientInternal.RunUntilMessage, ctx) + require.True(checkMessageType(t, msg2, "message")) + require.True(checkMessageSender(t, hub, msg2.Message.Sender, "session", hello.Hello)) + + if assert.NotNil(msg2.Message.Recipient) { + assert.Equal("session", msg2.Message.Recipient.Type) + assert.Equal(internalSessionId, msg2.Message.Recipient.SessionId) + } + + var payload string + if err := json.Unmarshal(msg2.Message.Data, &payload); assert.NoError(err) { + assert.Equal(data, payload) + } + + msgRemove := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "removesession", + RemoveSession: &api.RemoveSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ + SessionId: internalSessionId, + RoomId: roomId, + }, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgRemove)) + + if msg5, ok := client.RunUntilMessage(ctx); ok { + client.checkMessageRoomLeaveSession(msg5, sessionId) + } +} + +func TestVirtualSessionActorInformation(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + + roomId := "the-room-id" + emptyProperties := json.RawMessage("{}") + backend := talk.NewCompatBackend(nil) + room, err := hub.CreateRoom(roomId, emptyProperties, backend) + require.NoError(err) + defer room.Close() + + clientInternal := NewTestClient(t, server, hub) + defer clientInternal.CloseWithBye() + require.NoError(clientInternal.SendHelloInternal()) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + if hello, ok := clientInternal.RunUntilHello(ctx); ok { + assert.Empty(hello.Hello.UserId) + assert.NotEmpty(hello.Hello.SessionId) + assert.NotEmpty(hello.Hello.ResumeId) + } + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + // Ignore "join" events. + client.RunUntilJoined(ctx, hello.Hello) + + internalSessionId := api.PublicSessionId("session1") + userId := "user1" + msgAdd := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "addsession", + AddSession: &api.AddSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ + SessionId: internalSessionId, + RoomId: roomId, + }, + UserId: userId, + Flags: FLAG_MUTED_SPEAKING, + Options: &api.AddSessionOptions{ + ActorId: "actor-id", + ActorType: "actor-type", + }, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgAdd)) + + msg1 := MustSucceed1(t, client.RunUntilMessage, ctx) + // The public session id will be generated by the server, so don't check for it. + require.True(client.checkMessageJoinedSession(msg1, "", userId)) + sessionId := msg1.Event.Join[0].SessionId + session := hub.GetSessionByPublicId(sessionId) + if assert.NotNil(session, "Could not get virtual session %s", sessionId) { + assert.Equal(api.HelloClientTypeVirtual, session.ClientType()) + sid := session.(*VirtualSession).SessionId() + assert.Equal(internalSessionId, sid) + } + + // Also a participants update event will be triggered for the virtual user. + msg2 := MustSucceed1(t, client.RunUntilMessage, ctx) + if updateMsg, ok := checkMessageParticipantsInCall(t, msg2); ok { + assert.Equal(roomId, updateMsg.RoomId) + if assert.Len(updateMsg.Users, 1) { + assert.EqualValues(sessionId, updateMsg.Users[0]["sessionId"]) + assert.Equal(true, updateMsg.Users[0]["virtual"]) + assert.EqualValues((FlagInCall | FlagWithPhone), updateMsg.Users[0]["inCall"]) + } + } + + msg3 := MustSucceed1(t, client.RunUntilMessage, ctx) + if flagsMsg, ok := checkMessageParticipantFlags(t, msg3); ok { + assert.Equal(roomId, flagsMsg.RoomId) + assert.Equal(sessionId, flagsMsg.SessionId) + assert.EqualValues(FLAG_MUTED_SPEAKING, flagsMsg.Flags) + } + + newFlags := uint32(FLAG_TALKING) + msgFlags := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "updatesession", + UpdateSession: &api.UpdateSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ + SessionId: internalSessionId, + RoomId: roomId, + }, + Flags: &newFlags, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgFlags)) + + msg4 := MustSucceed1(t, client.RunUntilMessage, ctx) + if flagsMsg, ok := checkMessageParticipantFlags(t, msg4); ok { + assert.Equal(roomId, flagsMsg.RoomId) + assert.Equal(sessionId, flagsMsg.SessionId) + assert.Equal(newFlags, flagsMsg.Flags) + } + + // A new client will receive the initial flags of the virtual session. + client2, hello2 := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId+"2") + roomMsg = MustSucceed2(t, client2.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + gotFlags := false + var receivedMessages []*api.ServerMessage + for !gotFlags { + messages, err := client2.GetPendingMessages(ctx) + if err != nil { + assert.NoError(err) + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + break + } + } + + receivedMessages = append(receivedMessages, messages...) + for _, msg := range messages { + if msg.Type != "event" || msg.Event.Target != "participants" || msg.Event.Type != "flags" { + continue + } + + if assert.Equal(roomId, msg.Event.Flags.RoomId) && + assert.Equal(sessionId, msg.Event.Flags.SessionId) && + assert.Equal(newFlags, msg.Event.Flags.Flags) { + gotFlags = true + break + } + } + } + assert.True(gotFlags, "Didn't receive initial flags in %+v", receivedMessages) + + // Ignore "join" messages from second client + client.RunUntilJoined(ctx, hello2.Hello) + + // When sending to a virtual session, the message is sent to the actual + // client and contains a "Recipient" block with the internal session id. + recipient := api.MessageClientMessageRecipient{ + Type: "session", + SessionId: sessionId, + } + + data := "from-client-to-virtual" + require.NoError(client.SendMessage(recipient, data)) + + msg2 = MustSucceed1(t, clientInternal.RunUntilMessage, ctx) + require.True(checkMessageType(t, msg2, "message")) + require.True(checkMessageSender(t, hub, msg2.Message.Sender, "session", hello.Hello)) + + if assert.NotNil(msg2.Message.Recipient) { + assert.Equal("session", msg2.Message.Recipient.Type) + assert.Equal(internalSessionId, msg2.Message.Recipient.SessionId) + } + + var payload string + if err := json.Unmarshal(msg2.Message.Data, &payload); assert.NoError(err) { + assert.Equal(data, payload) + } + + msgRemove := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "removesession", + RemoveSession: &api.RemoveSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ + SessionId: internalSessionId, + RoomId: roomId, + }, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgRemove)) + + if msg5, ok := client.RunUntilMessage(ctx); ok { + client.checkMessageRoomLeaveSession(msg5, sessionId) + } +} + +func checkHasEntryWithInCall(t *testing.T, message *api.RoomEventServerMessage, sessionId api.PublicSessionId, entryType string, inCall int) bool { + assert := assert.New(t) + found := false + for _, entry := range message.Users { + if sid, ok := api.GetStringMapString[api.PublicSessionId](entry, "sessionId"); ok && sid == sessionId { + if value, found := api.GetStringMapEntry[bool](entry, entryType); !assert.True(found, "entry %s not found or invalid in %+v", entryType, entry) || + !assert.True(value, "entry %s invalid in %+v", entryType, entry) { + return false + } + + if value, found := api.GetStringMapEntry[float64](entry, "inCall"); !assert.True(found, "inCall not found or invalid in %+v", entry) || + !assert.InDelta(value, inCall, 0.0001, "invalid inCall") { + return false + } + found = true + break + } + } + + return assert.True(found, "no user with session id %s found, got %+v", sessionId, message) +} + +func TestVirtualSessionCustomInCall(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + + roomId := "the-room-id" + emptyProperties := json.RawMessage("{}") + backend := talk.NewCompatBackend(nil) + room, err := hub.CreateRoom(roomId, emptyProperties, backend) + require.NoError(err) + defer room.Close() + + clientInternal := NewTestClient(t, server, hub) + defer clientInternal.CloseWithBye() + features := []string{ + api.ClientFeatureInternalInCall, + } + require.NoError(clientInternal.SendHelloInternalWithFeatures(features)) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + helloInternal, ok := clientInternal.RunUntilHello(ctx) + if ok { + assert.Empty(helloInternal.Hello.UserId) + assert.NotEmpty(helloInternal.Hello.SessionId) + assert.NotEmpty(helloInternal.Hello.ResumeId) + } + roomMsg := MustSucceed3(t, clientInternal.JoinRoomWithRoomSession, ctx, roomId, "") + require.Equal(roomId, roomMsg.Room.RoomId) + + roomMsg = MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + // In some cases, the participants update event is triggered a bit after the joined + // event. If this happens, the "client" will also receive an additional update + // event after the joined of the internal client. + var expectUpdate bool + if _, additional, ok := clientInternal.RunUntilJoinedAndReturn(ctx, helloInternal.Hello, hello.Hello); ok { + if len(additional) == 0 { + if msg, ok := clientInternal.RunUntilMessage(ctx); ok { + additional = append(additional, msg) + } + expectUpdate = true + } + if assert.Len(additional, 1) && assert.Equal("event", additional[0].Type) { + assert.Equal("participants", additional[0].Event.Target) + assert.Equal("update", additional[0].Event.Type) + assert.EqualValues(helloInternal.Hello.SessionId, additional[0].Event.Update.Users[0]["sessionId"]) + assert.EqualValues(0, additional[0].Event.Update.Users[0]["inCall"]) + } + } + if _, additional, ok := client.RunUntilJoinedAndReturn(ctx, helloInternal.Hello, hello.Hello); ok { + if expectUpdate { + if len(additional) == 0 { + if msg, ok := client.RunUntilMessage(ctx); ok { + additional = append(additional, msg) + } + } + + if assert.Len(additional, 1) && assert.Equal("event", additional[0].Type) { + assert.Equal("participants", additional[0].Event.Target) + assert.Equal("update", additional[0].Event.Type) + assert.EqualValues(helloInternal.Hello.SessionId, additional[0].Event.Update.Users[0]["sessionId"]) + assert.EqualValues(0, additional[0].Event.Update.Users[0]["inCall"]) + } + } + } + + internalSessionId := api.PublicSessionId("session1") + userId := "user1" + msgAdd := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "addsession", + AddSession: &api.AddSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ + SessionId: internalSessionId, + RoomId: roomId, + }, + UserId: userId, + Flags: FLAG_MUTED_SPEAKING, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgAdd)) + + msg1 := MustSucceed1(t, client.RunUntilMessage, ctx) + // The public session id will be generated by the server, so don't check for it. + require.True(client.checkMessageJoinedSession(msg1, "", userId)) + sessionId := msg1.Event.Join[0].SessionId + session := hub.GetSessionByPublicId(sessionId) + if assert.NotNil(session) { + assert.Equal(api.HelloClientTypeVirtual, session.ClientType()) + sid := session.(*VirtualSession).SessionId() + assert.Equal(internalSessionId, sid) + } + + // Also a participants update event will be triggered for the virtual user. + msg2 := MustSucceed1(t, client.RunUntilMessage, ctx) + if updateMsg, ok := checkMessageParticipantsInCall(t, msg2); ok { + assert.Equal(roomId, updateMsg.RoomId) + assert.Len(updateMsg.Users, 2) + + checkHasEntryWithInCall(t, updateMsg, sessionId, "virtual", 0) + checkHasEntryWithInCall(t, updateMsg, helloInternal.Hello.SessionId, "internal", 0) + } + + msg3 := MustSucceed1(t, client.RunUntilMessage, ctx) + if flagsMsg, ok := checkMessageParticipantFlags(t, msg3); ok { + assert.Equal(roomId, flagsMsg.RoomId) + assert.Equal(sessionId, flagsMsg.SessionId) + assert.EqualValues(FLAG_MUTED_SPEAKING, flagsMsg.Flags) + } + + // The internal session can change its "inCall" flags + msgInCall := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "incall", + InCall: &api.InCallInternalClientMessage{ + InCall: FlagInCall | FlagWithAudio, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgInCall)) + + msg4 := MustSucceed1(t, client.RunUntilMessage, ctx) + if updateMsg, ok := checkMessageParticipantsInCall(t, msg4); ok { + assert.Equal(roomId, updateMsg.RoomId) + assert.Len(updateMsg.Users, 2) + checkHasEntryWithInCall(t, updateMsg, sessionId, "virtual", 0) + checkHasEntryWithInCall(t, updateMsg, helloInternal.Hello.SessionId, "internal", FlagInCall|FlagWithAudio) + } + + // The internal session can change the "inCall" flags of a virtual session + newInCall := FlagInCall | FlagWithPhone + msgInCall2 := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "updatesession", + UpdateSession: &api.UpdateSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ + SessionId: internalSessionId, + RoomId: roomId, + }, + InCall: &newInCall, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgInCall2)) + + msg5 := MustSucceed1(t, client.RunUntilMessage, ctx) + if updateMsg, ok := checkMessageParticipantsInCall(t, msg5); ok { + assert.Equal(roomId, updateMsg.RoomId) + assert.Len(updateMsg.Users, 2) + checkHasEntryWithInCall(t, updateMsg, sessionId, "virtual", newInCall) + checkHasEntryWithInCall(t, updateMsg, helloInternal.Hello.SessionId, "internal", FlagInCall|FlagWithAudio) + } + + newInCall2 := FlagDisconnected + msgInCall3 := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "updatesession", + UpdateSession: &api.UpdateSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ + SessionId: internalSessionId, + RoomId: roomId, + }, + InCall: &newInCall2, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgInCall3)) + + msg6 := MustSucceed1(t, client.RunUntilMessage, ctx) + if updateMsg, ok := checkMessageParticipantsInCall(t, msg6); ok { + assert.Equal(roomId, updateMsg.RoomId) + assert.Len(updateMsg.Users, 2) + checkHasEntryWithInCall(t, updateMsg, sessionId, "virtual", newInCall2) + checkHasEntryWithInCall(t, updateMsg, helloInternal.Hello.SessionId, "internal", FlagInCall|FlagWithAudio) + } +} + +func TestVirtualSessionCleanup(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + hub, _, _, server := CreateHubForTest(t) + + roomId := "the-room-id" + emptyProperties := json.RawMessage("{}") + backend := talk.NewCompatBackend(nil) + room, err := hub.CreateRoom(roomId, emptyProperties, backend) + require.NoError(err) + defer room.Close() + + clientInternal := NewTestClient(t, server, hub) + defer clientInternal.CloseWithBye() + require.NoError(clientInternal.SendHelloInternal()) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + if hello, ok := clientInternal.RunUntilHello(ctx); ok { + assert.Empty(hello.Hello.UserId) + assert.NotEmpty(hello.Hello.SessionId) + assert.NotEmpty(hello.Hello.ResumeId) + } + client, hello := NewTestClientWithHello(ctx, t, server, hub, testDefaultUserId) + + roomMsg := MustSucceed2(t, client.JoinRoom, ctx, roomId) + require.Equal(roomId, roomMsg.Room.RoomId) + + // Ignore "join" events. + client.RunUntilJoined(ctx, hello.Hello) + + internalSessionId := api.PublicSessionId("session1") + userId := "user1" + msgAdd := &api.ClientMessage{ + Type: "internal", + Internal: &api.InternalClientMessage{ + Type: "addsession", + AddSession: &api.AddSessionInternalClientMessage{ + CommonSessionInternalClientMessage: api.CommonSessionInternalClientMessage{ + SessionId: internalSessionId, + RoomId: roomId, + }, + UserId: userId, + Flags: FLAG_MUTED_SPEAKING, + }, + }, + } + require.NoError(clientInternal.WriteJSON(msgAdd)) + + msg1 := MustSucceed1(t, client.RunUntilMessage, ctx) + // The public session id will be generated by the server, so don't check for it. + require.True(client.checkMessageJoinedSession(msg1, "", userId)) + sessionId := msg1.Event.Join[0].SessionId + session := hub.GetSessionByPublicId(sessionId) + if assert.NotNil(session) { + assert.Equal(api.HelloClientTypeVirtual, session.ClientType()) + sid := session.(*VirtualSession).SessionId() + assert.Equal(internalSessionId, sid) + } + + // Also a participants update event will be triggered for the virtual user. + msg2 := MustSucceed1(t, client.RunUntilMessage, ctx) + if updateMsg, ok := checkMessageParticipantsInCall(t, msg2); ok { + assert.Equal(roomId, updateMsg.RoomId) + if assert.Len(updateMsg.Users, 1) { + assert.EqualValues(sessionId, updateMsg.Users[0]["sessionId"]) + assert.Equal(true, updateMsg.Users[0]["virtual"]) + assert.EqualValues((FlagInCall | FlagWithPhone), updateMsg.Users[0]["inCall"]) + } + } + + msg3 := MustSucceed1(t, client.RunUntilMessage, ctx) + if flagsMsg, ok := checkMessageParticipantFlags(t, msg3); ok { + assert.Equal(roomId, flagsMsg.RoomId) + assert.Equal(sessionId, flagsMsg.SessionId) + assert.EqualValues(FLAG_MUTED_SPEAKING, flagsMsg.Flags) + } + + // The virtual sessions are closed when the parent session is deleted. + clientInternal.CloseWithBye() + + if msg2, ok := client.RunUntilMessage(ctx); ok { + client.checkMessageRoomLeaveSession(msg2, sessionId) + } +} diff --git a/session.pb.go b/session.pb.go deleted file mode 100644 index f875cbf..0000000 --- a/session.pb.go +++ /dev/null @@ -1,172 +0,0 @@ -//* -// Standalone signaling server for the Nextcloud Spreed app. -// Copyright (C) 2024 struktur AG -// -// @author Joachim Bauch -// -// @license GNU AGPL version 3 or any later version -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -// Code generated by protoc-gen-go. DO NOT EDIT. -// source: session.proto - -package signaling - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type SessionIdData struct { - state protoimpl.MessageState `protogen:"open.v1"` - Sid uint64 `protobuf:"varint,1,opt,name=Sid,proto3" json:"Sid,omitempty"` - Created *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=Created,proto3" json:"Created,omitempty"` - BackendId string `protobuf:"bytes,3,opt,name=BackendId,proto3" json:"BackendId,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SessionIdData) Reset() { - *x = SessionIdData{} - mi := &file_session_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SessionIdData) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SessionIdData) ProtoMessage() {} - -func (x *SessionIdData) ProtoReflect() protoreflect.Message { - mi := &file_session_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SessionIdData.ProtoReflect.Descriptor instead. -func (*SessionIdData) Descriptor() ([]byte, []int) { - return file_session_proto_rawDescGZIP(), []int{0} -} - -func (x *SessionIdData) GetSid() uint64 { - if x != nil { - return x.Sid - } - return 0 -} - -func (x *SessionIdData) GetCreated() *timestamppb.Timestamp { - if x != nil { - return x.Created - } - return nil -} - -func (x *SessionIdData) GetBackendId() string { - if x != nil { - return x.BackendId - } - return "" -} - -var File_session_proto protoreflect.FileDescriptor - -var file_session_proto_rawDesc = []byte{ - 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, - 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, - 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x75, 0x0a, 0x0d, 0x53, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x44, 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, - 0x53, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, 0x53, 0x69, 0x64, 0x12, 0x34, - 0x0a, 0x07, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, - 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, - 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, - 0x49, 0x64, 0x42, 0x3c, 0x5a, 0x3a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, - 0x2f, 0x73, 0x74, 0x72, 0x75, 0x6b, 0x74, 0x75, 0x72, 0x61, 0x67, 0x2f, 0x6e, 0x65, 0x78, 0x74, - 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x70, 0x72, 0x65, 0x65, 0x64, 0x2d, 0x73, 0x69, 0x67, - 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0x3b, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x69, 0x6e, 0x67, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} - -var ( - file_session_proto_rawDescOnce sync.Once - file_session_proto_rawDescData = file_session_proto_rawDesc -) - -func file_session_proto_rawDescGZIP() []byte { - file_session_proto_rawDescOnce.Do(func() { - file_session_proto_rawDescData = protoimpl.X.CompressGZIP(file_session_proto_rawDescData) - }) - return file_session_proto_rawDescData -} - -var file_session_proto_msgTypes = make([]protoimpl.MessageInfo, 1) -var file_session_proto_goTypes = []any{ - (*SessionIdData)(nil), // 0: signaling.SessionIdData - (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp -} -var file_session_proto_depIdxs = []int32{ - 1, // 0: signaling.SessionIdData.Created:type_name -> google.protobuf.Timestamp - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name -} - -func init() { file_session_proto_init() } -func file_session_proto_init() { - if File_session_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_session_proto_rawDesc, - NumEnums: 0, - NumMessages: 1, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_session_proto_goTypes, - DependencyIndexes: file_session_proto_depIdxs, - MessageInfos: file_session_proto_msgTypes, - }.Build() - File_session_proto = out.File - file_session_proto_rawDesc = nil - file_session_proto_goTypes = nil - file_session_proto_depIdxs = nil -} diff --git a/session/session.pb.go b/session/session.pb.go new file mode 100644 index 0000000..0bf63ee --- /dev/null +++ b/session/session.pb.go @@ -0,0 +1,158 @@ +//* +// Standalone signaling server for the Nextcloud Spreed app. +// Copyright (C) 2024 struktur AG +// +// @author Joachim Bauch +// +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: session/session.proto + +package session + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SessionIdData struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sid uint64 `protobuf:"varint,1,opt,name=Sid,proto3" json:"Sid,omitempty"` + Created int64 `protobuf:"varint,2,opt,name=Created,proto3" json:"Created,omitempty"` + BackendId string `protobuf:"bytes,3,opt,name=BackendId,proto3" json:"BackendId,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionIdData) Reset() { + *x = SessionIdData{} + mi := &file_session_session_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionIdData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionIdData) ProtoMessage() {} + +func (x *SessionIdData) ProtoReflect() protoreflect.Message { + mi := &file_session_session_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionIdData.ProtoReflect.Descriptor instead. +func (*SessionIdData) Descriptor() ([]byte, []int) { + return file_session_session_proto_rawDescGZIP(), []int{0} +} + +func (x *SessionIdData) GetSid() uint64 { + if x != nil { + return x.Sid + } + return 0 +} + +func (x *SessionIdData) GetCreated() int64 { + if x != nil { + return x.Created + } + return 0 +} + +func (x *SessionIdData) GetBackendId() string { + if x != nil { + return x.BackendId + } + return "" +} + +var File_session_session_proto protoreflect.FileDescriptor + +const file_session_session_proto_rawDesc = "" + + "\n" + + "\x15session/session.proto\x12\asession\"Y\n" + + "\rSessionIdData\x12\x10\n" + + "\x03Sid\x18\x01 \x01(\x04R\x03Sid\x12\x18\n" + + "\aCreated\x18\x02 \x01(\x03R\aCreated\x12\x1c\n" + + "\tBackendId\x18\x03 \x01(\tR\tBackendIdB:Z8github.com/strukturag/nextcloud-spreed-signaling/sessionb\x06proto3" + +var ( + file_session_session_proto_rawDescOnce sync.Once + file_session_session_proto_rawDescData []byte +) + +func file_session_session_proto_rawDescGZIP() []byte { + file_session_session_proto_rawDescOnce.Do(func() { + file_session_session_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_session_session_proto_rawDesc), len(file_session_session_proto_rawDesc))) + }) + return file_session_session_proto_rawDescData +} + +var file_session_session_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_session_session_proto_goTypes = []any{ + (*SessionIdData)(nil), // 0: session.SessionIdData +} +var file_session_session_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_session_session_proto_init() } +func file_session_session_proto_init() { + if File_session_session_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_session_session_proto_rawDesc), len(file_session_session_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_session_session_proto_goTypes, + DependencyIndexes: file_session_session_proto_depIdxs, + MessageInfos: file_session_session_proto_msgTypes, + }.Build() + File_session_session_proto = out.File + file_session_session_proto_goTypes = nil + file_session_session_proto_depIdxs = nil +} diff --git a/session.proto b/session/session.proto similarity index 89% rename from session.proto rename to session/session.proto index 4b1cc71..6f976b2 100644 --- a/session.proto +++ b/session/session.proto @@ -21,14 +21,12 @@ */ syntax = "proto3"; -import "google/protobuf/timestamp.proto"; +option go_package = "github.com/strukturag/nextcloud-spreed-signaling/session"; -option go_package = "github.com/strukturag/nextcloud-spreed-signaling;signaling"; - -package signaling; +package session; message SessionIdData { uint64 Sid = 1; - google.protobuf.Timestamp Created = 2; + int64 Created = 2; string BackendId = 3; } diff --git a/session/sessionid_codec.go b/session/sessionid_codec.go new file mode 100644 index 0000000..3e2c576 --- /dev/null +++ b/session/sessionid_codec.go @@ -0,0 +1,299 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2024 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package session + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "hash" + "io" + "sync" + "unsafe" + + "google.golang.org/protobuf/proto" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" +) + +const ( + privateSessionName = "private-session" + publicSessionName = "public-session" + + // hmacLength specifies the length of the HMAC to use. 80 bits should be enough + // to prevent tampering. + hmacLength = 10 +) + +var ( + sessionHashFunc = sha256.New + sessionEncoding = base64.URLEncoding.WithPadding(base64.NoPadding) + sessionMarshalOptions = proto.MarshalOptions{ + UseCachedSize: true, + } + sessionUnmarshalOptions = proto.UnmarshalOptions{} + sessionSeparator = []byte{'|'} +) + +type bytesPool struct { + pool sync.Pool +} + +func (p *bytesPool) Get(size int) []byte { + bb := p.pool.Get() + if bb == nil { + return make([]byte, size) + } + + b := *(bb.(*[]byte)) + if cap(b) < size { + b = make([]byte, size) + } else { + b = b[:size] + } + return b +} + +func (p *bytesPool) Put(b []byte) { + p.pool.Put(&b) +} + +// SessionIdCodec encodes and decodes session ids. +// +// Inspired by https://github.com/gorilla/securecookie +type SessionIdCodec struct { // nolint + hashKey []byte + cipher cipher.Block + + bytesPool bytesPool + hmacPool sync.Pool + dataPool sync.Pool +} + +func NewSessionIdCodec(hashKey []byte, blockKey []byte) (*SessionIdCodec, error) { + if len(hashKey) == 0 { + return nil, errors.New("hash key is not set") + } + + codec := &SessionIdCodec{ + hashKey: hashKey, + hmacPool: sync.Pool{ + New: func() any { + return hmac.New(sessionHashFunc, hashKey) + }, + }, + dataPool: sync.Pool{ + New: func() any { + return &SessionIdData{} + }, + }, + } + if len(blockKey) > 0 { + block, err := aes.NewCipher(blockKey) + if err != nil { + return nil, fmt.Errorf("error creating cipher: %w", err) + } + codec.cipher = block + } + return codec, nil +} + +func (c *SessionIdCodec) encrypt(data []byte) ([]byte, error) { + iv := c.bytesPool.Get(c.cipher.BlockSize() + len(data))[:c.cipher.BlockSize()] + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return nil, fmt.Errorf("error creating iv: %w", err) + } + + ctr := cipher.NewCTR(c.cipher, iv) + ctr.XORKeyStream(data, data) + return append(iv, data...), nil +} + +func (c *SessionIdCodec) decrypt(data []byte) ([]byte, error) { + bs := c.cipher.BlockSize() + if len(data) <= bs { + return nil, errors.New("no iv found in data") + } + + iv := data[:bs] + data = data[bs:] + ctr := cipher.NewCTR(c.cipher, iv) + ctr.XORKeyStream(data, data) + return data, nil +} + +func (c *SessionIdCodec) encodeToString(b []byte) string { + s := c.bytesPool.Get(sessionEncoding.EncodedLen(len(b))) + defer c.bytesPool.Put(s) + + sessionEncoding.Encode(s, b) + return string(s) +} + +func (c *SessionIdCodec) decodeFromString(s string) ([]byte, error) { + b := c.bytesPool.Get(sessionEncoding.DecodedLen(len(s))) + n, err := sessionEncoding.Decode(b, []byte(s)) + if err != nil { + c.bytesPool.Put(b) + return nil, err + } + + return b[:n], nil +} + +func (c *SessionIdCodec) encodeRaw(name string, data *SessionIdData) ([]byte, error) { + body := c.bytesPool.Get(sessionMarshalOptions.Size(data)) + defer c.bytesPool.Put(body) + + body, err := sessionMarshalOptions.MarshalAppend(body[:0], data) + if err != nil { + return nil, fmt.Errorf("error marshaling data: %w", err) + } + + if c.cipher != nil { + body, err = c.encrypt(body) + if err != nil { + return nil, fmt.Errorf("error encrypting data: %w", err) + } + + defer c.bytesPool.Put(body) + } + + h := c.hmacPool.Get().(hash.Hash) + defer c.hmacPool.Put(h) + h.Reset() + h.Write(unsafe.Slice(unsafe.StringData(name), len(name))) // nolint + h.Write(sessionSeparator) // nolint + h.Write(body) // nolint + mac := c.bytesPool.Get(h.Size()) + defer c.bytesPool.Put(mac) + mac = h.Sum(mac[:0]) + + result := c.bytesPool.Get(len(body) + hmacLength)[:0] + result = append(result, body...) + result = append(result, mac[:hmacLength]...) + return result, nil +} + +func (c *SessionIdCodec) decodeRaw(name string, value []byte) (*SessionIdData, error) { + h := c.hmacPool.Get().(hash.Hash) + defer c.hmacPool.Put(h) + size := min(hmacLength, h.Size()) + if len(value) <= size { + return nil, errors.New("no hmac found in session id") + } + + h.Reset() + mac := value[len(value)-size:] + decoded := value[:len(value)-size] + + h.Write(unsafe.Slice(unsafe.StringData(name), len(name))) // nolint + h.Write(sessionSeparator) // nolint + h.Write(decoded) // nolint + check := c.bytesPool.Get(h.Size()) + defer c.bytesPool.Put(check) + if subtle.ConstantTimeCompare(mac, h.Sum(check[:0])[:hmacLength]) == 0 { + return nil, errors.New("invalid hmac in session id") + } + + if c.cipher != nil { + var err error + if decoded, err = c.decrypt(decoded); err != nil { + return nil, fmt.Errorf("invalid session id: %w", err) + } + } + + data := c.dataPool.Get().(*SessionIdData) + if err := sessionUnmarshalOptions.Unmarshal(decoded, data); err != nil { + c.dataPool.Put(data) + return nil, fmt.Errorf("invalid session id: %w", err) + } + + return data, nil +} + +func (c *SessionIdCodec) EncodePrivate(sessionData *SessionIdData) (api.PrivateSessionId, error) { + id, err := c.encodeRaw(privateSessionName, sessionData) + if err != nil { + return "", err + } + + defer c.bytesPool.Put(id) + return api.PrivateSessionId(c.encodeToString(id)), nil +} + +func (c *SessionIdCodec) reverseSessionId(data []byte) { + for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 { + data[i], data[j] = data[j], data[i] + } +} + +func (c *SessionIdCodec) EncodePublic(sessionData *SessionIdData) (api.PublicSessionId, error) { + encoded, err := c.encodeRaw(publicSessionName, sessionData) + if err != nil { + return "", err + } + + // We are reversing the public session ids because clients compare them + // to decide who calls whom. The prefix of the session id is increasing + // (a timestamp) but the suffix the (random) hash. + // By reversing we move the hash to the front, making the comparison of + // session ids "random". + c.reverseSessionId(encoded) + + defer c.bytesPool.Put(encoded) + return api.PublicSessionId(c.encodeToString(encoded)), nil +} + +func (c *SessionIdCodec) DecodePrivate(encodedData api.PrivateSessionId) (*SessionIdData, error) { + decoded, err := c.decodeFromString(string(encodedData)) + if err != nil { + return nil, fmt.Errorf("invalid session id: %w", err) + } + defer c.bytesPool.Put(decoded) + + return c.decodeRaw(privateSessionName, decoded) +} + +func (c *SessionIdCodec) DecodePublic(encodedData api.PublicSessionId) (*SessionIdData, error) { + decoded, err := c.decodeFromString(string(encodedData)) + if err != nil { + return nil, fmt.Errorf("invalid session id: %w", err) + } + defer c.bytesPool.Put(decoded) + + c.reverseSessionId(decoded) + return c.decodeRaw(publicSessionName, decoded) +} + +func (c *SessionIdCodec) Put(data *SessionIdData) { + if data != nil { + data.Reset() + c.dataPool.Put(data) + } +} diff --git a/session/sessionid_codec_test.go b/session/sessionid_codec_test.go new file mode 100644 index 0000000..3e5fb4b --- /dev/null +++ b/session/sessionid_codec_test.go @@ -0,0 +1,164 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2024 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package session + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +func TestReverseSessionId(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + codec, err := NewSessionIdCodec([]byte("12345678901234567890123456789012"), []byte("09876543210987654321098765432109")) + require.NoError(err) + a := []byte("12345") + codec.reverseSessionId(a) + assert.Equal([]byte("54321"), a) + b := []byte("4321") + codec.reverseSessionId(b) + assert.Equal([]byte("1234"), b) +} + +func Benchmark_EncodePrivateSessionId(b *testing.B) { + require := require.New(b) + backend := talk.NewCompatBackend(nil) + data := &SessionIdData{ + Sid: 1, + Created: time.Now().UnixMicro(), + BackendId: backend.Id(), + } + codec, err := NewSessionIdCodec([]byte("12345678901234567890123456789012"), []byte("09876543210987654321098765432109")) + require.NoError(err) + for b.Loop() { + if _, err := codec.EncodePrivate(data); err != nil { + b.Fatal(err) + } + } +} + +func Benchmark_DecodePrivateSessionId(b *testing.B) { + require := require.New(b) + backend := talk.NewCompatBackend(nil) + data := &SessionIdData{ + Sid: 1, + Created: time.Now().UnixMicro(), + BackendId: backend.Id(), + } + codec, err := NewSessionIdCodec([]byte("12345678901234567890123456789012"), []byte("09876543210987654321098765432109")) + require.NoError(err) + sid, err := codec.EncodePrivate(data) + require.NoError(err) + for b.Loop() { + if decoded, err := codec.DecodePrivate(sid); err != nil { + b.Fatal(err) + } else { + codec.Put(decoded) + } + } +} + +func Benchmark_EncodePublicSessionId(b *testing.B) { + require := require.New(b) + backend := talk.NewCompatBackend(nil) + data := &SessionIdData{ + Sid: 1, + Created: time.Now().UnixMicro(), + BackendId: backend.Id(), + } + codec, err := NewSessionIdCodec([]byte("12345678901234567890123456789012"), []byte("09876543210987654321098765432109")) + require.NoError(err) + for b.Loop() { + if _, err := codec.EncodePublic(data); err != nil { + b.Fatal(err) + } + } +} + +func Benchmark_DecodePublicSessionId(b *testing.B) { + require := require.New(b) + backend := talk.NewCompatBackend(nil) + data := &SessionIdData{ + Sid: 1, + Created: time.Now().UnixMicro(), + BackendId: backend.Id(), + } + codec, err := NewSessionIdCodec([]byte("12345678901234567890123456789012"), []byte("09876543210987654321098765432109")) + require.NoError(err) + sid, err := codec.EncodePublic(data) + require.NoError(err) + for b.Loop() { + if decoded, err := codec.DecodePublic(sid); err != nil { + b.Fatal(err) + } else { + codec.Put(decoded) + } + } +} + +func TestPublicPrivate(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + sd := &SessionIdData{ + Sid: 1, + Created: time.Now().UnixMicro(), + BackendId: "foo", + } + + codec, err := NewSessionIdCodec([]byte("0123456789012345"), []byte("0123456789012345")) + require.NoError(err) + private, err := codec.EncodePrivate(sd) + require.NoError(err) + public, err := codec.EncodePublic(sd) + require.NoError(err) + assert.NotEqual(private, public) + + if data, err := codec.DecodePublic(public); assert.NoError(err) { + assert.Equal(sd.Sid, data.Sid) + assert.Equal(sd.Created, data.Created) + assert.Equal(sd.BackendId, data.BackendId) + codec.Put(data) + } + if data, err := codec.DecodePrivate(private); assert.NoError(err) { + assert.Equal(sd.Sid, data.Sid) + assert.Equal(sd.Created, data.Created) + assert.Equal(sd.BackendId, data.BackendId) + codec.Put(data) + } + + if data, err := codec.DecodePublic(api.PublicSessionId(private)); !assert.Error(err) { + assert.Fail("should have failed", "received %+v", data) + codec.Put(data) + } + if data, err := codec.DecodePrivate(api.PrivateSessionId(public)); !assert.Error(err) { + assert.Fail("should have failed", "received %+v", data) + codec.Put(data) + } +} diff --git a/sessionid_codec.go b/sessionid_codec.go deleted file mode 100644 index 81de6a8..0000000 --- a/sessionid_codec.go +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2024 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "encoding/base64" - "fmt" - - "github.com/gorilla/securecookie" - "google.golang.org/protobuf/proto" -) - -type protoSerializer struct { -} - -func (s *protoSerializer) Serialize(src interface{}) ([]byte, error) { - msg, ok := src.(proto.Message) - if !ok { - return nil, fmt.Errorf("can't serialize type %T", src) - } - return proto.Marshal(msg) -} - -func (s *protoSerializer) Deserialize(src []byte, dst interface{}) error { - msg, ok := dst.(proto.Message) - if !ok { - return fmt.Errorf("can't deserialize type %T", src) - } - return proto.Unmarshal(src, msg) -} - -const ( - privateSessionName = "private-session" - publicSessionName = "public-session" -) - -type SessionIdCodec struct { - cookie *securecookie.SecureCookie -} - -func NewSessionIdCodec(hashKey []byte, blockKey []byte) *SessionIdCodec { - cookie := securecookie.New(hashKey, blockKey). - MaxAge(0). - SetSerializer(&protoSerializer{}) - return &SessionIdCodec{ - cookie: cookie, - } -} - -func (c *SessionIdCodec) EncodePrivate(sessionData *SessionIdData) (string, error) { - return c.cookie.Encode(privateSessionName, sessionData) -} - -func reverseSessionId(s string) (string, error) { - // Note that we are assuming base64 encoded strings here. - decoded, err := base64.URLEncoding.DecodeString(s) - if err != nil { - return "", err - } - - for i, j := 0, len(decoded)-1; i < j; i, j = i+1, j-1 { - decoded[i], decoded[j] = decoded[j], decoded[i] - } - return base64.URLEncoding.EncodeToString(decoded), nil -} - -func (c *SessionIdCodec) EncodePublic(sessionData *SessionIdData) (string, error) { - encoded, err := c.cookie.Encode(publicSessionName, sessionData) - if err != nil { - return "", err - } - - // We are reversing the public session ids because clients compare them - // to decide who calls whom. The prefix of the session id is increasing - // (a timestamp) but the suffix the (random) hash. - // By reversing we move the hash to the front, making the comparison of - // session ids "random". - return reverseSessionId(encoded) -} - -func (c *SessionIdCodec) DecodePrivate(encodedData string) (*SessionIdData, error) { - var data SessionIdData - if err := c.cookie.Decode(privateSessionName, encodedData, &data); err != nil { - return nil, err - } - - return &data, nil -} - -func (c *SessionIdCodec) DecodePublic(encodedData string) (*SessionIdData, error) { - encodedData, err := reverseSessionId(encodedData) - if err != nil { - return nil, err - } - - var data SessionIdData - if err := c.cookie.Decode(publicSessionName, encodedData, &data); err != nil { - return nil, err - } - - return &data, nil -} diff --git a/sessionid_codec_test.go b/sessionid_codec_test.go deleted file mode 100644 index 6961458..0000000 --- a/sessionid_codec_test.go +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2024 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "encoding/base64" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/timestamppb" -) - -func TestReverseSessionId(t *testing.T) { - assert := assert.New(t) - require := require.New(t) - a := base64.URLEncoding.EncodeToString([]byte("12345")) - ar, err := reverseSessionId(a) - require.NoError(err) - require.NotEqual(a, ar) - b := base64.URLEncoding.EncodeToString([]byte("54321")) - br, err := reverseSessionId(b) - require.NoError(err) - require.NotEqual(b, br) - assert.Equal(b, ar) - assert.Equal(a, br) - - // Invalid base64. - if s, err := reverseSessionId("hello world!"); !assert.Error(err) { - assert.Fail("should have failed but got %s", s) - } - // Invalid base64 length. - if s, err := reverseSessionId("123"); !assert.Error(err) { - assert.Fail("should have failed but got %s", s) - } -} - -func TestPublicPrivate(t *testing.T) { - assert := assert.New(t) - require := require.New(t) - sd := &SessionIdData{ - Sid: 1, - Created: timestamppb.Now(), - BackendId: "foo", - } - - codec := NewSessionIdCodec([]byte("0123456789012345"), []byte("0123456789012345")) - private, err := codec.EncodePrivate(sd) - require.NoError(err) - public, err := codec.EncodePublic(sd) - require.NoError(err) - assert.NotEqual(private, public) - - if data, err := codec.DecodePublic(private); !assert.Error(err) { - assert.Fail("should have failed but got %+v", data) - } - if data, err := codec.DecodePrivate(public); !assert.Error(err) { - assert.Fail("should have failed but got %+v", data) - } -} diff --git a/sfu/common.go b/sfu/common.go new file mode 100644 index 0000000..00aed84 --- /dev/null +++ b/sfu/common.go @@ -0,0 +1,260 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package sfu + +import ( + "context" + "errors" + "fmt" + "net" + "time" + + "github.com/dlintw/goconf" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +const ( + TypeJanus = "janus" + TypeProxy = "proxy" + + TypeDefault = TypeJanus +) + +var ( + ErrNotConnected = errors.New("not connected") +) + +type MediaType int + +const ( + MediaTypeAudio MediaType = 1 << 0 + MediaTypeVideo MediaType = 1 << 1 + MediaTypeScreen MediaType = 1 << 2 +) + +type Listener interface { + PublicId() api.PublicSessionId + + OnUpdateOffer(client Client, offer api.StringMap) + + OnIceCandidate(client Client, candidate any) + OnIceCompleted(client Client) + + SubscriberSidUpdated(subscriber Subscriber) + + PublisherClosed(publisher Publisher) + SubscriberClosed(subscriber Subscriber) +} + +type Initiator interface { + Country() geoip.Country +} + +type Settings interface { + MaxStreamBitrate() api.Bandwidth + MaxScreenBitrate() api.Bandwidth + Timeout() time.Duration + + Reload(config *goconf.ConfigFile) +} + +type NewPublisherSettings struct { + Bitrate api.Bandwidth `json:"bitrate,omitempty"` + MediaTypes MediaType `json:"mediatypes,omitempty"` + + AudioCodec string `json:"audiocodec,omitempty"` + VideoCodec string `json:"videocodec,omitempty"` + VP9Profile string `json:"vp9_profile,omitempty"` + H264Profile string `json:"h264_profile,omitempty"` +} + +type SFU interface { + Start(ctx context.Context) error + Stop() + Reload(config *goconf.ConfigFile) + + SetOnConnected(func()) + SetOnDisconnected(func()) + + GetStats() any + GetServerInfoSfu() *talk.BackendServerInfoSfu + GetBandwidthLimits() (api.Bandwidth, api.Bandwidth) + + NewPublisher(ctx context.Context, listener Listener, id api.PublicSessionId, sid string, streamType StreamType, settings NewPublisherSettings, initiator Initiator) (Publisher, error) + NewSubscriber(ctx context.Context, listener Listener, publisher api.PublicSessionId, streamType StreamType, initiator Initiator) (Subscriber, error) +} + +// PublisherStream contains the available properties when creating a +// remote publisher in Janus. +type PublisherStream struct { + Mid string `json:"mid"` + Mindex int `json:"mindex"` + Type string `json:"type"` + + Description string `json:"description,omitempty"` + Disabled bool `json:"disabled,omitempty"` + + // For types "audio" and "video" + Codec string `json:"codec,omitempty"` + + // For type "audio" + Stereo bool `json:"stereo,omitempty"` + Fec bool `json:"fec,omitempty"` + Dtx bool `json:"dtx,omitempty"` + + // For type "video" + Simulcast bool `json:"simulcast,omitempty"` + Svc bool `json:"svc,omitempty"` + + ProfileH264 string `json:"h264_profile,omitempty"` + ProfileVP9 string `json:"vp9_profile,omitempty"` + + ExtIdVideoOrientation int `json:"videoorient_ext_id,omitempty"` + ExtIdPlayoutDelay int `json:"playoutdelay_ext_id,omitempty"` +} + +type RemotePublisherController interface { + PublisherId() api.PublicSessionId + + StartPublishing(ctx context.Context, publisher RemotePublisherProperties) error + StopPublishing(ctx context.Context, publisher RemotePublisherProperties) error + GetStreams(ctx context.Context) ([]PublisherStream, error) +} + +type RemoteSfu interface { + NewRemotePublisher(ctx context.Context, listener Listener, controller RemotePublisherController, streamType StreamType) (RemotePublisher, error) + NewRemoteSubscriber(ctx context.Context, listener Listener, publisher RemotePublisher) (RemoteSubscriber, error) +} + +type WithToken interface { + CreateToken(subject string) (string, error) +} + +type StreamType string + +const ( + StreamTypeAudio StreamType = "audio" + StreamTypeVideo StreamType = "video" + StreamTypeScreen StreamType = "screen" +) + +func IsValidStreamType(s string) bool { + switch s { + case string(StreamTypeAudio): + fallthrough + case string(StreamTypeVideo): + fallthrough + case string(StreamTypeScreen): + return true + default: + return false + } +} + +type StreamId string + +func GetStreamId(publisherId api.PublicSessionId, streamType StreamType) StreamId { + return StreamId(fmt.Sprintf("%s|%s", publisherId, streamType)) +} + +type ClientBandwidthInfo struct { + // Sent is the outgoing bandwidth. + Sent api.Bandwidth + // Received is the incoming bandwidth. + Received api.Bandwidth +} + +type Client interface { + Id() string + Sid() string + StreamType() StreamType + // MaxBitrate is the maximum allowed bitrate. + MaxBitrate() api.Bandwidth + + Close(ctx context.Context) + + SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) +} + +type ClientWithBandwidth interface { + Client + + Bandwidth() *ClientBandwidthInfo +} + +type Publisher interface { + Client + + PublisherId() api.PublicSessionId + + HasMedia(MediaType) bool + SetMedia(MediaType) +} + +type PublisherWithStreams interface { + Publisher + + GetStreams(ctx context.Context) ([]PublisherStream, error) +} + +type RemoteAwarePublisher interface { + Publisher + + PublishRemote(ctx context.Context, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) error + UnpublishRemote(ctx context.Context, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) error +} + +type PublisherWithConnectionUrlAndIP interface { + Publisher + + GetConnectionURL() (string, net.IP) +} + +type Subscriber interface { + Client + + Publisher() api.PublicSessionId +} + +type SubscriberWithConnectionUrlAndIP interface { + Subscriber + + GetConnectionURL() (string, net.IP) +} + +type RemotePublisherProperties interface { + Port() int + RtcpPort() int +} + +type RemotePublisher interface { + Client + + RemotePublisherProperties +} + +type RemoteSubscriber interface { + Subscriber +} diff --git a/sfu/internal/settings.go b/sfu/internal/settings.go new file mode 100644 index 0000000..1dc2ebd --- /dev/null +++ b/sfu/internal/settings.go @@ -0,0 +1,81 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "sync/atomic" + "time" + + "github.com/dlintw/goconf" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" +) + +var ( + defaultMaxStreamBitrate = api.BandwidthFromMegabits(1) + defaultMaxScreenBitrate = api.BandwidthFromMegabits(2) +) + +type CommonSettings struct { + Logger log.Logger + + maxStreamBitrate api.AtomicBandwidth + maxScreenBitrate api.AtomicBandwidth + + timeout atomic.Int64 +} + +func (s *CommonSettings) MaxStreamBitrate() api.Bandwidth { + return s.maxStreamBitrate.Load() +} + +func (s *CommonSettings) MaxScreenBitrate() api.Bandwidth { + return s.maxScreenBitrate.Load() +} + +func (s *CommonSettings) Timeout() time.Duration { + return time.Duration(s.timeout.Load()) +} + +func (s *CommonSettings) SetTimeout(timeout time.Duration) { + s.timeout.Store(timeout.Nanoseconds()) +} + +func (s *CommonSettings) Load(config *goconf.ConfigFile) error { + maxStreamBitrateValue, _ := config.GetInt("mcu", "maxstreambitrate") + if maxStreamBitrateValue <= 0 { + maxStreamBitrateValue = int(defaultMaxStreamBitrate.Bits()) + } + maxStreamBitrate := api.BandwidthFromBits(uint64(maxStreamBitrateValue)) + s.Logger.Printf("Maximum bandwidth %s per publishing stream", maxStreamBitrate) + s.maxStreamBitrate.Store(maxStreamBitrate) + + maxScreenBitrateValue, _ := config.GetInt("mcu", "maxscreenbitrate") + if maxScreenBitrateValue <= 0 { + maxScreenBitrateValue = int(defaultMaxScreenBitrate.Bits()) + } + maxScreenBitrate := api.BandwidthFromBits(uint64(maxScreenBitrateValue)) + s.Logger.Printf("Maximum bandwidth %s per screensharing stream", maxScreenBitrate) + s.maxScreenBitrate.Store(maxScreenBitrate) + return nil +} diff --git a/sfu/internal/stats_prometheus.go b/sfu/internal/stats_prometheus.go new file mode 100644 index 0000000..a79448a --- /dev/null +++ b/sfu/internal/stats_prometheus.go @@ -0,0 +1,83 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" +) + +var ( + StatsPublishersCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "publishers", + Help: "The current number of publishers", + }, []string{"type"}) + StatsPublishersTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "publishers_total", + Help: "The total number of created publishers", + }, []string{"type"}) + StatsSubscribersCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "subscribers", + Help: "The current number of subscribers", + }, []string{"type"}) + StatsSubscribersTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "subscribers_total", + Help: "The total number of created subscribers", + }, []string{"type"}) + StatsWaitingForPublisherTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "nopublisher_total", + Help: "The total number of subscribe requests where no publisher exists", + }, []string{"type"}) + StatsMcuMessagesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "messages_total", + Help: "The total number of MCU messages", + }, []string{"type"}) + + commonMcuStats = []prometheus.Collector{ + StatsPublishersCurrent, + StatsPublishersTotal, + StatsSubscribersCurrent, + StatsSubscribersTotal, + StatsWaitingForPublisherTotal, + StatsMcuMessagesTotal, + } +) + +func RegisterCommonStats() { + metrics.RegisterAll(commonMcuStats...) +} + +func UnregisterCommonStats() { + metrics.UnregisterAll(commonMcuStats...) +} diff --git a/sfu/internal/stats_prometheus_test.go b/sfu/internal/stats_prometheus_test.go new file mode 100644 index 0000000..cf4dd5b --- /dev/null +++ b/sfu/internal/stats_prometheus_test.go @@ -0,0 +1,33 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package internal + +import ( + "testing" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics/test" +) + +func TestCommonMcuStats(t *testing.T) { + t.Parallel() + test.CollectAndLint(t, commonMcuStats...) +} diff --git a/sfu/janus/api.go b/sfu/janus/api.go new file mode 100644 index 0000000..b28c7b6 --- /dev/null +++ b/sfu/janus/api.go @@ -0,0 +1,514 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "encoding/json" + "fmt" +) + +const ( + janusEventTypeSession = 1 + + janusEventTypeHandle = 2 + + janusEventTypeExternal = 4 + + janusEventTypeJSEP = 8 + + janusEventTypeWebRTC = 16 + janusEventSubTypeWebRTCICE = 1 + janusEventSubTypeWebRTCLocalCandidate = 2 + janusEventSubTypeWebRTCRemoteCandidate = 3 + janusEventSubTypeWebRTCSelectedPair = 4 + janusEventSubTypeWebRTCDTLS = 5 + janusEventSubTypeWebRTCPeerConnection = 6 + + janusEventTypeMedia = 32 + janusEventSubTypeMediaState = 1 + janusEventSubTypeMediaSlowLink = 2 + janusEventSubTypeMediaStats = 3 + + janusEventTypePlugin = 64 + + janusEventTypeTransport = 128 + + janusEventTypeCore = 256 + janusEventSubTypeCoreStatusStartup = 1 + janusEventSubTypeCoreStatusShutdown = 2 +) + +func unmarshalEvent[T any](data json.RawMessage) (*T, error) { + var e T + if err := json.Unmarshal(data, &e); err != nil { + return nil, err + } + + return &e, nil +} + +func marshalEvent[T any](e T) string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("Could not serialize %#v: %s", e, err) + } + + return string(data) +} + +type janusEvent struct { + Emitter string `json:"emitter"` + Type int `json:"type"` + SubType int `json:"subtype,omitempty"` + Timestamp uint64 `json:"timestamp"` + SessionId uint64 `json:"session_id,omitempty"` + HandleId uint64 `json:"handle_id,omitempty"` + OpaqueId uint64 `json:"opaque_id,omitempty"` + Event json.RawMessage `json:"event"` +} + +func (e janusEvent) String() string { + return marshalEvent(e) +} + +func (e janusEvent) Decode() (any, error) { + switch e.Type { + case janusEventTypeSession: + return unmarshalEvent[janusEventSession](e.Event) + case janusEventTypeHandle: + return unmarshalEvent[janusEventHandle](e.Event) + case janusEventTypeExternal: + return unmarshalEvent[janusEventExternal](e.Event) + case janusEventTypeJSEP: + return unmarshalEvent[janusEventJSEP](e.Event) + case janusEventTypeWebRTC: + switch e.SubType { + case janusEventSubTypeWebRTCICE: + return unmarshalEvent[janusEventWebRTCICE](e.Event) + case janusEventSubTypeWebRTCLocalCandidate: + return unmarshalEvent[janusEventWebRTCLocalCandidate](e.Event) + case janusEventSubTypeWebRTCRemoteCandidate: + return unmarshalEvent[janusEventWebRTCRemoteCandidate](e.Event) + case janusEventSubTypeWebRTCSelectedPair: + return unmarshalEvent[janusEventWebRTCSelectedPair](e.Event) + case janusEventSubTypeWebRTCDTLS: + return unmarshalEvent[janusEventWebRTCDTLS](e.Event) + case janusEventSubTypeWebRTCPeerConnection: + return unmarshalEvent[janusEventWebRTCPeerConnection](e.Event) + } + case janusEventTypeMedia: + switch e.SubType { + case janusEventSubTypeMediaState: + return unmarshalEvent[janusEventMediaState](e.Event) + case janusEventSubTypeMediaSlowLink: + return unmarshalEvent[janusEventMediaSlowLink](e.Event) + case janusEventSubTypeMediaStats: + return unmarshalEvent[janusEventMediaStats](e.Event) + } + case janusEventTypePlugin: + return unmarshalEvent[janusEventPlugin](e.Event) + case janusEventTypeTransport: + return unmarshalEvent[janusEventTransport](e.Event) + case janusEventTypeCore: + switch e.SubType { + case janusEventSubTypeCoreStatusStartup: + event, err := unmarshalEvent[janusEventCoreStartup](e.Event) + if err != nil { + return nil, err + } + + switch event.Status { + case "started": + return unmarshalEvent[janusEventStatusStartupInfo](event.Info) + case "update": + return unmarshalEvent[janusEventStatusUpdateInfo](event.Info) + } + + return event, nil + case janusEventSubTypeCoreStatusShutdown: + return unmarshalEvent[janusEventCoreShutdown](e.Event) + } + } + + return nil, fmt.Errorf("unsupported event type %d", e.Type) +} + +type janusEventSessionTransport struct { + Transport string `json:"transport"` + ID string `json:"id"` +} + +// type=1 +type janusEventSession struct { + Name string `json:"name"` // "created", "destroyed", "timeout" + + Transport *janusEventSessionTransport `json:"transport,omitempty"` +} + +func (e janusEventSession) String() string { + return marshalEvent(e) +} + +// type=2 +type janusEventHandle struct { + Name string `json:"name"` // "attached", "detached" + Plugin string `json:"plugin"` + Token string `json:"token,omitempty"` + // Deprecated + OpaqueId string `json:"opaque_id,omitempty"` +} + +func (e janusEventHandle) String() string { + return marshalEvent(e) +} + +// type=4 +type janusEventExternal struct { + Schema string `json:"schema"` + Data json.RawMessage `json:"data"` +} + +func (e janusEventExternal) String() string { + return marshalEvent(e) +} + +// type=8 +type janusEventJSEP struct { + Owner string `json:"owner"` + Jsep struct { + Type string `json:"type"` + SDP string `json:"sdp"` + } `json:"jsep"` +} + +func (e janusEventJSEP) String() string { + return marshalEvent(e) +} + +// type=16, subtype=1 +type janusEventWebRTCICE struct { + ICE string `json:"ice"` // "gathering", "connecting", "connected", "ready" + StreamID int `json:"stream_id"` + ComponentID int `json:"component_id"` +} + +func (e janusEventWebRTCICE) String() string { + return marshalEvent(e) +} + +// type=16, subtype=2 +type janusEventWebRTCLocalCandidate struct { + LocalCandidate string `json:"local-candidate"` + StreamID int `json:"stream_id"` + ComponentID int `json:"component_id"` +} + +func (e janusEventWebRTCLocalCandidate) String() string { + return marshalEvent(e) +} + +// type=16, subtype=3 +type janusEventWebRTCRemoteCandidate struct { + RemoteCandidate string `json:"remote-candidate"` + StreamID int `json:"stream_id"` + ComponentID int `json:"component_id"` +} + +func (e janusEventWebRTCRemoteCandidate) String() string { + return marshalEvent(e) +} + +type janusEventCandidate struct { + Address string `json:"address"` + Port int `json:"port"` + Type string `json:"type"` + Transport string `json:"transport"` + Family int `json:"family"` +} + +func (e janusEventCandidate) String() string { + return marshalEvent(e) +} + +type janusEventCandidates struct { + Local janusEventCandidate `json:"local"` + Remote janusEventCandidate `json:"remote"` +} + +func (e janusEventCandidates) String() string { + return marshalEvent(e) +} + +// type=16, subtype=4 +type janusEventWebRTCSelectedPair struct { + StreamID int `json:"stream_id"` + ComponentID int `json:"component_id"` + + SelectedPair string `json:"selected-pair"` + Candidates janusEventCandidates `json:"candidates"` +} + +func (e janusEventWebRTCSelectedPair) String() string { + return marshalEvent(e) +} + +// type=16, subtype=5 +type janusEventWebRTCDTLS struct { + DTLS string `json:"dtls"` // "trying", "connected" + + StreamID int `json:"stream_id"` + ComponentID int `json:"component_id"` + + Retransmissions int `json:"retransmissions"` +} + +func (e janusEventWebRTCDTLS) String() string { + return marshalEvent(e) +} + +// type=16, subtype=6 +type janusEventWebRTCPeerConnection struct { + Connection string `json:"connection"` // "webrtcup", "hangup" + Reason string `json:"reason,omitempty"` // Only if "connection" == "hangup" +} + +func (e janusEventWebRTCPeerConnection) String() string { + return marshalEvent(e) +} + +// type=32, subtype=1 +type janusEventMediaState struct { + Media string `json:"media"` // "audio", "video" + MID string `json:"mid"` + SubStream *int `json:"substream,omitempty"` + Receiving bool `json:"receiving"` + Seconds int `json:"seconds"` +} + +func (e janusEventMediaState) String() string { + return marshalEvent(e) +} + +// type=32, subtype=2 +type janusEventMediaSlowLink struct { + Media string `json:"media"` // "audio", "video" + MID string `json:"mid"` + SlowLink string `json:"slow_link"` // "uplink", "downlink" + LostLastSec int `json:"lost_lastsec"` +} + +func (e janusEventMediaSlowLink) String() string { + return marshalEvent(e) +} + +type janusMediaStatsRTTValues struct { + NTP uint32 `json:"ntp"` + LSR uint32 `json:"lsr"` + DLSR uint32 `json:"dlsr"` +} + +func (e janusMediaStatsRTTValues) String() string { + return marshalEvent(e) +} + +// type=32, subtype=3 +type janusEventMediaStats struct { + MID string `json:"mid"` + MIndex int `json:"mindex"` + Media string `json:"media"` // "audio", "video", "video-sim1", "video-sim2" + + // Audio / video only + Codec string `json:"codec,omitempty"` + Base uint32 `json:"base"` + Lost int32 `json:"lost"` + LostByRemote int32 `json:"lost-by-remote"` + JitterLocal uint32 `json:"jitter-local"` + JitterRemote uint32 `json:"jitter-remote"` + InLinkQuality uint32 `json:"in-link-quality"` + InMediaLinkQuality uint32 `json:"in-media-link-quality"` + OutLinkQuality uint32 `json:"out-link-quality"` + OutMediaLinkQuality uint32 `json:"out-media-link-quality"` + BytesReceivedLastSec uint32 `json:"bytes-received-lastsec"` + BytesSentLastSec uint32 `json:"bytes-sent-lastsec"` + NacksReceived uint32 `json:"nacks-received"` + NacksSent uint32 `json:"nacks-sent"` + RetransmissionsReceived uint32 `json:"retransmissions-received"` + + // Only for audio / video on layer 0 + RTT uint32 `json:"rtt,omitempty"` + // Only for audio / video on layer 0 if RTCP is active + RTTValues *janusMediaStatsRTTValues `json:"rtt-values,omitempty"` + + // For all media on all layers + PacketsReceived uint32 `json:"packets-received"` + PacketsSent uint32 `json:"packets-sent"` + BytesReceived uint64 `json:"bytes-received"` + BytesSent uint64 `json:"bytes-sent"` + + // For layer 0 if REMB is enabled + REMBBitrate uint32 `json:"remb-bitrate"` +} + +func (e janusEventMediaStats) String() string { + return marshalEvent(e) +} + +// type=64 +type janusEventPlugin struct { + Plugin string `json:"plugin"` + Data json.RawMessage `json:"data"` +} + +func (e janusEventPlugin) String() string { + return marshalEvent(e) +} + +type janusEventTransportWebsocket struct { + Event string `json:"event"` + AdminApi bool `json:"admin_api,omitempty"` + IP string `json:"ip,omitempty"` +} + +// type=128 +type janusEventTransport struct { + Transport string `json:"transport"` + Id string `json:"id"` + Data janusEventTransportWebsocket `json:"data"` +} + +func (e janusEventTransport) String() string { + return marshalEvent(e) +} + +type janusEventDependenciesInfo struct { + Glib2 string `json:"glib2"` + Jansson string `json:"jansson"` + Libnice string `json:"libnice"` + Libsrtp string `json:"libsrtp"` + Libcurl string `json:"libcurl,omitempty"` + Crypto string `json:"crypto"` +} + +func (e janusEventDependenciesInfo) String() string { + return marshalEvent(e) +} + +type janusEventPluginInfo struct { + Name string `json:"name"` + Author string `json:"author"` + Description string `json:"description"` + VersionString string `json:"version_string"` + Version int `json:"version"` +} + +func (e janusEventPluginInfo) String() string { + return marshalEvent(e) +} + +// type=256, subtype=1, status="startup" +type janusEventStatusStartupInfo struct { + Janus string `json:"janus"` + Version int `json:"version"` + VersionString string `json:"version_string"` + Author string `json:"author"` + CommitHash string `json:"commit-hash"` + CompileTime string `json:"compile-time"` + LogToStdout bool `json:"log-to-stdout"` + LogToFile bool `json:"log-to-file"` + LogPath string `json:"log-path,omitempty"` + DataChannels bool `json:"data_channels"` + AcceptingNewSessions bool `json:"accepting-new-sessions"` + SessionTimeout int `json:"session-timeout"` + ReclaimSessionTimeout int `json:"reclaim-session-timeout"` + CandidatesTimeout int `json:"candidates-timeout"` + ServerName string `json:"server-name"` + LocalIP string `json:"local-ip"` + PublicIP string `json:"public-ip,omitempty"` + PublicIPs []string `json:"public-ips,omitempty"` + IPv6 bool `json:"ipv6"` + IPv6LinkLocal bool `json:"ipv6-link-local,omitempty"` + ICELite bool `json:"ice-lite"` + ICETCP bool `json:"ice-tcp"` + ICENomination string `json:"ice-nomination,omitempty"` + ICEConsentFreshness bool `json:"ice-consent-freshness"` + ICEKeepaliveConncheck bool `json:"ice-keepalive-conncheck"` + HangupOnFailed bool `json:"hangup-on-failed"` + FullTrickle bool `json:"full-trickle"` + MDNSEnabled bool `json:"mdns-enabled"` + MinNACKQueue int `json:"min-nack-queue"` + NACKOptimizations bool `json:"nack-optimizations"` + TWCCPeriod int `json:"twcc-period"` + DSCP int `json:"dscp,omitempty"` + DTLSMCU int `json:"dtls-mcu"` + STUNServer string `json:"stun-server,omitempty"` + TURNServer string `json:"turn-server,omitempty"` + AllowForceRelay bool `json:"allow-force-relay,omitempty"` + StaticEventLoops int `json:"static-event-loops"` + LoopIndication bool `json:"loop-indication,omitempty"` + APISecret bool `json:"api_secret"` + AuthToken bool `json:"auth_token"` + EventHandlers bool `json:"event_handlers"` + OpaqueIdInAPI bool `json:"opaqueid_in_api"` + WebRTCEncryption bool `json:"webrtc_encryption"` + + Dependencies *janusEventDependenciesInfo `json:"dependencies,omitempty"` + Transports map[string]janusEventPluginInfo `json:"transports,omitempty"` + Events map[string]janusEventPluginInfo `json:"events,omitempty"` + Loggers map[string]janusEventPluginInfo `json:"loggers,omitempty"` + Plugins map[string]janusEventPluginInfo `json:"plugins,omitempty"` +} + +func (e janusEventStatusStartupInfo) String() string { + return marshalEvent(e) +} + +// type=256, subtype=1, status="update" +type janusEventStatusUpdateInfo struct { + Sessions int `json:"sessions"` + Handles int `json:"handles"` + PeerConnections int `json:"peerconnections"` + StatsPeriod int `json:"stats-period"` +} + +func (e janusEventStatusUpdateInfo) String() string { + return marshalEvent(e) +} + +// type=256, subtype=1 +type janusEventCoreStartup struct { + Status string `json:"status"` + Info json.RawMessage `json:"info"` +} + +func (e janusEventCoreStartup) String() string { + return marshalEvent(e) +} + +// type=256, subtype=2 +type janusEventCoreShutdown struct { + Status string `json:"status"` + Signum int `json:"signum"` +} + +func (e janusEventCoreShutdown) String() string { + return marshalEvent(e) +} diff --git a/sfu/janus/api_easyjson.go b/sfu/janus/api_easyjson.go new file mode 100644 index 0000000..ed78b1a --- /dev/null +++ b/sfu/janus/api_easyjson.go @@ -0,0 +1,3467 @@ +// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. + +package janus + +import ( + json "encoding/json" + easyjson "github.com/mailru/easyjson" + jlexer "github.com/mailru/easyjson/jlexer" + jwriter "github.com/mailru/easyjson/jwriter" +) + +// suppress unused package warning +var ( + _ *json.RawMessage + _ *jlexer.Lexer + _ *jwriter.Writer + _ easyjson.Marshaler +) + +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus(in *jlexer.Lexer, out *janusMediaStatsRTTValues) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "ntp": + if in.IsNull() { + in.Skip() + } else { + out.NTP = uint32(in.Uint32()) + } + case "lsr": + if in.IsNull() { + in.Skip() + } else { + out.LSR = uint32(in.Uint32()) + } + case "dlsr": + if in.IsNull() { + in.Skip() + } else { + out.DLSR = uint32(in.Uint32()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus(out *jwriter.Writer, in janusMediaStatsRTTValues) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"ntp\":" + out.RawString(prefix[1:]) + out.Uint32(uint32(in.NTP)) + } + { + const prefix string = ",\"lsr\":" + out.RawString(prefix) + out.Uint32(uint32(in.LSR)) + } + { + const prefix string = ",\"dlsr\":" + out.RawString(prefix) + out.Uint32(uint32(in.DLSR)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusMediaStatsRTTValues) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusMediaStatsRTTValues) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusMediaStatsRTTValues) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusMediaStatsRTTValues) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus1(in *jlexer.Lexer, out *janusEventWebRTCSelectedPair) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "stream_id": + if in.IsNull() { + in.Skip() + } else { + out.StreamID = int(in.Int()) + } + case "component_id": + if in.IsNull() { + in.Skip() + } else { + out.ComponentID = int(in.Int()) + } + case "selected-pair": + if in.IsNull() { + in.Skip() + } else { + out.SelectedPair = string(in.String()) + } + case "candidates": + if in.IsNull() { + in.Skip() + } else { + (out.Candidates).UnmarshalEasyJSON(in) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus1(out *jwriter.Writer, in janusEventWebRTCSelectedPair) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"stream_id\":" + out.RawString(prefix[1:]) + out.Int(int(in.StreamID)) + } + { + const prefix string = ",\"component_id\":" + out.RawString(prefix) + out.Int(int(in.ComponentID)) + } + { + const prefix string = ",\"selected-pair\":" + out.RawString(prefix) + out.String(string(in.SelectedPair)) + } + { + const prefix string = ",\"candidates\":" + out.RawString(prefix) + (in.Candidates).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventWebRTCSelectedPair) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus1(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventWebRTCSelectedPair) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus1(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventWebRTCSelectedPair) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus1(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventWebRTCSelectedPair) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus1(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus2(in *jlexer.Lexer, out *janusEventWebRTCRemoteCandidate) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "remote-candidate": + if in.IsNull() { + in.Skip() + } else { + out.RemoteCandidate = string(in.String()) + } + case "stream_id": + if in.IsNull() { + in.Skip() + } else { + out.StreamID = int(in.Int()) + } + case "component_id": + if in.IsNull() { + in.Skip() + } else { + out.ComponentID = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus2(out *jwriter.Writer, in janusEventWebRTCRemoteCandidate) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"remote-candidate\":" + out.RawString(prefix[1:]) + out.String(string(in.RemoteCandidate)) + } + { + const prefix string = ",\"stream_id\":" + out.RawString(prefix) + out.Int(int(in.StreamID)) + } + { + const prefix string = ",\"component_id\":" + out.RawString(prefix) + out.Int(int(in.ComponentID)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventWebRTCRemoteCandidate) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus2(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventWebRTCRemoteCandidate) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus2(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventWebRTCRemoteCandidate) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus2(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventWebRTCRemoteCandidate) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus2(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus3(in *jlexer.Lexer, out *janusEventWebRTCPeerConnection) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "connection": + if in.IsNull() { + in.Skip() + } else { + out.Connection = string(in.String()) + } + case "reason": + if in.IsNull() { + in.Skip() + } else { + out.Reason = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus3(out *jwriter.Writer, in janusEventWebRTCPeerConnection) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"connection\":" + out.RawString(prefix[1:]) + out.String(string(in.Connection)) + } + if in.Reason != "" { + const prefix string = ",\"reason\":" + out.RawString(prefix) + out.String(string(in.Reason)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventWebRTCPeerConnection) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus3(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventWebRTCPeerConnection) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus3(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventWebRTCPeerConnection) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus3(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventWebRTCPeerConnection) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus3(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus4(in *jlexer.Lexer, out *janusEventWebRTCLocalCandidate) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "local-candidate": + if in.IsNull() { + in.Skip() + } else { + out.LocalCandidate = string(in.String()) + } + case "stream_id": + if in.IsNull() { + in.Skip() + } else { + out.StreamID = int(in.Int()) + } + case "component_id": + if in.IsNull() { + in.Skip() + } else { + out.ComponentID = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus4(out *jwriter.Writer, in janusEventWebRTCLocalCandidate) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"local-candidate\":" + out.RawString(prefix[1:]) + out.String(string(in.LocalCandidate)) + } + { + const prefix string = ",\"stream_id\":" + out.RawString(prefix) + out.Int(int(in.StreamID)) + } + { + const prefix string = ",\"component_id\":" + out.RawString(prefix) + out.Int(int(in.ComponentID)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventWebRTCLocalCandidate) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus4(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventWebRTCLocalCandidate) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus4(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventWebRTCLocalCandidate) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus4(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventWebRTCLocalCandidate) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus4(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus5(in *jlexer.Lexer, out *janusEventWebRTCICE) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "ice": + if in.IsNull() { + in.Skip() + } else { + out.ICE = string(in.String()) + } + case "stream_id": + if in.IsNull() { + in.Skip() + } else { + out.StreamID = int(in.Int()) + } + case "component_id": + if in.IsNull() { + in.Skip() + } else { + out.ComponentID = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus5(out *jwriter.Writer, in janusEventWebRTCICE) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"ice\":" + out.RawString(prefix[1:]) + out.String(string(in.ICE)) + } + { + const prefix string = ",\"stream_id\":" + out.RawString(prefix) + out.Int(int(in.StreamID)) + } + { + const prefix string = ",\"component_id\":" + out.RawString(prefix) + out.Int(int(in.ComponentID)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventWebRTCICE) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus5(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventWebRTCICE) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus5(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventWebRTCICE) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus5(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventWebRTCICE) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus5(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus6(in *jlexer.Lexer, out *janusEventWebRTCDTLS) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "dtls": + if in.IsNull() { + in.Skip() + } else { + out.DTLS = string(in.String()) + } + case "stream_id": + if in.IsNull() { + in.Skip() + } else { + out.StreamID = int(in.Int()) + } + case "component_id": + if in.IsNull() { + in.Skip() + } else { + out.ComponentID = int(in.Int()) + } + case "retransmissions": + if in.IsNull() { + in.Skip() + } else { + out.Retransmissions = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus6(out *jwriter.Writer, in janusEventWebRTCDTLS) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"dtls\":" + out.RawString(prefix[1:]) + out.String(string(in.DTLS)) + } + { + const prefix string = ",\"stream_id\":" + out.RawString(prefix) + out.Int(int(in.StreamID)) + } + { + const prefix string = ",\"component_id\":" + out.RawString(prefix) + out.Int(int(in.ComponentID)) + } + { + const prefix string = ",\"retransmissions\":" + out.RawString(prefix) + out.Int(int(in.Retransmissions)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventWebRTCDTLS) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus6(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventWebRTCDTLS) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus6(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventWebRTCDTLS) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus6(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventWebRTCDTLS) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus6(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus7(in *jlexer.Lexer, out *janusEventTransportWebsocket) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "event": + if in.IsNull() { + in.Skip() + } else { + out.Event = string(in.String()) + } + case "admin_api": + if in.IsNull() { + in.Skip() + } else { + out.AdminApi = bool(in.Bool()) + } + case "ip": + if in.IsNull() { + in.Skip() + } else { + out.IP = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus7(out *jwriter.Writer, in janusEventTransportWebsocket) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"event\":" + out.RawString(prefix[1:]) + out.String(string(in.Event)) + } + if in.AdminApi { + const prefix string = ",\"admin_api\":" + out.RawString(prefix) + out.Bool(bool(in.AdminApi)) + } + if in.IP != "" { + const prefix string = ",\"ip\":" + out.RawString(prefix) + out.String(string(in.IP)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventTransportWebsocket) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus7(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventTransportWebsocket) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus7(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventTransportWebsocket) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus7(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventTransportWebsocket) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus7(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus8(in *jlexer.Lexer, out *janusEventTransport) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "transport": + if in.IsNull() { + in.Skip() + } else { + out.Transport = string(in.String()) + } + case "id": + if in.IsNull() { + in.Skip() + } else { + out.Id = string(in.String()) + } + case "data": + if in.IsNull() { + in.Skip() + } else { + (out.Data).UnmarshalEasyJSON(in) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus8(out *jwriter.Writer, in janusEventTransport) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"transport\":" + out.RawString(prefix[1:]) + out.String(string(in.Transport)) + } + { + const prefix string = ",\"id\":" + out.RawString(prefix) + out.String(string(in.Id)) + } + { + const prefix string = ",\"data\":" + out.RawString(prefix) + (in.Data).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventTransport) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus8(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventTransport) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus8(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventTransport) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus8(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventTransport) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus8(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus9(in *jlexer.Lexer, out *janusEventStatusUpdateInfo) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "sessions": + if in.IsNull() { + in.Skip() + } else { + out.Sessions = int(in.Int()) + } + case "handles": + if in.IsNull() { + in.Skip() + } else { + out.Handles = int(in.Int()) + } + case "peerconnections": + if in.IsNull() { + in.Skip() + } else { + out.PeerConnections = int(in.Int()) + } + case "stats-period": + if in.IsNull() { + in.Skip() + } else { + out.StatsPeriod = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus9(out *jwriter.Writer, in janusEventStatusUpdateInfo) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"sessions\":" + out.RawString(prefix[1:]) + out.Int(int(in.Sessions)) + } + { + const prefix string = ",\"handles\":" + out.RawString(prefix) + out.Int(int(in.Handles)) + } + { + const prefix string = ",\"peerconnections\":" + out.RawString(prefix) + out.Int(int(in.PeerConnections)) + } + { + const prefix string = ",\"stats-period\":" + out.RawString(prefix) + out.Int(int(in.StatsPeriod)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventStatusUpdateInfo) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus9(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventStatusUpdateInfo) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus9(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventStatusUpdateInfo) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus9(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventStatusUpdateInfo) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus9(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus10(in *jlexer.Lexer, out *janusEventStatusStartupInfo) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "janus": + if in.IsNull() { + in.Skip() + } else { + out.Janus = string(in.String()) + } + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = int(in.Int()) + } + case "version_string": + if in.IsNull() { + in.Skip() + } else { + out.VersionString = string(in.String()) + } + case "author": + if in.IsNull() { + in.Skip() + } else { + out.Author = string(in.String()) + } + case "commit-hash": + if in.IsNull() { + in.Skip() + } else { + out.CommitHash = string(in.String()) + } + case "compile-time": + if in.IsNull() { + in.Skip() + } else { + out.CompileTime = string(in.String()) + } + case "log-to-stdout": + if in.IsNull() { + in.Skip() + } else { + out.LogToStdout = bool(in.Bool()) + } + case "log-to-file": + if in.IsNull() { + in.Skip() + } else { + out.LogToFile = bool(in.Bool()) + } + case "log-path": + if in.IsNull() { + in.Skip() + } else { + out.LogPath = string(in.String()) + } + case "data_channels": + if in.IsNull() { + in.Skip() + } else { + out.DataChannels = bool(in.Bool()) + } + case "accepting-new-sessions": + if in.IsNull() { + in.Skip() + } else { + out.AcceptingNewSessions = bool(in.Bool()) + } + case "session-timeout": + if in.IsNull() { + in.Skip() + } else { + out.SessionTimeout = int(in.Int()) + } + case "reclaim-session-timeout": + if in.IsNull() { + in.Skip() + } else { + out.ReclaimSessionTimeout = int(in.Int()) + } + case "candidates-timeout": + if in.IsNull() { + in.Skip() + } else { + out.CandidatesTimeout = int(in.Int()) + } + case "server-name": + if in.IsNull() { + in.Skip() + } else { + out.ServerName = string(in.String()) + } + case "local-ip": + if in.IsNull() { + in.Skip() + } else { + out.LocalIP = string(in.String()) + } + case "public-ip": + if in.IsNull() { + in.Skip() + } else { + out.PublicIP = string(in.String()) + } + case "public-ips": + if in.IsNull() { + in.Skip() + out.PublicIPs = nil + } else { + in.Delim('[') + if out.PublicIPs == nil { + if !in.IsDelim(']') { + out.PublicIPs = make([]string, 0, 4) + } else { + out.PublicIPs = []string{} + } + } else { + out.PublicIPs = (out.PublicIPs)[:0] + } + for !in.IsDelim(']') { + var v1 string + if in.IsNull() { + in.Skip() + } else { + v1 = string(in.String()) + } + out.PublicIPs = append(out.PublicIPs, v1) + in.WantComma() + } + in.Delim(']') + } + case "ipv6": + if in.IsNull() { + in.Skip() + } else { + out.IPv6 = bool(in.Bool()) + } + case "ipv6-link-local": + if in.IsNull() { + in.Skip() + } else { + out.IPv6LinkLocal = bool(in.Bool()) + } + case "ice-lite": + if in.IsNull() { + in.Skip() + } else { + out.ICELite = bool(in.Bool()) + } + case "ice-tcp": + if in.IsNull() { + in.Skip() + } else { + out.ICETCP = bool(in.Bool()) + } + case "ice-nomination": + if in.IsNull() { + in.Skip() + } else { + out.ICENomination = string(in.String()) + } + case "ice-consent-freshness": + if in.IsNull() { + in.Skip() + } else { + out.ICEConsentFreshness = bool(in.Bool()) + } + case "ice-keepalive-conncheck": + if in.IsNull() { + in.Skip() + } else { + out.ICEKeepaliveConncheck = bool(in.Bool()) + } + case "hangup-on-failed": + if in.IsNull() { + in.Skip() + } else { + out.HangupOnFailed = bool(in.Bool()) + } + case "full-trickle": + if in.IsNull() { + in.Skip() + } else { + out.FullTrickle = bool(in.Bool()) + } + case "mdns-enabled": + if in.IsNull() { + in.Skip() + } else { + out.MDNSEnabled = bool(in.Bool()) + } + case "min-nack-queue": + if in.IsNull() { + in.Skip() + } else { + out.MinNACKQueue = int(in.Int()) + } + case "nack-optimizations": + if in.IsNull() { + in.Skip() + } else { + out.NACKOptimizations = bool(in.Bool()) + } + case "twcc-period": + if in.IsNull() { + in.Skip() + } else { + out.TWCCPeriod = int(in.Int()) + } + case "dscp": + if in.IsNull() { + in.Skip() + } else { + out.DSCP = int(in.Int()) + } + case "dtls-mcu": + if in.IsNull() { + in.Skip() + } else { + out.DTLSMCU = int(in.Int()) + } + case "stun-server": + if in.IsNull() { + in.Skip() + } else { + out.STUNServer = string(in.String()) + } + case "turn-server": + if in.IsNull() { + in.Skip() + } else { + out.TURNServer = string(in.String()) + } + case "allow-force-relay": + if in.IsNull() { + in.Skip() + } else { + out.AllowForceRelay = bool(in.Bool()) + } + case "static-event-loops": + if in.IsNull() { + in.Skip() + } else { + out.StaticEventLoops = int(in.Int()) + } + case "loop-indication": + if in.IsNull() { + in.Skip() + } else { + out.LoopIndication = bool(in.Bool()) + } + case "api_secret": + if in.IsNull() { + in.Skip() + } else { + out.APISecret = bool(in.Bool()) + } + case "auth_token": + if in.IsNull() { + in.Skip() + } else { + out.AuthToken = bool(in.Bool()) + } + case "event_handlers": + if in.IsNull() { + in.Skip() + } else { + out.EventHandlers = bool(in.Bool()) + } + case "opaqueid_in_api": + if in.IsNull() { + in.Skip() + } else { + out.OpaqueIdInAPI = bool(in.Bool()) + } + case "webrtc_encryption": + if in.IsNull() { + in.Skip() + } else { + out.WebRTCEncryption = bool(in.Bool()) + } + case "dependencies": + if in.IsNull() { + in.Skip() + out.Dependencies = nil + } else { + if out.Dependencies == nil { + out.Dependencies = new(janusEventDependenciesInfo) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Dependencies).UnmarshalEasyJSON(in) + } + } + case "transports": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Transports = make(map[string]janusEventPluginInfo) + } else { + out.Transports = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v2 janusEventPluginInfo + if in.IsNull() { + in.Skip() + } else { + (v2).UnmarshalEasyJSON(in) + } + (out.Transports)[key] = v2 + in.WantComma() + } + in.Delim('}') + } + case "events": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Events = make(map[string]janusEventPluginInfo) + } else { + out.Events = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v3 janusEventPluginInfo + if in.IsNull() { + in.Skip() + } else { + (v3).UnmarshalEasyJSON(in) + } + (out.Events)[key] = v3 + in.WantComma() + } + in.Delim('}') + } + case "loggers": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Loggers = make(map[string]janusEventPluginInfo) + } else { + out.Loggers = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v4 janusEventPluginInfo + if in.IsNull() { + in.Skip() + } else { + (v4).UnmarshalEasyJSON(in) + } + (out.Loggers)[key] = v4 + in.WantComma() + } + in.Delim('}') + } + case "plugins": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Plugins = make(map[string]janusEventPluginInfo) + } else { + out.Plugins = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v5 janusEventPluginInfo + if in.IsNull() { + in.Skip() + } else { + (v5).UnmarshalEasyJSON(in) + } + (out.Plugins)[key] = v5 + in.WantComma() + } + in.Delim('}') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus10(out *jwriter.Writer, in janusEventStatusStartupInfo) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"janus\":" + out.RawString(prefix[1:]) + out.String(string(in.Janus)) + } + { + const prefix string = ",\"version\":" + out.RawString(prefix) + out.Int(int(in.Version)) + } + { + const prefix string = ",\"version_string\":" + out.RawString(prefix) + out.String(string(in.VersionString)) + } + { + const prefix string = ",\"author\":" + out.RawString(prefix) + out.String(string(in.Author)) + } + { + const prefix string = ",\"commit-hash\":" + out.RawString(prefix) + out.String(string(in.CommitHash)) + } + { + const prefix string = ",\"compile-time\":" + out.RawString(prefix) + out.String(string(in.CompileTime)) + } + { + const prefix string = ",\"log-to-stdout\":" + out.RawString(prefix) + out.Bool(bool(in.LogToStdout)) + } + { + const prefix string = ",\"log-to-file\":" + out.RawString(prefix) + out.Bool(bool(in.LogToFile)) + } + if in.LogPath != "" { + const prefix string = ",\"log-path\":" + out.RawString(prefix) + out.String(string(in.LogPath)) + } + { + const prefix string = ",\"data_channels\":" + out.RawString(prefix) + out.Bool(bool(in.DataChannels)) + } + { + const prefix string = ",\"accepting-new-sessions\":" + out.RawString(prefix) + out.Bool(bool(in.AcceptingNewSessions)) + } + { + const prefix string = ",\"session-timeout\":" + out.RawString(prefix) + out.Int(int(in.SessionTimeout)) + } + { + const prefix string = ",\"reclaim-session-timeout\":" + out.RawString(prefix) + out.Int(int(in.ReclaimSessionTimeout)) + } + { + const prefix string = ",\"candidates-timeout\":" + out.RawString(prefix) + out.Int(int(in.CandidatesTimeout)) + } + { + const prefix string = ",\"server-name\":" + out.RawString(prefix) + out.String(string(in.ServerName)) + } + { + const prefix string = ",\"local-ip\":" + out.RawString(prefix) + out.String(string(in.LocalIP)) + } + if in.PublicIP != "" { + const prefix string = ",\"public-ip\":" + out.RawString(prefix) + out.String(string(in.PublicIP)) + } + if len(in.PublicIPs) != 0 { + const prefix string = ",\"public-ips\":" + out.RawString(prefix) + { + out.RawByte('[') + for v6, v7 := range in.PublicIPs { + if v6 > 0 { + out.RawByte(',') + } + out.String(string(v7)) + } + out.RawByte(']') + } + } + { + const prefix string = ",\"ipv6\":" + out.RawString(prefix) + out.Bool(bool(in.IPv6)) + } + if in.IPv6LinkLocal { + const prefix string = ",\"ipv6-link-local\":" + out.RawString(prefix) + out.Bool(bool(in.IPv6LinkLocal)) + } + { + const prefix string = ",\"ice-lite\":" + out.RawString(prefix) + out.Bool(bool(in.ICELite)) + } + { + const prefix string = ",\"ice-tcp\":" + out.RawString(prefix) + out.Bool(bool(in.ICETCP)) + } + if in.ICENomination != "" { + const prefix string = ",\"ice-nomination\":" + out.RawString(prefix) + out.String(string(in.ICENomination)) + } + { + const prefix string = ",\"ice-consent-freshness\":" + out.RawString(prefix) + out.Bool(bool(in.ICEConsentFreshness)) + } + { + const prefix string = ",\"ice-keepalive-conncheck\":" + out.RawString(prefix) + out.Bool(bool(in.ICEKeepaliveConncheck)) + } + { + const prefix string = ",\"hangup-on-failed\":" + out.RawString(prefix) + out.Bool(bool(in.HangupOnFailed)) + } + { + const prefix string = ",\"full-trickle\":" + out.RawString(prefix) + out.Bool(bool(in.FullTrickle)) + } + { + const prefix string = ",\"mdns-enabled\":" + out.RawString(prefix) + out.Bool(bool(in.MDNSEnabled)) + } + { + const prefix string = ",\"min-nack-queue\":" + out.RawString(prefix) + out.Int(int(in.MinNACKQueue)) + } + { + const prefix string = ",\"nack-optimizations\":" + out.RawString(prefix) + out.Bool(bool(in.NACKOptimizations)) + } + { + const prefix string = ",\"twcc-period\":" + out.RawString(prefix) + out.Int(int(in.TWCCPeriod)) + } + if in.DSCP != 0 { + const prefix string = ",\"dscp\":" + out.RawString(prefix) + out.Int(int(in.DSCP)) + } + { + const prefix string = ",\"dtls-mcu\":" + out.RawString(prefix) + out.Int(int(in.DTLSMCU)) + } + if in.STUNServer != "" { + const prefix string = ",\"stun-server\":" + out.RawString(prefix) + out.String(string(in.STUNServer)) + } + if in.TURNServer != "" { + const prefix string = ",\"turn-server\":" + out.RawString(prefix) + out.String(string(in.TURNServer)) + } + if in.AllowForceRelay { + const prefix string = ",\"allow-force-relay\":" + out.RawString(prefix) + out.Bool(bool(in.AllowForceRelay)) + } + { + const prefix string = ",\"static-event-loops\":" + out.RawString(prefix) + out.Int(int(in.StaticEventLoops)) + } + if in.LoopIndication { + const prefix string = ",\"loop-indication\":" + out.RawString(prefix) + out.Bool(bool(in.LoopIndication)) + } + { + const prefix string = ",\"api_secret\":" + out.RawString(prefix) + out.Bool(bool(in.APISecret)) + } + { + const prefix string = ",\"auth_token\":" + out.RawString(prefix) + out.Bool(bool(in.AuthToken)) + } + { + const prefix string = ",\"event_handlers\":" + out.RawString(prefix) + out.Bool(bool(in.EventHandlers)) + } + { + const prefix string = ",\"opaqueid_in_api\":" + out.RawString(prefix) + out.Bool(bool(in.OpaqueIdInAPI)) + } + { + const prefix string = ",\"webrtc_encryption\":" + out.RawString(prefix) + out.Bool(bool(in.WebRTCEncryption)) + } + if in.Dependencies != nil { + const prefix string = ",\"dependencies\":" + out.RawString(prefix) + (*in.Dependencies).MarshalEasyJSON(out) + } + if len(in.Transports) != 0 { + const prefix string = ",\"transports\":" + out.RawString(prefix) + { + out.RawByte('{') + v8First := true + for v8Name, v8Value := range in.Transports { + if v8First { + v8First = false + } else { + out.RawByte(',') + } + out.String(string(v8Name)) + out.RawByte(':') + (v8Value).MarshalEasyJSON(out) + } + out.RawByte('}') + } + } + if len(in.Events) != 0 { + const prefix string = ",\"events\":" + out.RawString(prefix) + { + out.RawByte('{') + v9First := true + for v9Name, v9Value := range in.Events { + if v9First { + v9First = false + } else { + out.RawByte(',') + } + out.String(string(v9Name)) + out.RawByte(':') + (v9Value).MarshalEasyJSON(out) + } + out.RawByte('}') + } + } + if len(in.Loggers) != 0 { + const prefix string = ",\"loggers\":" + out.RawString(prefix) + { + out.RawByte('{') + v10First := true + for v10Name, v10Value := range in.Loggers { + if v10First { + v10First = false + } else { + out.RawByte(',') + } + out.String(string(v10Name)) + out.RawByte(':') + (v10Value).MarshalEasyJSON(out) + } + out.RawByte('}') + } + } + if len(in.Plugins) != 0 { + const prefix string = ",\"plugins\":" + out.RawString(prefix) + { + out.RawByte('{') + v11First := true + for v11Name, v11Value := range in.Plugins { + if v11First { + v11First = false + } else { + out.RawByte(',') + } + out.String(string(v11Name)) + out.RawByte(':') + (v11Value).MarshalEasyJSON(out) + } + out.RawByte('}') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventStatusStartupInfo) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus10(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventStatusStartupInfo) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus10(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventStatusStartupInfo) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus10(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventStatusStartupInfo) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus10(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus11(in *jlexer.Lexer, out *janusEventSessionTransport) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "transport": + if in.IsNull() { + in.Skip() + } else { + out.Transport = string(in.String()) + } + case "id": + if in.IsNull() { + in.Skip() + } else { + out.ID = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus11(out *jwriter.Writer, in janusEventSessionTransport) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"transport\":" + out.RawString(prefix[1:]) + out.String(string(in.Transport)) + } + { + const prefix string = ",\"id\":" + out.RawString(prefix) + out.String(string(in.ID)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventSessionTransport) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus11(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventSessionTransport) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus11(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventSessionTransport) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus11(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventSessionTransport) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus11(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus12(in *jlexer.Lexer, out *janusEventSession) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "name": + if in.IsNull() { + in.Skip() + } else { + out.Name = string(in.String()) + } + case "transport": + if in.IsNull() { + in.Skip() + out.Transport = nil + } else { + if out.Transport == nil { + out.Transport = new(janusEventSessionTransport) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Transport).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus12(out *jwriter.Writer, in janusEventSession) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"name\":" + out.RawString(prefix[1:]) + out.String(string(in.Name)) + } + if in.Transport != nil { + const prefix string = ",\"transport\":" + out.RawString(prefix) + (*in.Transport).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventSession) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus12(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventSession) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus12(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventSession) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus12(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventSession) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus12(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus13(in *jlexer.Lexer, out *janusEventPluginInfo) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "name": + if in.IsNull() { + in.Skip() + } else { + out.Name = string(in.String()) + } + case "author": + if in.IsNull() { + in.Skip() + } else { + out.Author = string(in.String()) + } + case "description": + if in.IsNull() { + in.Skip() + } else { + out.Description = string(in.String()) + } + case "version_string": + if in.IsNull() { + in.Skip() + } else { + out.VersionString = string(in.String()) + } + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus13(out *jwriter.Writer, in janusEventPluginInfo) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"name\":" + out.RawString(prefix[1:]) + out.String(string(in.Name)) + } + { + const prefix string = ",\"author\":" + out.RawString(prefix) + out.String(string(in.Author)) + } + { + const prefix string = ",\"description\":" + out.RawString(prefix) + out.String(string(in.Description)) + } + { + const prefix string = ",\"version_string\":" + out.RawString(prefix) + out.String(string(in.VersionString)) + } + { + const prefix string = ",\"version\":" + out.RawString(prefix) + out.Int(int(in.Version)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventPluginInfo) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus13(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventPluginInfo) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus13(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventPluginInfo) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus13(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventPluginInfo) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus13(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus14(in *jlexer.Lexer, out *janusEventPlugin) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "plugin": + if in.IsNull() { + in.Skip() + } else { + out.Plugin = string(in.String()) + } + case "data": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Data).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus14(out *jwriter.Writer, in janusEventPlugin) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"plugin\":" + out.RawString(prefix[1:]) + out.String(string(in.Plugin)) + } + { + const prefix string = ",\"data\":" + out.RawString(prefix) + out.Raw((in.Data).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventPlugin) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus14(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventPlugin) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus14(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventPlugin) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus14(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventPlugin) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus14(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus15(in *jlexer.Lexer, out *janusEventMediaStats) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "mid": + if in.IsNull() { + in.Skip() + } else { + out.MID = string(in.String()) + } + case "mindex": + if in.IsNull() { + in.Skip() + } else { + out.MIndex = int(in.Int()) + } + case "media": + if in.IsNull() { + in.Skip() + } else { + out.Media = string(in.String()) + } + case "codec": + if in.IsNull() { + in.Skip() + } else { + out.Codec = string(in.String()) + } + case "base": + if in.IsNull() { + in.Skip() + } else { + out.Base = uint32(in.Uint32()) + } + case "lost": + if in.IsNull() { + in.Skip() + } else { + out.Lost = int32(in.Int32()) + } + case "lost-by-remote": + if in.IsNull() { + in.Skip() + } else { + out.LostByRemote = int32(in.Int32()) + } + case "jitter-local": + if in.IsNull() { + in.Skip() + } else { + out.JitterLocal = uint32(in.Uint32()) + } + case "jitter-remote": + if in.IsNull() { + in.Skip() + } else { + out.JitterRemote = uint32(in.Uint32()) + } + case "in-link-quality": + if in.IsNull() { + in.Skip() + } else { + out.InLinkQuality = uint32(in.Uint32()) + } + case "in-media-link-quality": + if in.IsNull() { + in.Skip() + } else { + out.InMediaLinkQuality = uint32(in.Uint32()) + } + case "out-link-quality": + if in.IsNull() { + in.Skip() + } else { + out.OutLinkQuality = uint32(in.Uint32()) + } + case "out-media-link-quality": + if in.IsNull() { + in.Skip() + } else { + out.OutMediaLinkQuality = uint32(in.Uint32()) + } + case "bytes-received-lastsec": + if in.IsNull() { + in.Skip() + } else { + out.BytesReceivedLastSec = uint32(in.Uint32()) + } + case "bytes-sent-lastsec": + if in.IsNull() { + in.Skip() + } else { + out.BytesSentLastSec = uint32(in.Uint32()) + } + case "nacks-received": + if in.IsNull() { + in.Skip() + } else { + out.NacksReceived = uint32(in.Uint32()) + } + case "nacks-sent": + if in.IsNull() { + in.Skip() + } else { + out.NacksSent = uint32(in.Uint32()) + } + case "retransmissions-received": + if in.IsNull() { + in.Skip() + } else { + out.RetransmissionsReceived = uint32(in.Uint32()) + } + case "rtt": + if in.IsNull() { + in.Skip() + } else { + out.RTT = uint32(in.Uint32()) + } + case "rtt-values": + if in.IsNull() { + in.Skip() + out.RTTValues = nil + } else { + if out.RTTValues == nil { + out.RTTValues = new(janusMediaStatsRTTValues) + } + if in.IsNull() { + in.Skip() + } else { + (*out.RTTValues).UnmarshalEasyJSON(in) + } + } + case "packets-received": + if in.IsNull() { + in.Skip() + } else { + out.PacketsReceived = uint32(in.Uint32()) + } + case "packets-sent": + if in.IsNull() { + in.Skip() + } else { + out.PacketsSent = uint32(in.Uint32()) + } + case "bytes-received": + if in.IsNull() { + in.Skip() + } else { + out.BytesReceived = uint64(in.Uint64()) + } + case "bytes-sent": + if in.IsNull() { + in.Skip() + } else { + out.BytesSent = uint64(in.Uint64()) + } + case "remb-bitrate": + if in.IsNull() { + in.Skip() + } else { + out.REMBBitrate = uint32(in.Uint32()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus15(out *jwriter.Writer, in janusEventMediaStats) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"mid\":" + out.RawString(prefix[1:]) + out.String(string(in.MID)) + } + { + const prefix string = ",\"mindex\":" + out.RawString(prefix) + out.Int(int(in.MIndex)) + } + { + const prefix string = ",\"media\":" + out.RawString(prefix) + out.String(string(in.Media)) + } + if in.Codec != "" { + const prefix string = ",\"codec\":" + out.RawString(prefix) + out.String(string(in.Codec)) + } + { + const prefix string = ",\"base\":" + out.RawString(prefix) + out.Uint32(uint32(in.Base)) + } + { + const prefix string = ",\"lost\":" + out.RawString(prefix) + out.Int32(int32(in.Lost)) + } + { + const prefix string = ",\"lost-by-remote\":" + out.RawString(prefix) + out.Int32(int32(in.LostByRemote)) + } + { + const prefix string = ",\"jitter-local\":" + out.RawString(prefix) + out.Uint32(uint32(in.JitterLocal)) + } + { + const prefix string = ",\"jitter-remote\":" + out.RawString(prefix) + out.Uint32(uint32(in.JitterRemote)) + } + { + const prefix string = ",\"in-link-quality\":" + out.RawString(prefix) + out.Uint32(uint32(in.InLinkQuality)) + } + { + const prefix string = ",\"in-media-link-quality\":" + out.RawString(prefix) + out.Uint32(uint32(in.InMediaLinkQuality)) + } + { + const prefix string = ",\"out-link-quality\":" + out.RawString(prefix) + out.Uint32(uint32(in.OutLinkQuality)) + } + { + const prefix string = ",\"out-media-link-quality\":" + out.RawString(prefix) + out.Uint32(uint32(in.OutMediaLinkQuality)) + } + { + const prefix string = ",\"bytes-received-lastsec\":" + out.RawString(prefix) + out.Uint32(uint32(in.BytesReceivedLastSec)) + } + { + const prefix string = ",\"bytes-sent-lastsec\":" + out.RawString(prefix) + out.Uint32(uint32(in.BytesSentLastSec)) + } + { + const prefix string = ",\"nacks-received\":" + out.RawString(prefix) + out.Uint32(uint32(in.NacksReceived)) + } + { + const prefix string = ",\"nacks-sent\":" + out.RawString(prefix) + out.Uint32(uint32(in.NacksSent)) + } + { + const prefix string = ",\"retransmissions-received\":" + out.RawString(prefix) + out.Uint32(uint32(in.RetransmissionsReceived)) + } + if in.RTT != 0 { + const prefix string = ",\"rtt\":" + out.RawString(prefix) + out.Uint32(uint32(in.RTT)) + } + if in.RTTValues != nil { + const prefix string = ",\"rtt-values\":" + out.RawString(prefix) + (*in.RTTValues).MarshalEasyJSON(out) + } + { + const prefix string = ",\"packets-received\":" + out.RawString(prefix) + out.Uint32(uint32(in.PacketsReceived)) + } + { + const prefix string = ",\"packets-sent\":" + out.RawString(prefix) + out.Uint32(uint32(in.PacketsSent)) + } + { + const prefix string = ",\"bytes-received\":" + out.RawString(prefix) + out.Uint64(uint64(in.BytesReceived)) + } + { + const prefix string = ",\"bytes-sent\":" + out.RawString(prefix) + out.Uint64(uint64(in.BytesSent)) + } + { + const prefix string = ",\"remb-bitrate\":" + out.RawString(prefix) + out.Uint32(uint32(in.REMBBitrate)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventMediaStats) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus15(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventMediaStats) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus15(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventMediaStats) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus15(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventMediaStats) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus15(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus16(in *jlexer.Lexer, out *janusEventMediaState) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "media": + if in.IsNull() { + in.Skip() + } else { + out.Media = string(in.String()) + } + case "mid": + if in.IsNull() { + in.Skip() + } else { + out.MID = string(in.String()) + } + case "substream": + if in.IsNull() { + in.Skip() + out.SubStream = nil + } else { + if out.SubStream == nil { + out.SubStream = new(int) + } + if in.IsNull() { + in.Skip() + } else { + *out.SubStream = int(in.Int()) + } + } + case "receiving": + if in.IsNull() { + in.Skip() + } else { + out.Receiving = bool(in.Bool()) + } + case "seconds": + if in.IsNull() { + in.Skip() + } else { + out.Seconds = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus16(out *jwriter.Writer, in janusEventMediaState) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"media\":" + out.RawString(prefix[1:]) + out.String(string(in.Media)) + } + { + const prefix string = ",\"mid\":" + out.RawString(prefix) + out.String(string(in.MID)) + } + if in.SubStream != nil { + const prefix string = ",\"substream\":" + out.RawString(prefix) + out.Int(int(*in.SubStream)) + } + { + const prefix string = ",\"receiving\":" + out.RawString(prefix) + out.Bool(bool(in.Receiving)) + } + { + const prefix string = ",\"seconds\":" + out.RawString(prefix) + out.Int(int(in.Seconds)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventMediaState) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus16(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventMediaState) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus16(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventMediaState) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus16(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventMediaState) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus16(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus17(in *jlexer.Lexer, out *janusEventMediaSlowLink) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "media": + if in.IsNull() { + in.Skip() + } else { + out.Media = string(in.String()) + } + case "mid": + if in.IsNull() { + in.Skip() + } else { + out.MID = string(in.String()) + } + case "slow_link": + if in.IsNull() { + in.Skip() + } else { + out.SlowLink = string(in.String()) + } + case "lost_lastsec": + if in.IsNull() { + in.Skip() + } else { + out.LostLastSec = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus17(out *jwriter.Writer, in janusEventMediaSlowLink) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"media\":" + out.RawString(prefix[1:]) + out.String(string(in.Media)) + } + { + const prefix string = ",\"mid\":" + out.RawString(prefix) + out.String(string(in.MID)) + } + { + const prefix string = ",\"slow_link\":" + out.RawString(prefix) + out.String(string(in.SlowLink)) + } + { + const prefix string = ",\"lost_lastsec\":" + out.RawString(prefix) + out.Int(int(in.LostLastSec)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventMediaSlowLink) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus17(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventMediaSlowLink) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus17(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventMediaSlowLink) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus17(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventMediaSlowLink) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus17(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus18(in *jlexer.Lexer, out *janusEventJSEP) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "owner": + if in.IsNull() { + in.Skip() + } else { + out.Owner = string(in.String()) + } + case "jsep": + easyjsonC1cedd36Decode(in, &out.Jsep) + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus18(out *jwriter.Writer, in janusEventJSEP) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"owner\":" + out.RawString(prefix[1:]) + out.String(string(in.Owner)) + } + { + const prefix string = ",\"jsep\":" + out.RawString(prefix) + easyjsonC1cedd36Encode(out, in.Jsep) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventJSEP) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus18(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventJSEP) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus18(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventJSEP) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus18(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventJSEP) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus18(l, v) +} +func easyjsonC1cedd36Decode(in *jlexer.Lexer, out *struct { + Type string `json:"type"` + SDP string `json:"sdp"` +}) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "sdp": + if in.IsNull() { + in.Skip() + } else { + out.SDP = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36Encode(out *jwriter.Writer, in struct { + Type string `json:"type"` + SDP string `json:"sdp"` +}) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"type\":" + out.RawString(prefix[1:]) + out.String(string(in.Type)) + } + { + const prefix string = ",\"sdp\":" + out.RawString(prefix) + out.String(string(in.SDP)) + } + out.RawByte('}') +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus19(in *jlexer.Lexer, out *janusEventHandle) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "name": + if in.IsNull() { + in.Skip() + } else { + out.Name = string(in.String()) + } + case "plugin": + if in.IsNull() { + in.Skip() + } else { + out.Plugin = string(in.String()) + } + case "token": + if in.IsNull() { + in.Skip() + } else { + out.Token = string(in.String()) + } + case "opaque_id": + if in.IsNull() { + in.Skip() + } else { + out.OpaqueId = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus19(out *jwriter.Writer, in janusEventHandle) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"name\":" + out.RawString(prefix[1:]) + out.String(string(in.Name)) + } + { + const prefix string = ",\"plugin\":" + out.RawString(prefix) + out.String(string(in.Plugin)) + } + if in.Token != "" { + const prefix string = ",\"token\":" + out.RawString(prefix) + out.String(string(in.Token)) + } + if in.OpaqueId != "" { + const prefix string = ",\"opaque_id\":" + out.RawString(prefix) + out.String(string(in.OpaqueId)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventHandle) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus19(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventHandle) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus19(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventHandle) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus19(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventHandle) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus19(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus20(in *jlexer.Lexer, out *janusEventExternal) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "schema": + if in.IsNull() { + in.Skip() + } else { + out.Schema = string(in.String()) + } + case "data": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Data).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus20(out *jwriter.Writer, in janusEventExternal) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"schema\":" + out.RawString(prefix[1:]) + out.String(string(in.Schema)) + } + { + const prefix string = ",\"data\":" + out.RawString(prefix) + out.Raw((in.Data).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventExternal) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus20(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventExternal) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus20(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventExternal) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus20(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventExternal) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus20(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus21(in *jlexer.Lexer, out *janusEventDependenciesInfo) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "glib2": + if in.IsNull() { + in.Skip() + } else { + out.Glib2 = string(in.String()) + } + case "jansson": + if in.IsNull() { + in.Skip() + } else { + out.Jansson = string(in.String()) + } + case "libnice": + if in.IsNull() { + in.Skip() + } else { + out.Libnice = string(in.String()) + } + case "libsrtp": + if in.IsNull() { + in.Skip() + } else { + out.Libsrtp = string(in.String()) + } + case "libcurl": + if in.IsNull() { + in.Skip() + } else { + out.Libcurl = string(in.String()) + } + case "crypto": + if in.IsNull() { + in.Skip() + } else { + out.Crypto = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus21(out *jwriter.Writer, in janusEventDependenciesInfo) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"glib2\":" + out.RawString(prefix[1:]) + out.String(string(in.Glib2)) + } + { + const prefix string = ",\"jansson\":" + out.RawString(prefix) + out.String(string(in.Jansson)) + } + { + const prefix string = ",\"libnice\":" + out.RawString(prefix) + out.String(string(in.Libnice)) + } + { + const prefix string = ",\"libsrtp\":" + out.RawString(prefix) + out.String(string(in.Libsrtp)) + } + if in.Libcurl != "" { + const prefix string = ",\"libcurl\":" + out.RawString(prefix) + out.String(string(in.Libcurl)) + } + { + const prefix string = ",\"crypto\":" + out.RawString(prefix) + out.String(string(in.Crypto)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventDependenciesInfo) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus21(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventDependenciesInfo) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus21(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventDependenciesInfo) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus21(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventDependenciesInfo) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus21(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus22(in *jlexer.Lexer, out *janusEventCoreStartup) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "status": + if in.IsNull() { + in.Skip() + } else { + out.Status = string(in.String()) + } + case "info": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Info).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus22(out *jwriter.Writer, in janusEventCoreStartup) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"status\":" + out.RawString(prefix[1:]) + out.String(string(in.Status)) + } + { + const prefix string = ",\"info\":" + out.RawString(prefix) + out.Raw((in.Info).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventCoreStartup) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus22(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventCoreStartup) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus22(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventCoreStartup) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus22(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventCoreStartup) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus22(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus23(in *jlexer.Lexer, out *janusEventCoreShutdown) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "status": + if in.IsNull() { + in.Skip() + } else { + out.Status = string(in.String()) + } + case "signum": + if in.IsNull() { + in.Skip() + } else { + out.Signum = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus23(out *jwriter.Writer, in janusEventCoreShutdown) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"status\":" + out.RawString(prefix[1:]) + out.String(string(in.Status)) + } + { + const prefix string = ",\"signum\":" + out.RawString(prefix) + out.Int(int(in.Signum)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventCoreShutdown) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus23(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventCoreShutdown) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus23(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventCoreShutdown) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus23(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventCoreShutdown) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus23(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus24(in *jlexer.Lexer, out *janusEventCandidates) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "local": + if in.IsNull() { + in.Skip() + } else { + (out.Local).UnmarshalEasyJSON(in) + } + case "remote": + if in.IsNull() { + in.Skip() + } else { + (out.Remote).UnmarshalEasyJSON(in) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus24(out *jwriter.Writer, in janusEventCandidates) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"local\":" + out.RawString(prefix[1:]) + (in.Local).MarshalEasyJSON(out) + } + { + const prefix string = ",\"remote\":" + out.RawString(prefix) + (in.Remote).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventCandidates) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus24(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventCandidates) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus24(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventCandidates) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus24(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventCandidates) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus24(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus25(in *jlexer.Lexer, out *janusEventCandidate) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "address": + if in.IsNull() { + in.Skip() + } else { + out.Address = string(in.String()) + } + case "port": + if in.IsNull() { + in.Skip() + } else { + out.Port = int(in.Int()) + } + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "transport": + if in.IsNull() { + in.Skip() + } else { + out.Transport = string(in.String()) + } + case "family": + if in.IsNull() { + in.Skip() + } else { + out.Family = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus25(out *jwriter.Writer, in janusEventCandidate) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"address\":" + out.RawString(prefix[1:]) + out.String(string(in.Address)) + } + { + const prefix string = ",\"port\":" + out.RawString(prefix) + out.Int(int(in.Port)) + } + { + const prefix string = ",\"type\":" + out.RawString(prefix) + out.String(string(in.Type)) + } + { + const prefix string = ",\"transport\":" + out.RawString(prefix) + out.String(string(in.Transport)) + } + { + const prefix string = ",\"family\":" + out.RawString(prefix) + out.Int(int(in.Family)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEventCandidate) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus25(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEventCandidate) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus25(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEventCandidate) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus25(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEventCandidate) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus25(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus26(in *jlexer.Lexer, out *janusEvent) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "emitter": + if in.IsNull() { + in.Skip() + } else { + out.Emitter = string(in.String()) + } + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = int(in.Int()) + } + case "subtype": + if in.IsNull() { + in.Skip() + } else { + out.SubType = int(in.Int()) + } + case "timestamp": + if in.IsNull() { + in.Skip() + } else { + out.Timestamp = uint64(in.Uint64()) + } + case "session_id": + if in.IsNull() { + in.Skip() + } else { + out.SessionId = uint64(in.Uint64()) + } + case "handle_id": + if in.IsNull() { + in.Skip() + } else { + out.HandleId = uint64(in.Uint64()) + } + case "opaque_id": + if in.IsNull() { + in.Skip() + } else { + out.OpaqueId = uint64(in.Uint64()) + } + case "event": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Event).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus26(out *jwriter.Writer, in janusEvent) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"emitter\":" + out.RawString(prefix[1:]) + out.String(string(in.Emitter)) + } + { + const prefix string = ",\"type\":" + out.RawString(prefix) + out.Int(int(in.Type)) + } + if in.SubType != 0 { + const prefix string = ",\"subtype\":" + out.RawString(prefix) + out.Int(int(in.SubType)) + } + { + const prefix string = ",\"timestamp\":" + out.RawString(prefix) + out.Uint64(uint64(in.Timestamp)) + } + if in.SessionId != 0 { + const prefix string = ",\"session_id\":" + out.RawString(prefix) + out.Uint64(uint64(in.SessionId)) + } + if in.HandleId != 0 { + const prefix string = ",\"handle_id\":" + out.RawString(prefix) + out.Uint64(uint64(in.HandleId)) + } + if in.OpaqueId != 0 { + const prefix string = ",\"opaque_id\":" + out.RawString(prefix) + out.Uint64(uint64(in.OpaqueId)) + } + { + const prefix string = ",\"event\":" + out.RawString(prefix) + out.Raw((in.Event).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v janusEvent) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus26(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v janusEvent) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus26(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *janusEvent) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus26(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *janusEvent) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2SfuJanus26(l, v) +} diff --git a/sfu/janus/client.go b/sfu/janus/client.go new file mode 100644 index 0000000..04b3593 --- /dev/null +++ b/sfu/janus/client.go @@ -0,0 +1,259 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "context" + "reflect" + "strconv" + "sync" + "sync/atomic" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/janus" +) + +type janusClient struct { + logger log.Logger + mcu *janusSFU + listener sfu.Listener + mu sync.Mutex + + id uint64 + session uint64 + roomId uint64 + sid string + streamType sfu.StreamType + maxBitrate api.Bandwidth + + // +checklocks:mu + bandwidth map[string]*sfu.ClientBandwidthInfo + + handle atomic.Pointer[janus.Handle] + handleId atomic.Uint64 + closeChan chan struct{} + deferred chan func() + + handleEvent func(event *janus.EventMsg) + handleHangup func(event *janus.HangupMsg) + handleDetached func(event *janus.DetachedMsg) + handleConnected func(event *janus.WebRTCUpMsg) + handleSlowLink func(event *janus.SlowLinkMsg) + handleMedia func(event *janus.MediaMsg) +} + +func (c *janusClient) Id() string { + return strconv.FormatUint(c.id, 10) +} + +func (c *janusClient) Sid() string { + return c.sid +} + +func (c *janusClient) Handle() uint64 { + return c.handleId.Load() +} + +func (c *janusClient) StreamType() sfu.StreamType { + return c.streamType +} + +func (c *janusClient) MaxBitrate() api.Bandwidth { + return c.maxBitrate +} + +func (c *janusClient) Close(ctx context.Context) { +} + +func (c *janusClient) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { +} + +func (c *janusClient) UpdateBandwidth(media string, sent api.Bandwidth, received api.Bandwidth) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.bandwidth == nil { + c.bandwidth = make(map[string]*sfu.ClientBandwidthInfo) + } + + info, found := c.bandwidth[media] + if !found { + info = &sfu.ClientBandwidthInfo{} + c.bandwidth[media] = info + } + + info.Sent = sent + info.Received = received +} + +func (c *janusClient) Bandwidth() *sfu.ClientBandwidthInfo { + c.mu.Lock() + defer c.mu.Unlock() + + if c.bandwidth == nil { + return nil + } + + result := &sfu.ClientBandwidthInfo{} + for _, info := range c.bandwidth { + result.Received += info.Received + result.Sent += info.Sent + } + return result +} + +func (c *janusClient) closeClient(ctx context.Context) bool { + if handle := c.handle.Swap(nil); handle != nil { + close(c.closeChan) + if _, err := handle.Detach(ctx); err != nil { + if e, ok := err.(*janus.ErrorMsg); !ok || e.Err.Code != janus.JANUS_ERROR_HANDLE_NOT_FOUND { + c.logger.Println("Could not detach client", handle.Id, err) + } + } + return true + } + + return false +} + +func (c *janusClient) run(handle *janus.Handle, closeChan <-chan struct{}) { +loop: + for { + select { + case msg := <-handle.Events: + switch t := msg.(type) { + case *janus.EventMsg: + c.handleEvent(t) + case *janus.HangupMsg: + c.handleHangup(t) + case *janus.DetachedMsg: + c.handleDetached(t) + case *janus.MediaMsg: + c.handleMedia(t) + case *janus.WebRTCUpMsg: + c.handleConnected(t) + case *janus.SlowLinkMsg: + c.handleSlowLink(t) + case *janus.TrickleMsg: + c.handleTrickle(t) + default: + c.logger.Println("Received unsupported event type", msg, reflect.TypeOf(msg)) + } + case f := <-c.deferred: + f() + case <-closeChan: + break loop + } + } +} + +func (c *janusClient) sendOffer(ctx context.Context, offer api.StringMap, callback func(error, api.StringMap)) { + handle := c.handle.Load() + if handle == nil { + callback(sfu.ErrNotConnected, nil) + return + } + + configure_msg := api.StringMap{ + "request": "configure", + "audio": true, + "video": true, + "data": true, + } + answer_msg, err := handle.Message(ctx, configure_msg, offer) + if err != nil { + callback(err, nil) + return + } + + callback(nil, answer_msg.Jsep) +} + +func (c *janusClient) sendAnswer(ctx context.Context, answer api.StringMap, callback func(error, api.StringMap)) { + handle := c.handle.Load() + if handle == nil { + callback(sfu.ErrNotConnected, nil) + return + } + + start_msg := api.StringMap{ + "request": "start", + "room": c.roomId, + } + start_response, err := handle.Message(ctx, start_msg, answer) + if err != nil { + callback(err, nil) + return + } + + c.logger.Println("Started listener", start_response) + callback(nil, nil) +} + +func (c *janusClient) sendCandidate(ctx context.Context, candidate any, callback func(error, api.StringMap)) { + handle := c.handle.Load() + if handle == nil { + callback(sfu.ErrNotConnected, nil) + return + } + + if _, err := handle.Trickle(ctx, candidate); err != nil { + callback(err, nil) + return + } + callback(nil, nil) +} + +func (c *janusClient) handleTrickle(event *janus.TrickleMsg) { + if event.Candidate.Completed { + c.listener.OnIceCompleted(c) + } else { + c.listener.OnIceCandidate(c, event.Candidate) + } +} + +func (c *janusClient) selectStream(ctx context.Context, stream *streamSelection, callback func(error, api.StringMap)) { + handle := c.handle.Load() + if handle == nil { + callback(sfu.ErrNotConnected, nil) + return + } + + if stream == nil || !stream.HasValues() { + callback(nil, nil) + return + } + + configure_msg := api.StringMap{ + "request": "configure", + } + stream.AddToMessage(configure_msg) + _, err := handle.Message(ctx, configure_msg, nil) + if err != nil { + callback(err, nil) + return + } + + callback(nil, nil) +} diff --git a/sfu/janus/events_handler.go b/sfu/janus/events_handler.go new file mode 100644 index 0000000..37cd4f3 --- /dev/null +++ b/sfu/janus/events_handler.go @@ -0,0 +1,461 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/pool" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" +) + +const ( + EventsSubprotocol = "janus-events" + + // Time allowed to write a message to the peer. + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 60 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 +) + +var ( + bufferPool pool.BufferPool +) + +type EventHandler interface { + UpdateBandwidth(handle uint64, media string, sent api.Bandwidth, received api.Bandwidth) +} + +type valueCounter struct { + values map[string]uint64 +} + +func (c *valueCounter) Update(key string, value uint64) uint64 { + if c.values == nil { + c.values = make(map[string]uint64) + } + + var delta uint64 + prev := c.values[key] + if value == prev { + return 0 + } else if value < prev { + // Wrap around + c.values[key] = 0 + delta = math.MaxUint64 - prev + value + } else { + delta = value - prev + } + + c.values[key] += delta + return delta +} + +type handleStats struct { + codecs map[string]string + + bytesReceived valueCounter + bytesSent valueCounter + + nacksReceived valueCounter + nacksSent valueCounter + + lostLocal valueCounter + lostRemote valueCounter + + retransmissionsReceived valueCounter +} + +func (h *handleStats) Codec(media string, codec string) { + if h.codecs == nil { + h.codecs = make(map[string]string) + } + if h.codecs[media] != codec { + statsJanusMediaCodecsTotal.WithLabelValues(media, codec).Inc() + h.codecs[media] = codec + } +} + +func (h *handleStats) BytesReceived(media string, bytes uint64) { + delta := h.bytesReceived.Update(media, bytes) + statsJanusMediaBytesTotal.WithLabelValues(media, "incoming").Add(float64(delta)) +} + +func (h *handleStats) BytesSent(media string, bytes uint64) { + delta := h.bytesSent.Update(media, bytes) + statsJanusMediaBytesTotal.WithLabelValues(media, "outgoing").Add(float64(delta)) +} + +func (h *handleStats) NacksReceived(media string, nacks uint64) { + delta := h.nacksReceived.Update(media, nacks) + statsJanusMediaNACKTotal.WithLabelValues(media, "incoming").Add(float64(delta)) +} + +func (h *handleStats) NacksSent(media string, nacks uint64) { + delta := h.nacksSent.Update(media, nacks) + statsJanusMediaNACKTotal.WithLabelValues(media, "outgoing").Add(float64(delta)) +} + +func (h *handleStats) RetransmissionsReceived(media string, retransmissions uint64) { + delta := h.retransmissionsReceived.Update(media, retransmissions) + statsJanusMediaRetransmissionsTotal.WithLabelValues(media).Add(float64(delta)) +} + +func (h *handleStats) LostLocal(media string, lost uint64) { + delta := h.lostLocal.Update(media, lost) + statsJanusMediaLostTotal.WithLabelValues(media, "local").Add(float64(delta)) +} + +func (h *handleStats) LostRemote(media string, lost uint64) { + delta := h.lostRemote.Update(media, lost) + statsJanusMediaLostTotal.WithLabelValues(media, "remote").Add(float64(delta)) +} + +type EventsHandler struct { + mu sync.Mutex + + logger log.Logger + ctx context.Context + mcu EventHandler + // +checklocks:mu + conn *websocket.Conn + addr string + agent string + + supportsHandles bool + handleStats map[uint64]*handleStats + + events chan janusEvent +} + +func RunEventsHandler(ctx context.Context, mcu sfu.SFU, conn *websocket.Conn, addr string, agent string) { + deadline := time.Now().Add(time.Second) + if mcu == nil { + conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "no mcu configured"), deadline) // nolint + return + } + + m, ok := mcu.(EventHandler) + if !ok { + conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "mcu does not support events"), deadline) // nolint + return + } + + if !internal.IsLoopbackIP(addr) && !internal.IsPrivateIP(addr) { + conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "only loopback and private connections allowed"), deadline) // nolint + return + } + + client, err := NewEventsHandler(ctx, m, conn, addr, agent) + if err != nil { + logger := log.LoggerFromContext(ctx) + logger.Printf("Could not create Janus events handler for %s: %s", addr, err) + conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "error creating handler"), deadline) // nolint + return + } + + client.Run() +} + +func NewEventsHandler(ctx context.Context, mcu EventHandler, conn *websocket.Conn, addr string, agent string) (*EventsHandler, error) { + handler := &EventsHandler{ + logger: log.LoggerFromContext(ctx), + ctx: ctx, + mcu: mcu, + conn: conn, + addr: addr, + agent: agent, + + events: make(chan janusEvent, 1), + } + + return handler, nil +} + +func (h *EventsHandler) Run() { + h.logger.Printf("Processing Janus events from %s", h.addr) + go h.writePump() + go h.processEvents() + + h.readPump() +} + +func (h *EventsHandler) close() { + h.mu.Lock() + conn := h.conn + h.conn = nil + h.mu.Unlock() + + if conn != nil { + if err := conn.Close(); err != nil { + h.logger.Printf("Error closing %s", err) + } + } +} + +func (h *EventsHandler) readPump() { + h.mu.Lock() + conn := h.conn + h.mu.Unlock() + if conn == nil { + h.logger.Printf("Connection from %s closed while starting readPump", h.addr) + return + } + + conn.SetPongHandler(func(msg string) error { + now := time.Now() + conn.SetReadDeadline(now.Add(pongWait)) // nolint + return nil + }) + + for { + conn.SetReadDeadline(time.Now().Add(pongWait)) // nolint + messageType, reader, err := conn.NextReader() + if err != nil { + // Gorilla websocket hides the original net.Error, so also compare error messages + if errors.Is(err, net.ErrClosed) || errors.Is(err, websocket.ErrCloseSent) || strings.Contains(err.Error(), net.ErrClosed.Error()) { + break + } else if _, ok := err.(*websocket.CloseError); !ok || websocket.IsUnexpectedCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseNoStatusReceived) { + h.logger.Printf("Error reading from %s: %v", h.addr, err) + } + break + } + + if messageType != websocket.TextMessage { + h.logger.Printf("Unsupported message type %v from %s", messageType, h.addr) + continue + } + + decodeBuffer, err := bufferPool.ReadAll(reader) + if err != nil { + h.logger.Printf("Error reading message from %s: %v", h.addr, err) + break + } + + if decodeBuffer.Len() == 0 { + h.logger.Printf("Received empty message from %s", h.addr) + bufferPool.Put(decodeBuffer) + break + } + + var events []janusEvent + if data := decodeBuffer.Bytes(); data[0] != '[' { + var event janusEvent + if err := json.Unmarshal(data, &event); err != nil { + h.logger.Printf("Error decoding message %s from %s: %v", decodeBuffer.String(), h.addr, err) + bufferPool.Put(decodeBuffer) + break + } + + events = append(events, event) + } else { + if err := json.Unmarshal(data, &events); err != nil { + h.logger.Printf("Error decoding message %s from %s: %v", decodeBuffer.String(), h.addr, err) + bufferPool.Put(decodeBuffer) + break + } + } + + bufferPool.Put(decodeBuffer) + for _, e := range events { + h.events <- e + } + } +} + +func (h *EventsHandler) sendPing() bool { + h.mu.Lock() + defer h.mu.Unlock() + if h.conn == nil { + return false + } + + now := time.Now().UnixNano() + msg := strconv.FormatInt(now, 10) + h.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint + if err := h.conn.WriteMessage(websocket.PingMessage, []byte(msg)); err != nil { + h.logger.Printf("Could not send ping to %s: %v", h.addr, err) + return false + } + + return true +} + +func (h *EventsHandler) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + h.close() + }() + + for { + select { + case <-ticker.C: + if !h.sendPing() { + return + } + case <-h.ctx.Done(): + return + } + } +} + +func (h *EventsHandler) processEvents() { + for { + select { + case event := <-h.events: + h.processEvent(event) + case <-h.ctx.Done(): + return + } + } +} + +func (h *EventsHandler) deleteHandleStats(event janusEvent) { + if event.HandleId != 0 { + delete(h.handleStats, event.HandleId) + } +} + +func (h *EventsHandler) getHandleStats(event janusEvent) *handleStats { + if !h.supportsHandles { + // Only create per-handle stats if enabled in Janus. Otherwise the + // handleStats map will never be cleaned up. + return nil + } else if event.HandleId == 0 { + return nil + } + + if h.handleStats == nil { + h.handleStats = make(map[uint64]*handleStats) + } + stats, found := h.handleStats[event.HandleId] + if !found { + stats = &handleStats{} + h.handleStats[event.HandleId] = stats + } + return stats +} + +func (h *EventsHandler) processEvent(event janusEvent) { + evt, err := event.Decode() + if err != nil { + h.logger.Printf("Error decoding event %s (%s)", event, err) + return + } + + switch evt := evt.(type) { + case *janusEventHandle: + switch evt.Name { + case "attached": + h.supportsHandles = true + case "detached": + h.deleteHandleStats(event) + } + case *janusEventWebRTCICE: + statsJanusICEStateTotal.WithLabelValues(evt.ICE).Inc() + case *janusEventWebRTCDTLS: + statsJanusDTLSStateTotal.WithLabelValues(evt.DTLS).Inc() + case *janusEventWebRTCPeerConnection: + statsJanusPeerConnectionStateTotal.WithLabelValues(evt.Connection, evt.Reason).Inc() + case *janusEventWebRTCSelectedPair: + statsJanusSelectedCandidateTotal.WithLabelValues("local", evt.Candidates.Local.Type, evt.Candidates.Local.Transport, fmt.Sprintf("ipv%d", evt.Candidates.Local.Family)).Inc() + statsJanusSelectedCandidateTotal.WithLabelValues("remote", evt.Candidates.Remote.Type, evt.Candidates.Remote.Transport, fmt.Sprintf("ipv%d", evt.Candidates.Remote.Family)).Inc() + case *janusEventMediaSlowLink: + var direction string + // "uplink" is Janus -> client, "downlink" is client -> Janus. + if evt.SlowLink == "uplink" { + direction = "outgoing" + } else { + direction = "incoming" + } + statsJanusSlowLinkTotal.WithLabelValues(evt.Media, direction).Inc() + case *janusEventMediaStats: + if rtt := evt.RTT; rtt > 0 { + statsJanusMediaRTT.WithLabelValues(evt.Media).Observe(float64(rtt)) + } + if jitter := evt.JitterLocal; jitter > 0 { + statsJanusMediaJitter.WithLabelValues(evt.Media, "local").Observe(float64(jitter)) + } + if jitter := evt.JitterRemote; jitter > 0 { + statsJanusMediaJitter.WithLabelValues(evt.Media, "remote").Observe(float64(jitter)) + } + if codec := evt.Codec; codec != "" { + if stats := h.getHandleStats(event); stats != nil { + stats.Codec(evt.Media, codec) + } + } + if stats := h.getHandleStats(event); stats != nil { + stats.BytesReceived(evt.Media, evt.BytesReceived) + } + if stats := h.getHandleStats(event); stats != nil { + stats.BytesSent(evt.Media, evt.BytesSent) + } + if nacks := evt.NacksReceived; nacks > 0 { + if stats := h.getHandleStats(event); stats != nil { + stats.NacksReceived(evt.Media, uint64(nacks)) + } + } + if nacks := evt.NacksSent; nacks > 0 { + if stats := h.getHandleStats(event); stats != nil { + stats.NacksSent(evt.Media, uint64(nacks)) + } + } + if retransmissions := evt.RetransmissionsReceived; retransmissions > 0 { + if stats := h.getHandleStats(event); stats != nil { + stats.RetransmissionsReceived(evt.Media, uint64(retransmissions)) + } + } + if lost := evt.Lost; lost > 0 { + if stats := h.getHandleStats(event); stats != nil { + stats.LostLocal(evt.Media, uint64(lost)) + } + } + if lost := evt.LostByRemote; lost > 0 { + if stats := h.getHandleStats(event); stats != nil { + stats.LostRemote(evt.Media, uint64(lost)) + } + } + h.mcu.UpdateBandwidth(event.HandleId, evt.Media, api.BandwidthFromBytes(uint64(evt.BytesSentLastSec)), api.BandwidthFromBytes(uint64(evt.BytesReceivedLastSec))) + } +} diff --git a/sfu/janus/events_handler_test.go b/sfu/janus/events_handler_test.go new file mode 100644 index 0000000..346aaa2 --- /dev/null +++ b/sfu/janus/events_handler_test.go @@ -0,0 +1,671 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + metricstest "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + sfutest "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/test" +) + +type TestJanusEventsServerHandler struct { + t *testing.T + upgrader websocket.Upgrader + + mcu sfu.SFU + addr string + wg sync.WaitGroup +} + +func (h *TestJanusEventsServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.t.Helper() + h.wg.Add(1) + defer h.wg.Done() + assert := assert.New(h.t) + conn, err := h.upgrader.Upgrade(w, r, nil) + assert.NoError(err) + + if conn.Subprotocol() == EventsSubprotocol { + addr := h.addr + if addr == "" { + addr = r.RemoteAddr + } + if host, _, err := net.SplitHostPort(addr); err == nil { + addr = host + } + logger := logtest.NewLoggerForTest(h.t) + ctx := log.NewLoggerContext(r.Context(), logger) + RunEventsHandler(ctx, h.mcu, conn, addr, r.Header.Get("User-Agent")) + return + } + + deadline := time.Now().Add(time.Second) + assert.NoError(conn.SetWriteDeadline(deadline)) + assert.NoError(conn.WriteJSON(map[string]string{"error": "invalid_subprotocol"})) + assert.NoError(conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseProtocolError, "invalid_subprotocol"), deadline)) + assert.NoError(conn.Close()) +} + +func NewTestJanusEventsHandlerServer(t *testing.T) (*httptest.Server, string, *TestJanusEventsServerHandler) { + t.Helper() + + handler := &TestJanusEventsServerHandler{ + t: t, + upgrader: websocket.Upgrader{ + Subprotocols: []string{ + EventsSubprotocol, + }, + }, + } + server := httptest.NewServer(handler) + t.Cleanup(func() { + server.Close() + server.CloseClientConnections() + handler.wg.Wait() + }) + url := strings.ReplaceAll(server.URL, "http://", "ws://") + url = strings.ReplaceAll(url, "https://", "wss://") + return server, url, handler +} + +func TestJanusEventsHandlerNoMcu(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + _, url, _ := NewTestJanusEventsHandlerServer(t) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + dialer := websocket.Dialer{ + Subprotocols: []string{ + EventsSubprotocol, + }, + } + conn, response, err := dialer.DialContext(ctx, url, nil) + require.NoError(err) + defer func() { + assert.NoError(conn.Close()) + }() + + assert.Equal(EventsSubprotocol, response.Header.Get("Sec-WebSocket-Protocol")) + + var ce *websocket.CloseError + require.NoError(conn.SetReadDeadline(time.Now().Add(testTimeout))) + if mt, msg, err := conn.ReadMessage(); err == nil { + assert.Fail("connection was not closed", "expected close error, got message %s with type %d", string(msg), mt) + } else if assert.ErrorAs(err, &ce) { + assert.Equal(websocket.CloseInternalServerErr, ce.Code) + assert.Equal("no mcu configured", ce.Text) + } +} + +func TestJanusEventsHandlerInvalidMcu(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + _, url, handler := NewTestJanusEventsHandlerServer(t) + + handler.mcu = sfutest.NewSFU(t) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + dialer := websocket.Dialer{ + Subprotocols: []string{ + EventsSubprotocol, + }, + } + conn, response, err := dialer.DialContext(ctx, url, nil) + require.NoError(err) + defer func() { + assert.NoError(conn.Close()) + }() + + assert.Equal(EventsSubprotocol, response.Header.Get("Sec-WebSocket-Protocol")) + + var ce *websocket.CloseError + require.NoError(conn.SetReadDeadline(time.Now().Add(testTimeout))) + if mt, msg, err := conn.ReadMessage(); err == nil { + assert.Fail("connection was not closed", "expected close error, got message %s with type %d", string(msg), mt) + } else if assert.ErrorAs(err, &ce) { + assert.Equal(websocket.CloseInternalServerErr, ce.Code) + assert.Equal("mcu does not support events", ce.Text) + } +} + +func TestJanusEventsHandlerPublicIP(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + _, url, handler := NewTestJanusEventsHandlerServer(t) + + handler.mcu = &janusSFU{} + handler.addr = "1.2.3.4" + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + dialer := websocket.Dialer{ + Subprotocols: []string{ + EventsSubprotocol, + }, + } + conn, response, err := dialer.DialContext(ctx, url, nil) + require.NoError(err) + defer func() { + assert.NoError(conn.Close()) + }() + + assert.Equal(EventsSubprotocol, response.Header.Get("Sec-WebSocket-Protocol")) + + var ce *websocket.CloseError + require.NoError(conn.SetReadDeadline(time.Now().Add(testTimeout))) + if mt, msg, err := conn.ReadMessage(); err == nil { + assert.Fail("connection was not closed", "expected close error, got message %s with type %d", string(msg), mt) + } else if assert.ErrorAs(err, &ce) { + assert.Equal(websocket.ClosePolicyViolation, ce.Code) + assert.Equal("only loopback and private connections allowed", ce.Text) + } +} + +type TestMcuWithEvents struct { + sfutest.SFU + + t *testing.T + mu sync.Mutex + // +checklocks:mu + idx int +} + +func (m *TestMcuWithEvents) UpdateBandwidth(handle uint64, media string, sent api.Bandwidth, received api.Bandwidth) { + assert := assert.New(m.t) + + m.mu.Lock() + defer m.mu.Unlock() + + m.idx++ + switch m.idx { + case 1: + assert.EqualValues(1, handle) + assert.Equal("audio", media) + assert.Equal(api.BandwidthFromBytes(100), sent) + assert.Equal(api.BandwidthFromBytes(200), received) + case 2: + assert.EqualValues(1, handle) + assert.Equal("video", media) + assert.Equal(api.BandwidthFromBytes(200), sent) + assert.Equal(api.BandwidthFromBytes(300), received) + default: + assert.Fail("too many updates", "received update %d (handle=%d, media=%s, sent=%d, received=%d)", m.idx, handle, media, sent, received) + } +} + +func (m *TestMcuWithEvents) WaitForUpdates(ctx context.Context, waitForIdx int) error { + for { + if err := ctx.Err(); err != nil { + return err + } + + m.mu.Lock() + idx := m.idx + m.mu.Unlock() + if idx == waitForIdx { + return nil + } + + time.Sleep(time.Millisecond) + } +} + +type janusEventSender struct { + events []janusEvent +} + +func (s *janusEventSender) SendSingle(t *testing.T, conn *websocket.Conn) { + t.Helper() + + require := require.New(t) + require.Len(s.events, 1) + + require.NoError(conn.WriteJSON(s.events[0])) + s.events = nil +} + +func (s *janusEventSender) Send(t *testing.T, conn *websocket.Conn) { + t.Helper() + + require := require.New(t) + require.NoError(conn.WriteJSON(s.events)) + s.events = nil +} + +func (s *janusEventSender) AddEvent(t *testing.T, eventType int, eventSubtype int, handleId uint64, event any) { + t.Helper() + + require := require.New(t) + assert := assert.New(t) + data, err := json.Marshal(event) + require.NoError(err) + if s, ok := event.(fmt.Stringer); assert.True(ok, "%T should implement fmt.Stringer", event) { + assert.Equal(s.String(), string(data)) + } + + message := janusEvent{ + Type: eventType, + SubType: eventSubtype, + HandleId: handleId, + Event: data, + } + + s.events = append(s.events, message) +} + +func TestJanusEventsHandlerDifferentTypes(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + _, url, handler := NewTestJanusEventsHandlerServer(t) + + mcu := &TestMcuWithEvents{ + t: t, + } + handler.mcu = mcu + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + dialer := websocket.Dialer{ + Subprotocols: []string{ + EventsSubprotocol, + }, + } + conn, response, err := dialer.DialContext(ctx, url, nil) + require.NoError(err) + defer func() { + assert.NoError(conn.Close()) + }() + + assert.Equal(EventsSubprotocol, response.Header.Get("Sec-WebSocket-Protocol")) + + var sender janusEventSender + + sender.AddEvent( + t, + janusEventTypeSession, + 0, + 1, + janusEventSession{ + Name: "created", + }, + ) + + sender.AddEvent( + t, + janusEventTypeHandle, + 0, + 1, + janusEventHandle{ + Name: "attached", + }, + ) + + sender.AddEvent( + t, + janusEventTypeExternal, + 0, + 0, + janusEventExternal{ + Schema: "test-external", + }, + ) + + sender.AddEvent( + t, + janusEventTypeJSEP, + 0, + 1, + janusEventJSEP{ + Owner: "testing", + }, + ) + + sender.AddEvent( + t, + janusEventTypeWebRTC, + janusEventSubTypeWebRTCICE, + 1, + janusEventWebRTCICE{ + ICE: "gathering", + }, + ) + + sender.AddEvent( + t, + janusEventTypeWebRTC, + janusEventSubTypeWebRTCLocalCandidate, + 1, + janusEventWebRTCLocalCandidate{ + LocalCandidate: "invalid-candidate", + }, + ) + + sender.AddEvent( + t, + janusEventTypeWebRTC, + janusEventSubTypeWebRTCRemoteCandidate, + 1, + janusEventWebRTCRemoteCandidate{ + RemoteCandidate: "invalid-candidate", + }, + ) + + sender.AddEvent( + t, + janusEventTypeWebRTC, + janusEventSubTypeWebRTCSelectedPair, + 1, + janusEventWebRTCSelectedPair{ + SelectedPair: "invalid-pair", + }, + ) + + sender.AddEvent( + t, + janusEventTypeWebRTC, + janusEventSubTypeWebRTCDTLS, + 1, + janusEventWebRTCDTLS{ + DTLS: "trying", + }, + ) + + sender.AddEvent( + t, + janusEventTypeWebRTC, + janusEventSubTypeWebRTCPeerConnection, + 1, + janusEventWebRTCPeerConnection{ + Connection: "webrtcup", + }, + ) + + sender.AddEvent( + t, + janusEventTypeMedia, + janusEventSubTypeMediaState, + 1, + janusEventMediaState{ + Media: "audio", + }, + ) + + sender.AddEvent( + t, + janusEventTypeMedia, + janusEventSubTypeMediaSlowLink, + 1, + janusEventMediaSlowLink{ + Media: "audio", + }, + ) + + sender.AddEvent( + t, + janusEventTypePlugin, + 0, + 1, + janusEventPlugin{ + Plugin: "test-plugin", + }, + ) + + sender.AddEvent( + t, + janusEventTypeTransport, + 0, + 1, + janusEventTransport{ + Transport: "test-transport", + }, + ) + + sender.AddEvent( + t, + janusEventTypeCore, + janusEventSubTypeCoreStatusStartup, + 0, + janusEventCoreStartup{ + Status: "started", + }, + ) + + sender.AddEvent( + t, + janusEventTypeCore, + janusEventSubTypeCoreStatusStartup, + 0, + janusEventCoreStartup{ + Status: "update", + }, + ) + + sender.AddEvent( + t, + janusEventTypeCore, + janusEventSubTypeCoreStatusShutdown, + 0, + janusEventCoreShutdown{ + Status: "shutdown", + }, + ) + + sender.AddEvent( + t, + janusEventTypeMedia, + janusEventSubTypeMediaStats, + 1, + janusEventMediaStats{ + Media: "audio", + BytesSentLastSec: 100, + BytesReceivedLastSec: 200, + }, + ) + sender.Send(t, conn) + + // Wait until all events are processed. + assert.NoError(mcu.WaitForUpdates(ctx, 1)) +} + +func TestJanusEventsHandlerNotGrouped(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + _, url, handler := NewTestJanusEventsHandlerServer(t) + + mcu := &TestMcuWithEvents{ + t: t, + } + handler.mcu = mcu + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + dialer := websocket.Dialer{ + Subprotocols: []string{ + EventsSubprotocol, + }, + } + conn, response, err := dialer.DialContext(ctx, url, nil) + require.NoError(err) + defer func() { + assert.NoError(conn.Close()) + }() + + assert.Equal(EventsSubprotocol, response.Header.Get("Sec-WebSocket-Protocol")) + + metricstest.AssertCollectorChangeBy(t, statsJanusMediaNACKTotal.WithLabelValues("audio", "incoming"), 20) + metricstest.AssertCollectorChangeBy(t, statsJanusMediaNACKTotal.WithLabelValues("audio", "outgoing"), 30) + metricstest.AssertCollectorChangeBy(t, statsJanusMediaRetransmissionsTotal.WithLabelValues("audio"), 40) + metricstest.AssertCollectorChangeBy(t, statsJanusMediaLostTotal.WithLabelValues("audio", "local"), 50) + metricstest.AssertCollectorChangeBy(t, statsJanusMediaLostTotal.WithLabelValues("audio", "remote"), 60) + + var sender janusEventSender + sender.AddEvent( + t, + janusEventTypeHandle, + 0, + 1, + janusEventHandle{ + Name: "attached", + }, + ) + sender.SendSingle(t, conn) + sender.AddEvent( + t, + janusEventTypeMedia, + janusEventSubTypeMediaStats, + 1, + janusEventMediaStats{ + Media: "audio", + BytesSentLastSec: 100, + BytesReceivedLastSec: 200, + Codec: "opus", + RTT: 10, + JitterLocal: 11, + JitterRemote: 12, + NacksReceived: 20, + NacksSent: 30, + RetransmissionsReceived: 40, + Lost: 50, + LostByRemote: 60, + }, + ) + sender.SendSingle(t, conn) + sender.AddEvent( + t, + janusEventTypeHandle, + 0, + 1, + janusEventHandle{ + Name: "detached", + }, + ) + sender.SendSingle(t, conn) + assert.NoError(mcu.WaitForUpdates(ctx, 1)) +} + +func TestJanusEventsHandlerGrouped(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + _, url, handler := NewTestJanusEventsHandlerServer(t) + + mcu := &TestMcuWithEvents{ + t: t, + } + handler.mcu = mcu + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + dialer := websocket.Dialer{ + Subprotocols: []string{ + EventsSubprotocol, + }, + } + conn, response, err := dialer.DialContext(ctx, url, nil) + require.NoError(err) + defer func() { + assert.NoError(conn.Close()) + }() + + assert.Equal(EventsSubprotocol, response.Header.Get("Sec-WebSocket-Protocol")) + + var sender janusEventSender + sender.AddEvent( + t, + janusEventTypeMedia, + janusEventSubTypeMediaStats, + 1, + janusEventMediaStats{ + Media: "audio", + BytesSentLastSec: 100, + BytesReceivedLastSec: 200, + }, + ) + sender.AddEvent( + t, + janusEventTypeMedia, + janusEventSubTypeMediaStats, + 1, + janusEventMediaStats{ + Media: "video", + BytesSentLastSec: 200, + BytesReceivedLastSec: 300, + }, + ) + sender.Send(t, conn) + + assert.NoError(mcu.WaitForUpdates(ctx, 2)) +} + +func TestValueCounter(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + + var c valueCounter + assert.EqualValues(0, c.Update("foo", 0)) + assert.EqualValues(10, c.Update("foo", 10)) + assert.EqualValues(0, c.Update("foo", 10)) + assert.EqualValues(1, c.Update("foo", 11)) + assert.EqualValues(10, c.Update("bar", 10)) + assert.EqualValues(1, c.Update("bar", 11)) + assert.Equal(uint64(math.MaxUint64-10), c.Update("baz", math.MaxUint64-10)) + assert.EqualValues(20, c.Update("baz", 10)) +} diff --git a/sfu/janus/janus.go b/sfu/janus/janus.go new file mode 100644 index 0000000..9efbd9b --- /dev/null +++ b/sfu/janus/janus.go @@ -0,0 +1,1181 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/dlintw/goconf" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/async" + "github.com/strukturag/nextcloud-spreed-signaling/v2/container" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + sfuinternal "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/janus" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +const ( + pluginVideoRoom = "janus.plugin.videoroom" + eventWebsocket = "janus.eventhandler.wsevh" + + keepaliveInterval = 30 * time.Second + bandwidthInterval = time.Second + + videoPublisherUserId = 1 + screenPublisherUserId = 2 + + initialReconnectInterval = 1 * time.Second + maxReconnectInterval = 16 * time.Second + + // MCU requests will be cancelled if they take too long. + defaultMcuTimeoutSeconds = 10 +) + +var ( + ErrRemoteStreamsNotSupported = errors.New("need Janus 1.1.0 for remote streams") + + streamTypeUserIds = map[sfu.StreamType]uint64{ + sfu.StreamTypeVideo: videoPublisherUserId, + sfu.StreamTypeScreen: screenPublisherUserId, + } +) + +func getPluginValue(data janus.PluginData, pluginName string, key string) any { + if data.Plugin != pluginName { + return nil + } + + return data.Data[key] +} + +func convertIntValue(value any) (uint64, error) { + switch t := value.(type) { + case float64: + if t < 0 { + return 0, fmt.Errorf("unsupported float64 number: %+v", t) + } + return uint64(t), nil + case uint64: + return t, nil + case int: + if t < 0 { + return 0, fmt.Errorf("unsupported int number: %+v", t) + } + return uint64(t), nil + case int64: + if t < 0 { + return 0, fmt.Errorf("unsupported int64 number: %+v", t) + } + return uint64(t), nil + case json.Number: + r, err := t.Int64() + if err != nil { + return 0, err + } else if r < 0 { + return 0, fmt.Errorf("unsupported JSON number: %+v", t) + } + return uint64(r), nil + default: + return 0, fmt.Errorf("unknown number type: %+v (%T)", t, t) + } +} + +func getPluginIntValue(logger log.Logger, data janus.PluginData, pluginName string, key string) uint64 { + val := getPluginValue(data, pluginName, key) + if val == nil { + return 0 + } + + result, err := convertIntValue(val) + if err != nil { + logger.Printf("Invalid value %+v for %s: %s", val, key, err) + result = 0 + } + return result +} + +func getPluginStringValue(data janus.PluginData, pluginName string, key string) string { + val := getPluginValue(data, pluginName, key) + if val == nil { + return "" + } + + strVal, ok := val.(string) + if !ok { + return "" + } + + return strVal +} + +// TODO(jojo): Lots of error handling still missing. + +type clientInterface interface { + Handle() uint64 + + NotifyReconnected() + + Bandwidth() *sfu.ClientBandwidthInfo + UpdateBandwidth(media string, sent api.Bandwidth, received api.Bandwidth) +} + +type Settings struct { + sfuinternal.CommonSettings + + allowedCandidates atomic.Pointer[container.IPList] + blockedCandidates atomic.Pointer[container.IPList] +} + +func newJanusSettings(ctx context.Context, config *goconf.ConfigFile) (*Settings, error) { + settings := &Settings{ + CommonSettings: sfuinternal.CommonSettings{ + Logger: log.LoggerFromContext(ctx), + }, + } + if err := settings.load(config); err != nil { + return nil, err + } + + return settings, nil +} + +func (s *Settings) load(config *goconf.ConfigFile) error { + if err := s.Load(config); err != nil { + return err + } + + mcuTimeoutSeconds, _ := config.GetInt("mcu", "timeout") + if mcuTimeoutSeconds <= 0 { + mcuTimeoutSeconds = defaultMcuTimeoutSeconds + } + mcuTimeout := time.Duration(mcuTimeoutSeconds) * time.Second + s.Logger.Printf("Using a timeout of %s for MCU requests", mcuTimeout) + s.SetTimeout(mcuTimeout) + + if value, _ := config.GetString("mcu", "allowedcandidates"); value != "" { + allowed, err := container.ParseIPList(value) + if err != nil { + return fmt.Errorf("invalid allowedcandidates: %w", err) + } + + s.Logger.Printf("Candidates allowlist: %s", allowed) + s.allowedCandidates.Store(allowed) + } else { + s.Logger.Printf("No candidates allowlist") + s.allowedCandidates.Store(nil) + } + if value, _ := config.GetString("mcu", "blockedcandidates"); value != "" { + blocked, err := container.ParseIPList(value) + if err != nil { + return fmt.Errorf("invalid blockedcandidates: %w", err) + } + + s.Logger.Printf("Candidates blocklist: %s", blocked) + s.blockedCandidates.Store(blocked) + } else { + s.Logger.Printf("No candidates blocklist") + s.blockedCandidates.Store(nil) + } + + return nil +} + +func (s *Settings) Reload(config *goconf.ConfigFile) { + if err := s.load(config); err != nil { + s.Logger.Printf("Error reloading MCU settings: %s", err) + } +} + +type Stats interface { + IncSubscriber(streamType sfu.StreamType) + DecSubscriber(streamType sfu.StreamType) +} + +type prometheusJanusStats struct{} + +func (s *prometheusJanusStats) IncSubscriber(streamType sfu.StreamType) { + sfuinternal.StatsSubscribersCurrent.WithLabelValues(string(streamType)).Inc() +} + +func (s *prometheusJanusStats) DecSubscriber(streamType sfu.StreamType) { + sfuinternal.StatsSubscribersCurrent.WithLabelValues(string(streamType)).Dec() +} + +type refcountCond struct { + ref int + cond *sync.Cond +} + +type janusSFU struct { + logger log.Logger + + url string + mu sync.Mutex + + settings *Settings + stats Stats + + createJanusGateway func(ctx context.Context, wsURL string, listener janus.GatewayListener) (janus.GatewayInterface, error) + + gw janus.GatewayInterface + session *janus.Session + handle *janus.Handle + + version int + info atomic.Pointer[janus.InfoMsg] + + closeChan chan struct{} + + muClients sync.RWMutex + // +checklocks:muClients + clients map[uint64]clientInterface + clientId atomic.Uint64 + + // +checklocks:mu + publishers map[sfu.StreamId]*janusPublisher + // +checklocks:mu + publisherCreated map[sfu.StreamId]*refcountCond + publisherConnected async.Notifier + // +checklocks:mu + remotePublishers map[sfu.StreamId]*janusRemotePublisher + + reconnectTimer *time.Timer + reconnectInterval time.Duration + + connectedSince time.Time + onConnected atomic.Value + onDisconnected atomic.Value +} + +func emptyOnConnected() {} +func emptyOnDisconnected() {} + +func NewJanusSFU(ctx context.Context, url string, config *goconf.ConfigFile) (sfu.SFU, error) { + settings, err := newJanusSettings(ctx, config) + if err != nil { + return nil, err + } + + mcu := &janusSFU{ + logger: log.LoggerFromContext(ctx), + url: url, + settings: settings, + stats: &prometheusJanusStats{}, + closeChan: make(chan struct{}, 1), + clients: make(map[uint64]clientInterface), + + publishers: make(map[sfu.StreamId]*janusPublisher), + publisherCreated: make(map[sfu.StreamId]*refcountCond), + remotePublishers: make(map[sfu.StreamId]*janusRemotePublisher), + + createJanusGateway: func(ctx context.Context, wsURL string, listener janus.GatewayListener) (janus.GatewayInterface, error) { + return janus.NewGateway(ctx, wsURL, listener) + }, + reconnectInterval: initialReconnectInterval, + } + mcu.onConnected.Store(emptyOnConnected) + mcu.onDisconnected.Store(emptyOnDisconnected) + + mcu.reconnectTimer = time.AfterFunc(mcu.reconnectInterval, func() { + mcu.doReconnect(context.Background()) + }) + mcu.reconnectTimer.Stop() + if mcu.url != "" { + if err := mcu.reconnect(ctx); err != nil { + return nil, err + } + } + return mcu, nil +} + +func NewJanusSFUWithGateway(ctx context.Context, gateway janus.GatewayInterface, config *goconf.ConfigFile) (sfu.SFU, error) { + sfu, err := NewJanusSFU(ctx, "", config) + if err != nil { + return nil, err + } + + sfuJanus := sfu.(*janusSFU) + sfuJanus.createJanusGateway = func(ctx context.Context, wsURL string, listener janus.GatewayListener) (janus.GatewayInterface, error) { + return gateway, nil + } + return sfu, nil +} + +func (m *janusSFU) SetStats(stats Stats) { + m.stats = stats +} + +func (m *janusSFU) Settings() *Settings { + return m.settings +} + +func (m *janusSFU) disconnect() { + if handle := m.handle; handle != nil { + m.handle = nil + m.closeChan <- struct{}{} + if _, err := handle.Detach(context.TODO()); err != nil { + m.logger.Printf("Error detaching handle %d: %s", handle.Id, err) + } + } + if m.session != nil { + if _, err := m.session.Destroy(context.TODO()); err != nil { + m.logger.Printf("Error destroying session %d: %s", m.session.Id, err) + } + m.session = nil + } + if m.gw != nil { + if err := m.gw.Close(); err != nil { + m.logger.Println("Error while closing connection to MCU", err) + } + m.gw = nil + } +} + +func (m *janusSFU) GetBandwidthLimits() (api.Bandwidth, api.Bandwidth) { + return m.settings.MaxStreamBitrate(), m.settings.MaxScreenBitrate() +} + +func (m *janusSFU) Bandwidth() (result *sfu.ClientBandwidthInfo) { + m.muClients.RLock() + defer m.muClients.RUnlock() + + for _, client := range m.clients { + if bandwidth := client.Bandwidth(); bandwidth != nil { + if result == nil { + result = &sfu.ClientBandwidthInfo{} + } + result.Received += bandwidth.Received + result.Sent += bandwidth.Sent + } + } + return +} + +type janusBandwidthStats interface { + SetBandwidth(incoming uint64, outgoing uint64) +} + +type prometheusJanusBandwidthStats struct{} + +func (s *prometheusJanusBandwidthStats) SetBandwidth(incoming uint64, outgoing uint64) { + statsJanusBandwidthCurrent.WithLabelValues("incoming").Set(float64(incoming)) + statsJanusBandwidthCurrent.WithLabelValues("outgoing").Set(float64(outgoing)) +} + +var ( + defaultJanusBandwidthStats = &prometheusJanusBandwidthStats{} +) + +func (m *janusSFU) updateBandwidthStats(stats janusBandwidthStats) { + if info := m.info.Load(); info != nil { + if !info.EventHandlers { + // Event handlers are disabled, no stats will be available. + return + } + + if _, found := info.Events[eventWebsocket]; !found { + // Event handler plugin not found, no stats will be available. + return + } + } + + if stats == nil { + stats = defaultJanusBandwidthStats + } + + if bandwidth := m.Bandwidth(); bandwidth != nil { + stats.SetBandwidth(bandwidth.Received.Bytes(), bandwidth.Sent.Bytes()) + } else { + stats.SetBandwidth(0, 0) + } +} + +func (m *janusSFU) reconnect(ctx context.Context) error { + m.disconnect() + gw, err := m.createJanusGateway(ctx, m.url, m) + if err != nil { + return err + } + + m.gw = gw + m.reconnectTimer.Stop() + return nil +} + +func (m *janusSFU) doReconnect(ctx context.Context) { + if err := m.reconnect(ctx); err != nil { + m.scheduleReconnect(err) + return + } + if err := m.Start(ctx); err != nil { + m.scheduleReconnect(err) + return + } + + m.logger.Println("Reconnection to Janus gateway successful") + m.mu.Lock() + clear(m.publishers) + for _, c := range m.publisherCreated { + c.cond.Broadcast() + } + clear(m.publisherCreated) + m.publisherConnected.Reset() + m.reconnectInterval = initialReconnectInterval + m.mu.Unlock() + + m.notifyClientsReconnected() +} + +func (m *janusSFU) notifyClientsReconnected() { + m.muClients.RLock() + defer m.muClients.RUnlock() + + for oldHandle, client := range m.clients { + go func(oldHandle uint64, client clientInterface) { + client.NotifyReconnected() + newHandle := client.Handle() + + if oldHandle != newHandle { + m.muClients.Lock() + defer m.muClients.Unlock() + + delete(m.clients, oldHandle) + m.clients[newHandle] = client + } + }(oldHandle, client) + } +} + +func (m *janusSFU) scheduleReconnect(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.reconnectTimer.Reset(m.reconnectInterval) + if err == nil { + m.logger.Printf("Connection to Janus gateway was interrupted, reconnecting in %s", m.reconnectInterval) + } else { + m.logger.Printf("Reconnect to Janus gateway failed (%s), reconnecting in %s", err, m.reconnectInterval) + } + + m.reconnectInterval = min(m.reconnectInterval*2, maxReconnectInterval) +} + +func (m *janusSFU) ConnectionInterrupted() { + m.scheduleReconnect(nil) + m.notifyOnDisconnected() +} + +func (m *janusSFU) isMultistream() bool { + return m.version >= 1000 +} + +func (m *janusSFU) hasRemotePublisher() bool { + return m.version >= 1100 +} + +func (m *janusSFU) Start(ctx context.Context) error { + if m.url == "" { + if err := m.reconnect(ctx); err != nil { + return err + } + } + info, err := m.gw.Info(ctx) + if err != nil { + return err + } + + m.logger.Printf("Connected to %s %s by %s", info.Name, info.VersionString, info.Author) + m.version = info.Version + + if plugin, found := info.Plugins[pluginVideoRoom]; found { + m.logger.Printf("Found %s %s by %s", plugin.Name, plugin.VersionString, plugin.Author) + } else { + return fmt.Errorf("plugin %s is not supported", pluginVideoRoom) + } + + if plugin, found := info.Events[eventWebsocket]; found { + if !info.EventHandlers { + m.logger.Printf("Found %s %s by %s but event handlers are disabled, realtime usage will not be available", plugin.Name, plugin.VersionString, plugin.Author) + } else { + m.logger.Printf("Found %s %s by %s", plugin.Name, plugin.VersionString, plugin.Author) + } + } else { + m.logger.Printf("Plugin %s not found, realtime usage will not be available", eventWebsocket) + } + + m.logger.Printf("Used dependencies: %+v", info.Dependencies) + if !info.DataChannels { + return errors.New("data channels are not supported") + } + + m.logger.Println("Data channels are supported") + if !info.FullTrickle { + m.logger.Println("WARNING: Full-Trickle is NOT enabled in Janus!") + } else { + m.logger.Println("Full-Trickle is enabled") + } + + if m.session, err = m.gw.Create(ctx); err != nil { + m.disconnect() + return err + } + m.logger.Println("Created Janus session", m.session.Id) + m.connectedSince = time.Now() + + if m.handle, err = m.session.Attach(ctx, pluginVideoRoom); err != nil { + m.disconnect() + return err + } + m.logger.Println("Created Janus handle", m.handle.Id) + + m.info.Store(info) + + go m.run() + + m.notifyOnConnected() + return nil +} + +func (m *janusSFU) registerClient(client clientInterface) { + m.muClients.Lock() + defer m.muClients.Unlock() + + m.clients[client.Handle()] = client +} + +func (m *janusSFU) unregisterClient(client clientInterface) { + m.muClients.Lock() + defer m.muClients.Unlock() + + delete(m.clients, client.Handle()) +} + +func (m *janusSFU) run() { + ticker := time.NewTicker(keepaliveInterval) + defer ticker.Stop() + + bandwidthTicker := time.NewTicker(bandwidthInterval) + defer bandwidthTicker.Stop() + +loop: + for { + select { + case <-ticker.C: + m.sendKeepalive(context.Background()) + case <-bandwidthTicker.C: + m.updateBandwidthStats(nil) + case <-m.closeChan: + break loop + } + } +} + +func (m *janusSFU) Stop() { + m.disconnect() + m.reconnectTimer.Stop() +} + +func (m *janusSFU) IsConnected() bool { + return m.handle != nil +} + +func (m *janusSFU) Info() *janus.InfoMsg { + return m.info.Load() +} + +func (m *janusSFU) GetServerInfoSfu() *talk.BackendServerInfoSfu { + janus := &talk.BackendServerInfoSfuJanus{ + Url: m.url, + } + if m.IsConnected() { + janus.Connected = true + if info := m.Info(); info != nil { + janus.Name = info.Name + janus.Version = info.VersionString + janus.Author = info.Author + janus.DataChannels = internal.MakePtr(info.DataChannels) + janus.FullTrickle = internal.MakePtr(info.FullTrickle) + janus.LocalIP = info.LocalIP + janus.IPv6 = internal.MakePtr(info.IPv6) + + if plugin, found := info.Plugins[pluginVideoRoom]; found { + janus.VideoRoom = &talk.BackendServerInfoVideoRoom{ + Name: plugin.Name, + Version: plugin.VersionString, + Author: plugin.Author, + } + } + } + } + + sfu := &talk.BackendServerInfoSfu{ + Mode: talk.SfuModeJanus, + Janus: janus, + } + return sfu +} + +func (m *janusSFU) Reload(config *goconf.ConfigFile) { + m.settings.Reload(config) +} + +func (m *janusSFU) SetOnConnected(f func()) { + if f == nil { + f = emptyOnConnected + } + + m.onConnected.Store(f) +} + +func (m *janusSFU) notifyOnConnected() { + f := m.onConnected.Load().(func()) + f() +} + +func (m *janusSFU) SetOnDisconnected(f func()) { + if f == nil { + f = emptyOnDisconnected + } + + m.onDisconnected.Store(f) +} + +func (m *janusSFU) notifyOnDisconnected() { + f := m.onDisconnected.Load().(func()) + f() +} + +type janusConnectionStats struct { + Url string `json:"url"` + Connected bool `json:"connected"` + Publishers int64 `json:"publishers"` + Clients int64 `json:"clients"` + Uptime *time.Time `json:"uptime,omitempty"` +} + +func (m *janusSFU) GetStats() any { + result := janusConnectionStats{ + Url: m.url, + } + if m.session != nil { + result.Connected = true + result.Uptime = &m.connectedSince + } + m.mu.Lock() + result.Publishers = int64(len(m.publishers)) + m.mu.Unlock() + m.muClients.Lock() + result.Clients = int64(len(m.clients)) + m.muClients.Unlock() + return result +} + +func (m *janusSFU) sendKeepalive(ctx context.Context) { + if _, err := m.session.KeepAlive(ctx); err != nil { + m.logger.Println("Could not send keepalive request", err) + if e, ok := err.(*janus.ErrorMsg); ok { + switch e.Err.Code { + case janus.JANUS_ERROR_SESSION_NOT_FOUND: + m.scheduleReconnect(err) + } + } + } +} + +func (m *janusSFU) SubscriberConnected(id string, publisher api.PublicSessionId, streamType sfu.StreamType) { + m.mu.Lock() + defer m.mu.Unlock() + + if p, found := m.publishers[sfu.GetStreamId(publisher, streamType)]; found { + p.stats.AddSubscriber(id) + } +} + +func (m *janusSFU) SubscriberDisconnected(id string, publisher api.PublicSessionId, streamType sfu.StreamType) { + m.mu.Lock() + defer m.mu.Unlock() + + if p, found := m.publishers[sfu.GetStreamId(publisher, streamType)]; found { + p.stats.RemoveSubscriber(id) + } +} + +func (m *janusSFU) createPublisherRoom(ctx context.Context, handle *janus.Handle, id api.PublicSessionId, streamType sfu.StreamType, settings sfu.NewPublisherSettings) (uint64, api.Bandwidth, error) { + create_msg := api.StringMap{ + "request": "create", + "description": sfu.GetStreamId(id, streamType), + // We publish every stream in its own Janus room. + "publishers": 1, + // Do not use the video-orientation RTP extension as it breaks video + // orientation changes in Firefox. + "videoorient_ext": false, + } + if codec := settings.AudioCodec; codec != "" { + create_msg["audiocodec"] = codec + } + if codec := settings.VideoCodec; codec != "" { + create_msg["videocodec"] = codec + } + if profile := settings.VP9Profile; profile != "" { + create_msg["vp9_profile"] = profile + } + if profile := settings.H264Profile; profile != "" { + create_msg["h264_profile"] = profile + } + var maxBitrate api.Bandwidth + if streamType == sfu.StreamTypeScreen { + maxBitrate = m.settings.MaxScreenBitrate() + } else { + maxBitrate = m.settings.MaxStreamBitrate() + } + bitrate := settings.Bitrate + if bitrate <= 0 { + bitrate = maxBitrate + } else { + bitrate = min(bitrate, maxBitrate) + } + create_msg["bitrate"] = bitrate.Bits() + create_response, err := handle.Request(ctx, create_msg) + if err != nil { + if _, err2 := handle.Detach(ctx); err2 != nil { + m.logger.Printf("Error detaching handle %d: %s", handle.Id, err2) + } + return 0, 0, err + } + + roomId := getPluginIntValue(m.logger, create_response.PluginData, pluginVideoRoom, "room") + if roomId == 0 { + if _, err := handle.Detach(ctx); err != nil { + m.logger.Printf("Error detaching handle %d: %s", handle.Id, err) + } + return 0, 0, fmt.Errorf("no room id received: %+v", create_response) + } + + m.logger.Println("Created room", roomId, create_response.PluginData) + return roomId, bitrate, nil +} + +func (m *janusSFU) getOrCreatePublisherHandle(ctx context.Context, id api.PublicSessionId, streamType sfu.StreamType, settings sfu.NewPublisherSettings) (*janus.Handle, uint64, uint64, api.Bandwidth, error) { + session := m.session + if session == nil { + return nil, 0, 0, 0, sfu.ErrNotConnected + } + handle, err := session.Attach(ctx, pluginVideoRoom) + if err != nil { + return nil, 0, 0, 0, err + } + + m.logger.Printf("Attached %s as publisher %d to plugin %s in session %d", streamType, handle.Id, pluginVideoRoom, session.Id) + + roomId, bitrate, err := m.createPublisherRoom(ctx, handle, id, streamType, settings) + if err != nil { + if _, err2 := handle.Detach(ctx); err2 != nil { + m.logger.Printf("Error detaching handle %d: %s", handle.Id, err2) + } + return nil, 0, 0, 0, err + } + + msg := api.StringMap{ + "request": "join", + "ptype": "publisher", + "room": roomId, + "id": streamTypeUserIds[streamType], + } + + response, err := handle.Message(ctx, msg, nil) + if err != nil { + if _, err2 := handle.Detach(ctx); err2 != nil { + m.logger.Printf("Error detaching handle %d: %s", handle.Id, err2) + } + return nil, 0, 0, 0, err + } + + return handle, response.Session, roomId, bitrate, nil +} + +func (m *janusSFU) NewPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings, initiator sfu.Initiator) (sfu.Publisher, error) { + if _, found := streamTypeUserIds[streamType]; !found { + return nil, fmt.Errorf("unsupported stream type %s", streamType) + } + + handle, session, roomId, maxBitrate, err := m.getOrCreatePublisherHandle(ctx, id, streamType, settings) + if err != nil { + return nil, err + } + + client := &janusPublisher{ + janusClient: janusClient{ + logger: m.logger, + mcu: m, + listener: listener, + + id: m.clientId.Add(1), + session: session, + roomId: roomId, + sid: sid, + streamType: streamType, + maxBitrate: maxBitrate, + + closeChan: make(chan struct{}, 1), + deferred: make(chan func(), 64), + }, + sdpReady: internal.NewCloser(), + id: id, + settings: settings, + } + client.handle.Store(handle) + client.handleId.Store(handle.Id) + client.janusClient.handleEvent = client.handleEvent + client.janusClient.handleHangup = client.handleHangup + client.janusClient.handleDetached = client.handleDetached + client.janusClient.handleConnected = client.handleConnected + client.janusClient.handleSlowLink = client.handleSlowLink + client.janusClient.handleMedia = client.handleMedia + + m.registerClient(client) + m.logger.Printf("Publisher %s is using handle %d", client.id, handle.Id) + go client.run(handle, client.closeChan) + + m.notifyPublisherCreated(id, streamType, client) + sfuinternal.StatsPublishersCurrent.WithLabelValues(string(streamType)).Inc() + sfuinternal.StatsPublishersTotal.WithLabelValues(string(streamType)).Inc() + return client, nil +} + +func (m *janusSFU) notifyPublisherCreated(id api.PublicSessionId, streamType sfu.StreamType, client *janusPublisher) { + key := sfu.GetStreamId(id, streamType) + m.mu.Lock() + defer m.mu.Unlock() + + m.publishers[key] = client + if c, found := m.publisherCreated[key]; found { + c.cond.Broadcast() + delete(m.publisherCreated, key) + } +} + +func (m *janusSFU) notifyPublisherConnected(id api.PublicSessionId, streamType sfu.StreamType) { + key := sfu.GetStreamId(id, streamType) + m.publisherConnected.Notify(string(key)) +} + +func (m *janusSFU) newPublisherConnectedWaiter(id api.PublicSessionId, streamType sfu.StreamType) (*async.Waiter, async.ReleaseFunc) { + key := sfu.GetStreamId(id, streamType) + + return m.publisherConnected.NewWaiter(string(key)) +} + +func (m *janusSFU) getPublisher(ctx context.Context, publisher api.PublicSessionId, streamType sfu.StreamType) (*janusPublisher, error) { + // Do the direct check immediately as this should be the normal case. + key := sfu.GetStreamId(publisher, streamType) + m.mu.Lock() + defer m.mu.Unlock() + result, found := m.publishers[key] + if found { + return result, nil + } + + c, found := m.publisherCreated[key] + if !found { + c = &refcountCond{ + cond: sync.NewCond(&m.mu), + } + m.publisherCreated[key] = c + } + c.ref++ + + stop := context.AfterFunc(ctx, func() { + c.cond.Broadcast() + }) + defer stop() + + for result == nil && ctx.Err() == nil { + c.cond.Wait() + result = m.publishers[key] + } + + c.ref-- + if c.ref == 0 { + delete(m.publisherCreated, key) + } + + if err := ctx.Err(); err != nil { + return nil, err + } + + return result, nil +} + +func (m *janusSFU) getOrCreateSubscriberHandle(ctx context.Context, publisher api.PublicSessionId, streamType sfu.StreamType) (*janus.Handle, *janusPublisher, error) { + var pub *janusPublisher + var err error + if pub, err = m.getPublisher(ctx, publisher, streamType); err != nil { + return nil, nil, err + } + + session := m.session + if session == nil { + return nil, nil, sfu.ErrNotConnected + } + + handle, err := session.Attach(ctx, pluginVideoRoom) + if err != nil { + return nil, nil, err + } + + m.logger.Printf("Attached subscriber to room %d of publisher %s in plugin %s in session %d as %d", pub.roomId, publisher, pluginVideoRoom, session.Id, handle.Id) + return handle, pub, nil +} + +func (m *janusSFU) NewSubscriber(ctx context.Context, listener sfu.Listener, publisher api.PublicSessionId, streamType sfu.StreamType, initiator sfu.Initiator) (sfu.Subscriber, error) { + if _, found := streamTypeUserIds[streamType]; !found { + return nil, fmt.Errorf("unsupported stream type %s", streamType) + } + + handle, pub, err := m.getOrCreateSubscriberHandle(ctx, publisher, streamType) + if err != nil { + return nil, err + } + + client := &janusSubscriber{ + janusClient: janusClient{ + logger: m.logger, + mcu: m, + listener: listener, + + id: m.clientId.Add(1), + roomId: pub.roomId, + sid: strconv.FormatUint(handle.Id, 10), + streamType: streamType, + maxBitrate: pub.MaxBitrate(), + + closeChan: make(chan struct{}, 1), + deferred: make(chan func(), 64), + }, + publisher: publisher, + } + client.handle.Store(handle) + client.handleId.Store(handle.Id) + client.janusClient.handleEvent = client.handleEvent + client.janusClient.handleHangup = client.handleHangup + client.janusClient.handleDetached = client.handleDetached + client.janusClient.handleConnected = client.handleConnected + client.janusClient.handleSlowLink = client.handleSlowLink + client.janusClient.handleMedia = client.handleMedia + m.registerClient(client) + go client.run(handle, client.closeChan) + m.stats.IncSubscriber(streamType) + sfuinternal.StatsSubscribersTotal.WithLabelValues(string(streamType)).Inc() + return client, nil +} + +func (m *janusSFU) getOrCreateRemotePublisher(ctx context.Context, controller sfu.RemotePublisherController, streamType sfu.StreamType, settings sfu.NewPublisherSettings) (*janusRemotePublisher, error) { + m.mu.Lock() + defer m.mu.Unlock() + pub, found := m.remotePublishers[sfu.GetStreamId(controller.PublisherId(), streamType)] + if found { + return pub, nil + } + + streams, err := controller.GetStreams(ctx) + if err != nil { + return nil, err + } + + if len(streams) == 0 { + return nil, errors.New("remote publisher has no streams") + } + + session := m.session + if session == nil { + return nil, sfu.ErrNotConnected + } + + handle, err := session.Attach(ctx, pluginVideoRoom) + if err != nil { + return nil, err + } + + roomId, maxBitrate, err := m.createPublisherRoom(ctx, handle, controller.PublisherId(), streamType, settings) + if err != nil { + if _, err2 := handle.Detach(ctx); err2 != nil { + m.logger.Printf("Error detaching handle %d: %s", handle.Id, err2) + } + return nil, err + } + + response, err := handle.Request(ctx, api.StringMap{ + "request": "add_remote_publisher", + "room": roomId, + "id": streamTypeUserIds[streamType], + "streams": streams, + }) + if err != nil { + if _, err2 := handle.Detach(ctx); err2 != nil { + m.logger.Printf("Error detaching handle %d: %s", handle.Id, err2) + } + return nil, err + } + + id := getPluginIntValue(m.logger, response.PluginData, pluginVideoRoom, "id") + port := getPluginIntValue(m.logger, response.PluginData, pluginVideoRoom, "port") + rtcp_port := getPluginIntValue(m.logger, response.PluginData, pluginVideoRoom, "rtcp_port") + + pub = &janusRemotePublisher{ + janusPublisher: janusPublisher{ + janusClient: janusClient{ + logger: m.logger, + mcu: m, + + id: id, + session: response.Session, + roomId: roomId, + sid: strconv.FormatUint(handle.Id, 10), + streamType: streamType, + maxBitrate: maxBitrate, + + closeChan: make(chan struct{}, 1), + deferred: make(chan func(), 64), + }, + + sdpReady: internal.NewCloser(), + id: controller.PublisherId(), + settings: settings, + }, + + controller: controller, + + port: int(port), + rtcpPort: int(rtcp_port), + } + pub.handle.Store(handle) + pub.handleId.Store(handle.Id) + pub.janusClient.handleEvent = pub.handleEvent + pub.janusClient.handleHangup = pub.handleHangup + pub.janusClient.handleDetached = pub.handleDetached + pub.janusClient.handleConnected = pub.handleConnected + pub.janusClient.handleSlowLink = pub.handleSlowLink + pub.janusClient.handleMedia = pub.handleMedia + + if err := controller.StartPublishing(ctx, pub); err != nil { + go pub.Close(context.Background()) + return nil, err + } + + m.remotePublishers[sfu.GetStreamId(controller.PublisherId(), streamType)] = pub + + return pub, nil +} + +func (m *janusSFU) NewRemotePublisher(ctx context.Context, listener sfu.Listener, controller sfu.RemotePublisherController, streamType sfu.StreamType) (sfu.RemotePublisher, error) { + if _, found := streamTypeUserIds[streamType]; !found { + return nil, fmt.Errorf("unsupported stream type %s", streamType) + } + + if !m.hasRemotePublisher() { + return nil, ErrRemoteStreamsNotSupported + } + + pub, err := m.getOrCreateRemotePublisher(ctx, controller, streamType, sfu.NewPublisherSettings{}) + if err != nil { + return nil, err + } + + pub.addRef() + return pub, nil +} + +func (m *janusSFU) NewRemoteSubscriber(ctx context.Context, listener sfu.Listener, publisher sfu.RemotePublisher) (sfu.RemoteSubscriber, error) { + pub, ok := publisher.(*janusRemotePublisher) + if !ok { + return nil, errors.New("unsupported remote publisher") + } + + session := m.session + if session == nil { + return nil, sfu.ErrNotConnected + } + + handle, err := session.Attach(ctx, pluginVideoRoom) + if err != nil { + return nil, err + } + + m.logger.Printf("Attached subscriber to room %d of publisher %s in plugin %s in session %d as %d", pub.roomId, pub.id, pluginVideoRoom, session.Id, handle.Id) + + client := &janusRemoteSubscriber{ + janusSubscriber: janusSubscriber{ + janusClient: janusClient{ + logger: m.logger, + mcu: m, + listener: listener, + + id: m.clientId.Add(1), + roomId: pub.roomId, + sid: strconv.FormatUint(handle.Id, 10), + streamType: publisher.StreamType(), + maxBitrate: pub.MaxBitrate(), + + closeChan: make(chan struct{}, 1), + deferred: make(chan func(), 64), + }, + publisher: pub.id, + }, + } + client.remote.Store(pub) + pub.addRef() + client.handle.Store(handle) + client.handleId.Store(handle.Id) + client.janusClient.handleEvent = client.handleEvent + client.janusClient.handleHangup = client.handleHangup + client.janusClient.handleDetached = client.handleDetached + client.janusClient.handleConnected = client.handleConnected + client.janusClient.handleSlowLink = client.handleSlowLink + client.janusClient.handleMedia = client.handleMedia + m.registerClient(client) + go client.run(handle, client.closeChan) + m.stats.IncSubscriber(publisher.StreamType()) + sfuinternal.StatsSubscribersTotal.WithLabelValues(string(publisher.StreamType())).Inc() + return client, nil +} + +func (m *janusSFU) UpdateBandwidth(handle uint64, media string, sent api.Bandwidth, received api.Bandwidth) { + m.muClients.RLock() + defer m.muClients.RUnlock() + + client, found := m.clients[handle] + if !found { + return + } + + client.UpdateBandwidth(media, sent, received) +} diff --git a/janus_client.go b/sfu/janus/janus/janus.go similarity index 76% rename from janus_client.go rename to sfu/janus/janus/janus.go index 5716bef..bf45b90 100644 --- a/janus_client.go +++ b/sfu/janus/janus/janus.go @@ -26,12 +26,13 @@ * * Added error handling and improve functionality. */ -package signaling +package janus import ( "bytes" "context" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -42,6 +43,9 @@ import ( "github.com/gorilla/websocket" "github.com/notedit/janus-go" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" ) const ( @@ -119,24 +123,51 @@ const ( var ( janusDialer = websocket.Dialer{ - Subprotocols: []string{"janus-protocol"}, - Proxy: http.ProxyFromEnvironment, + Subprotocols: []string{"janus-protocol"}, + Proxy: http.ProxyFromEnvironment, + WriteBufferPool: &sync.Pool{}, } ) -var msgtypes = map[string]func() interface{}{ - "error": func() interface{} { return &janus.ErrorMsg{} }, - "success": func() interface{} { return &janus.SuccessMsg{} }, - "detached": func() interface{} { return &janus.DetachedMsg{} }, - "server_info": func() interface{} { return &InfoMsg{} }, - "ack": func() interface{} { return &janus.AckMsg{} }, - "event": func() interface{} { return &janus.EventMsg{} }, - "webrtcup": func() interface{} { return &janus.WebRTCUpMsg{} }, - "media": func() interface{} { return &janus.MediaMsg{} }, - "hangup": func() interface{} { return &janus.HangupMsg{} }, - "slowlink": func() interface{} { return &janus.SlowLinkMsg{} }, - "timeout": func() interface{} { return &janus.TimeoutMsg{} }, - "trickle": func() interface{} { return &TrickleMsg{} }, +var msgtypes = map[string]func() any{ + "error": func() any { return &janus.ErrorMsg{} }, + "success": func() any { return &janus.SuccessMsg{} }, + "detached": func() any { return &janus.DetachedMsg{} }, + "server_info": func() any { return &InfoMsg{} }, + "ack": func() any { return &janus.AckMsg{} }, + "event": func() any { return &janus.EventMsg{} }, + "webrtcup": func() any { return &janus.WebRTCUpMsg{} }, + "media": func() any { return &janus.MediaMsg{} }, + "hangup": func() any { return &janus.HangupMsg{} }, + "slowlink": func() any { return &janus.SlowLinkMsg{} }, + "timeout": func() any { return &janus.TimeoutMsg{} }, + "trickle": func() any { return &TrickleMsg{} }, +} + +type ( + EventMsg = janus.EventMsg + HangupMsg = janus.HangupMsg + DetachedMsg = janus.DetachedMsg + WebRTCUpMsg = janus.WebRTCUpMsg + SlowLinkMsg = janus.SlowLinkMsg + MediaMsg = janus.MediaMsg + AckMsg = janus.AckMsg + SuccessMsg = janus.SuccessMsg + SuccessData = janus.SuccessData + ErrorMsg = janus.ErrorMsg + ErrorData = janus.ErrorData + + PluginInfo = janus.PluginInfo + PluginData = janus.PluginData +) + +type InfoDependencies struct { + Glib2 string `json:"glib2"` + Jansson string `json:"jansson"` + Libnice string `json:"libnice"` + Libsrtp string `json:"libsrtp"` + Libcurl string `json:"libcurl,omitempty"` + Crypto string `json:"crypto"` } type InfoMsg struct { @@ -145,12 +176,15 @@ type InfoMsg struct { VersionString string `json:"version_string"` Author string DataChannels bool `json:"data_channels"` + EventHandlers bool `json:"event_handlers"` IPv6 bool `json:"ipv6"` LocalIP string `json:"local-ip"` ICE_TCP bool `json:"ice-tcp"` FullTrickle bool `json:"full-trickle"` Transports map[string]janus.PluginInfo Plugins map[string]janus.PluginInfo + Events map[string]janus.PluginInfo + Dependencies InfoDependencies } type TrickleMsg struct { @@ -169,13 +203,13 @@ func unexpected(request string) error { return fmt.Errorf("unexpected response received to '%s' request", request) } -type transaction struct { - ch chan interface{} - incoming chan interface{} - closer *Closer +type Transaction struct { + ch chan any + incoming chan any + closer *internal.Closer } -func (t *transaction) run() { +func (t *Transaction) Run() { for { select { case msg := <-t.incoming: @@ -186,25 +220,25 @@ func (t *transaction) run() { } } -func (t *transaction) add(msg interface{}) { +func (t *Transaction) Add(msg any) { t.incoming <- msg } -func (t *transaction) quit() { +func (t *Transaction) Quit() { t.closer.Close() } -func newTransaction() *transaction { - t := &transaction{ - ch: make(chan interface{}, 1), - incoming: make(chan interface{}, 8), - closer: NewCloser(), +func newTransaction() *Transaction { + t := &Transaction{ + ch: make(chan any, 1), + incoming: make(chan any, 8), + closer: internal.NewCloser(), } return t } -func newRequest(method string) (map[string]interface{}, *transaction) { - req := make(map[string]interface{}, 8) +func newRequest(method string) (api.StringMap, *Transaction) { + req := make(api.StringMap, 8) req["janus"] = method return req, newTransaction() } @@ -219,33 +253,36 @@ type dummyGatewayListener struct { func (l *dummyGatewayListener) ConnectionInterrupted() { } -type JanusGatewayInterface interface { +type GatewayInterface interface { Info(context.Context) (*InfoMsg, error) - Create(context.Context) (*JanusSession, error) + Create(context.Context) (*Session, error) Close() error - send(map[string]interface{}, *transaction) (uint64, error) - removeTransaction(uint64) + Send(api.StringMap, *Transaction) (uint64, error) + RemoveTransaction(uint64) - removeSession(*JanusSession) + RemoveSession(*Session) } // Gateway represents a connection to an instance of the Janus Gateway. -type JanusGateway struct { +type Gateway struct { listener GatewayListener // Sessions is a map of the currently active sessions to the gateway. - Sessions map[uint64]*JanusSession + // +checklocks:Mutex + Sessions map[uint64]*Session // Access to the Sessions map should be synchronized with the Gateway.Lock() // and Gateway.Unlock() methods provided by the embedded sync.Mutex. sync.Mutex + // +checklocks:writeMu conn *websocket.Conn nextTransaction atomic.Uint64 - transactions map[uint64]*transaction + // +checklocks:Mutex + transactions map[uint64]*Transaction - closer *Closer + closer *internal.Closer writeMu sync.Mutex } @@ -262,14 +299,14 @@ type JanusGateway struct { // gateway := new(Gateway) // //gateway.conn = conn -// gateway.transactions = make(map[uint64]chan interface{}) +// gateway.transactions = make(map[uint64]chan any) // gateway.Sessions = make(map[uint64]*JanusSession) // go gateway.recv() // return gateway, nil // } -func NewJanusGateway(ctx context.Context, wsURL string, listener GatewayListener) (*JanusGateway, error) { +func NewGateway(ctx context.Context, wsURL string, listener GatewayListener) (*Gateway, error) { conn, _, err := janusDialer.DialContext(ctx, wsURL, nil) if err != nil { return nil, err @@ -278,12 +315,12 @@ func NewJanusGateway(ctx context.Context, wsURL string, listener GatewayListener if listener == nil { listener = new(dummyGatewayListener) } - gateway := &JanusGateway{ + gateway := &Gateway{ conn: conn, listener: listener, - transactions: make(map[uint64]*transaction), - Sessions: make(map[uint64]*JanusSession), - closer: NewCloser(), + transactions: make(map[uint64]*Transaction), + Sessions: make(map[uint64]*Session), + closer: internal.NewCloser(), } go gateway.ping() @@ -292,7 +329,7 @@ func NewJanusGateway(ctx context.Context, wsURL string, listener GatewayListener } // Close closes the underlying connection to the Gateway. -func (gateway *JanusGateway) Close() error { +func (gateway *Gateway) Close() error { gateway.closer.Close() gateway.writeMu.Lock() if gateway.conn == nil { @@ -307,7 +344,7 @@ func (gateway *JanusGateway) Close() error { return err } -func (gateway *JanusGateway) cancelTransactions() { +func (gateway *Gateway) cancelTransactions() { msg := &janus.ErrorMsg{ Err: janus.ErrorData{ Code: 500, @@ -316,16 +353,16 @@ func (gateway *JanusGateway) cancelTransactions() { } gateway.Lock() for _, t := range gateway.transactions { - go func(t *transaction) { - t.add(msg) - t.quit() + go func(t *Transaction) { + t.Add(msg) + t.Quit() }(t) } clear(gateway.transactions) gateway.Unlock() } -func (gateway *JanusGateway) removeTransaction(id uint64) { +func (gateway *Gateway) RemoveTransaction(id uint64) { gateway.Lock() t, found := gateway.transactions[id] if found { @@ -333,11 +370,11 @@ func (gateway *JanusGateway) removeTransaction(id uint64) { } gateway.Unlock() if t != nil { - t.quit() + t.Quit() } } -func (gateway *JanusGateway) send(msg map[string]interface{}, t *transaction) (uint64, error) { +func (gateway *Gateway) Send(msg api.StringMap, t *Transaction) (uint64, error) { id := gateway.nextTransaction.Add(1) msg["transaction"] = strconv.FormatUint(id, 10) data, err := json.Marshal(msg) @@ -345,7 +382,7 @@ func (gateway *JanusGateway) send(msg map[string]interface{}, t *transaction) (u return 0, err } - go t.run() + go t.Run() gateway.Lock() gateway.transactions[id] = t gateway.Unlock() @@ -353,24 +390,24 @@ func (gateway *JanusGateway) send(msg map[string]interface{}, t *transaction) (u gateway.writeMu.Lock() if gateway.conn == nil { gateway.writeMu.Unlock() - gateway.removeTransaction(id) - return 0, fmt.Errorf("not connected") + gateway.RemoveTransaction(id) + return 0, errors.New("not connected") } err = gateway.conn.WriteMessage(websocket.TextMessage, data) gateway.writeMu.Unlock() if err != nil { - gateway.removeTransaction(id) + gateway.RemoveTransaction(id) return 0, err } return id, nil } -func passMsg(ch chan interface{}, msg interface{}) { +func passMsg(ch chan any, msg any) { ch <- msg } -func (gateway *JanusGateway) ping() { +func (gateway *Gateway) ping() { ticker := time.NewTicker(time.Second * 30) defer ticker.Stop() @@ -395,7 +432,7 @@ loop: } } -func (gateway *JanusGateway) recv() { +func (gateway *Gateway) recv() { var decodeBuffer bytes.Buffer for { // Read message from Gateway @@ -502,12 +539,12 @@ func (gateway *JanusGateway) recv() { } // Pass msg - transaction.add(msg) + transaction.Add(msg) } } } -func waitForMessage(ctx context.Context, t *transaction) (interface{}, error) { +func waitForMessage(ctx context.Context, t *Transaction) (any, error) { select { case <-ctx.Done(): return nil, ctx.Err() @@ -518,13 +555,13 @@ func waitForMessage(ctx context.Context, t *transaction) (interface{}, error) { // Info sends an info request to the Gateway. // On success, an InfoMsg will be returned and error will be nil. -func (gateway *JanusGateway) Info(ctx context.Context) (*InfoMsg, error) { +func (gateway *Gateway) Info(ctx context.Context) (*InfoMsg, error) { req, ch := newRequest("info") - id, err := gateway.send(req, ch) + id, err := gateway.Send(req, ch) if err != nil { return nil, err } - defer gateway.removeTransaction(id) + defer gateway.RemoveTransaction(id) msg, err := waitForMessage(ctx, ch) if err != nil { @@ -543,13 +580,13 @@ func (gateway *JanusGateway) Info(ctx context.Context) (*InfoMsg, error) { // Create sends a create request to the Gateway. // On success, a new Session will be returned and error will be nil. -func (gateway *JanusGateway) Create(ctx context.Context) (*JanusSession, error) { +func (gateway *Gateway) Create(ctx context.Context) (*Session, error) { req, ch := newRequest("create") - id, err := gateway.send(req, ch) + id, err := gateway.Send(req, ch) if err != nil { return nil, err } - defer gateway.removeTransaction(id) + defer gateway.RemoveTransaction(id) msg, err := waitForMessage(ctx, ch) if err != nil { @@ -564,10 +601,10 @@ func (gateway *JanusGateway) Create(ctx context.Context) (*JanusSession, error) } // Create new session - session := new(JanusSession) + session := new(Session) session.gateway = gateway session.Id = success.Data.ID - session.Handles = make(map[uint64]*JanusHandle) + session.Handles = make(map[uint64]*Handle) // Store this session gateway.Lock() @@ -577,43 +614,52 @@ func (gateway *JanusGateway) Create(ctx context.Context) (*JanusSession, error) return session, nil } -func (gateway *JanusGateway) removeSession(session *JanusSession) { +func (gateway *Gateway) RemoveSession(session *Session) { gateway.Lock() defer gateway.Unlock() delete(gateway.Sessions, session.Id) } // Session represents a session instance on the Janus Gateway. -type JanusSession struct { +type Session struct { // Id is the session_id of this session Id uint64 // Handles is a map of plugin handles within this session - Handles map[uint64]*JanusHandle + // +checklocks:Mutex + Handles map[uint64]*Handle // Access to the Handles map should be synchronized with the Session.Lock() // and Session.Unlock() methods provided by the embedded sync.Mutex. sync.Mutex - gateway JanusGatewayInterface + gateway GatewayInterface } -func (session *JanusSession) send(msg map[string]interface{}, t *transaction) (uint64, error) { +func NewSession(id uint64, g GatewayInterface) *Session { + return &Session{ + Id: id, + Handles: make(map[uint64]*Handle), + gateway: g, + } +} + +func (session *Session) send(msg api.StringMap, t *Transaction) (uint64, error) { msg["session_id"] = session.Id - return session.gateway.send(msg, t) + return session.gateway.Send(msg, t) } // Attach sends an attach request to the Gateway within this session. // plugin should be the unique string of the plugin to attach to. // On success, a new Handle will be returned and error will be nil. -func (session *JanusSession) Attach(ctx context.Context, plugin string) (*JanusHandle, error) { +func (session *Session) Attach(ctx context.Context, plugin string) (*Handle, error) { req, ch := newRequest("attach") req["plugin"] = plugin id, err := session.send(req, ch) if err != nil { return nil, err } - defer session.gateway.removeTransaction(id) + defer session.gateway.RemoveTransaction(id) msg, err := waitForMessage(ctx, ch) if err != nil { @@ -627,10 +673,10 @@ func (session *JanusSession) Attach(ctx context.Context, plugin string) (*JanusH return nil, msg } - handle := new(JanusHandle) + handle := new(Handle) handle.session = session handle.Id = success.Data.ID - handle.Events = make(chan interface{}, 8) + handle.Events = make(chan any, 8) session.Lock() session.Handles[handle.Id] = handle @@ -641,13 +687,13 @@ func (session *JanusSession) Attach(ctx context.Context, plugin string) (*JanusH // KeepAlive sends a keep-alive request to the Gateway. // On success, an AckMsg will be returned and error will be nil. -func (session *JanusSession) KeepAlive(ctx context.Context) (*janus.AckMsg, error) { +func (session *Session) KeepAlive(ctx context.Context) (*janus.AckMsg, error) { req, ch := newRequest("keepalive") id, err := session.send(req, ch) if err != nil { return nil, err } - defer session.gateway.removeTransaction(id) + defer session.gateway.RemoveTransaction(id) msg, err := waitForMessage(ctx, ch) if err != nil { @@ -666,13 +712,13 @@ func (session *JanusSession) KeepAlive(ctx context.Context) (*janus.AckMsg, erro // Destroy sends a destroy request to the Gateway to tear down this session. // On success, the Session will be removed from the Gateway.Sessions map, an // AckMsg will be returned and error will be nil. -func (session *JanusSession) Destroy(ctx context.Context) (*janus.AckMsg, error) { +func (session *Session) Destroy(ctx context.Context) (*janus.AckMsg, error) { req, ch := newRequest("destroy") id, err := session.send(req, ch) if err != nil { return nil, err } - defer session.gateway.removeTransaction(id) + defer session.gateway.RemoveTransaction(id) msg, err := waitForMessage(ctx, ch) if err != nil { @@ -687,36 +733,36 @@ func (session *JanusSession) Destroy(ctx context.Context) (*janus.AckMsg, error) } // Remove this session from the gateway - session.gateway.removeSession(session) + session.gateway.RemoveSession(session) return ack, nil } // Handle represents a handle to a plugin instance on the Gateway. -type JanusHandle struct { +type Handle struct { // Id is the handle_id of this plugin handle Id uint64 // Type // pub or sub Type string - //User // Userid + // User // Userid User string // Events is a receive only channel that can be used to receive events // related to this handle from the gateway. - Events chan interface{} + Events chan any - session *JanusSession + session *Session } -func (handle *JanusHandle) send(msg map[string]interface{}, t *transaction) (uint64, error) { +func (handle *Handle) send(msg api.StringMap, t *Transaction) (uint64, error) { msg["handle_id"] = handle.Id return handle.session.send(msg, t) } // send sync request -func (handle *JanusHandle) Request(ctx context.Context, body interface{}) (*janus.SuccessMsg, error) { +func (handle *Handle) Request(ctx context.Context, body any) (*janus.SuccessMsg, error) { req, ch := newRequest("message") if body != nil { req["body"] = body @@ -725,7 +771,7 @@ func (handle *JanusHandle) Request(ctx context.Context, body interface{}) (*janu if err != nil { return nil, err } - defer handle.session.gateway.removeTransaction(id) + defer handle.session.gateway.RemoveTransaction(id) msg, err := waitForMessage(ctx, ch) if err != nil { @@ -745,7 +791,7 @@ func (handle *JanusHandle) Request(ctx context.Context, body interface{}) (*janu // body should be the plugin data to be passed to the plugin, and jsep should // contain an optional SDP offer/answer to establish a WebRTC PeerConnection. // On success, an EventMsg will be returned and error will be nil. -func (handle *JanusHandle) Message(ctx context.Context, body, jsep interface{}) (*janus.EventMsg, error) { +func (handle *Handle) Message(ctx context.Context, body, jsep any) (*janus.EventMsg, error) { req, ch := newRequest("message") if body != nil { req["body"] = body @@ -757,7 +803,7 @@ func (handle *JanusHandle) Message(ctx context.Context, body, jsep interface{}) if err != nil { return nil, err } - defer handle.session.gateway.removeTransaction(id) + defer handle.session.gateway.RemoveTransaction(id) GetMessage: // No tears.. msg, err := waitForMessage(ctx, ch) @@ -786,14 +832,14 @@ GetMessage: // No tears.. // } // // On success, an AckMsg will be returned and error will be nil. -func (handle *JanusHandle) Trickle(ctx context.Context, candidate interface{}) (*janus.AckMsg, error) { +func (handle *Handle) Trickle(ctx context.Context, candidate any) (*janus.AckMsg, error) { req, ch := newRequest("trickle") req["candidate"] = candidate id, err := handle.send(req, ch) if err != nil { return nil, err } - defer handle.session.gateway.removeTransaction(id) + defer handle.session.gateway.RemoveTransaction(id) msg, err := waitForMessage(ctx, ch) if err != nil { @@ -813,14 +859,14 @@ func (handle *JanusHandle) Trickle(ctx context.Context, candidate interface{}) ( // a new PeerConnection with a plugin. // candidates should be an array of ICE candidates. // On success, an AckMsg will be returned and error will be nil. -func (handle *JanusHandle) TrickleMany(ctx context.Context, candidates interface{}) (*janus.AckMsg, error) { +func (handle *Handle) TrickleMany(ctx context.Context, candidates any) (*janus.AckMsg, error) { req, ch := newRequest("trickle") req["candidates"] = candidates id, err := handle.send(req, ch) if err != nil { return nil, err } - handle.session.gateway.removeTransaction(id) + handle.session.gateway.RemoveTransaction(id) msg, err := waitForMessage(ctx, ch) if err != nil { @@ -838,13 +884,13 @@ func (handle *JanusHandle) TrickleMany(ctx context.Context, candidates interface // Detach sends a detach request to the Gateway to remove this handle. // On success, an AckMsg will be returned and error will be nil. -func (handle *JanusHandle) Detach(ctx context.Context) (*janus.AckMsg, error) { +func (handle *Handle) Detach(ctx context.Context) (*janus.AckMsg, error) { req, ch := newRequest("detach") id, err := handle.send(req, ch) if err != nil { return nil, err } - defer handle.session.gateway.removeTransaction(id) + defer handle.session.gateway.RemoveTransaction(id) msg, err := waitForMessage(ctx, ch) if err != nil { diff --git a/sfu/janus/janus_test.go b/sfu/janus/janus_test.go new file mode 100644 index 0000000..6852e66 --- /dev/null +++ b/sfu/janus/janus_test.go @@ -0,0 +1,1733 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2024 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "context" + "encoding/json" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + metricstest "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/mock" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/janus" + janustest "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/test" +) + +const ( + testTimeout = 10 * time.Second +) + +func TestMcuJanusStats(t *testing.T) { + t.Parallel() + metricstest.CollectAndLint(t, janusMcuStats...) +} + +func newMcuJanusForTesting(t *testing.T) (*janusSFU, *janustest.JanusGateway) { + gateway := janustest.NewJanusGateway(t) + + config := goconf.NewConfigFile() + if strings.Contains(t.Name(), "Filter") { + config.AddOption("mcu", "blockedcandidates", "192.0.0.0/24, 192.168.0.0/16") + } + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + mcu, err := NewJanusSFU(ctx, "", config) + require.NoError(t, err) + t.Cleanup(func() { + mcu.Stop() + }) + + mcuJanus := mcu.(*janusSFU) + mcuJanus.createJanusGateway = func(ctx context.Context, wsURL string, listener janus.GatewayListener) (janus.GatewayInterface, error) { + return gateway, nil + } + require.NoError(t, mcu.Start(ctx)) + return mcuJanus, gateway +} + +type TestMcuListener struct { + id api.PublicSessionId + closed atomic.Bool + updatedOffer chan api.StringMap +} + +func NewTestMcuListener(id api.PublicSessionId) *TestMcuListener { + return &TestMcuListener{ + id: id, + updatedOffer: make(chan api.StringMap), + } +} + +func (t *TestMcuListener) PublicId() api.PublicSessionId { + return t.id +} + +func (t *TestMcuListener) OnUpdateOffer(client sfu.Client, offer api.StringMap) { + t.updatedOffer <- offer +} + +func (t *TestMcuListener) OnIceCandidate(client sfu.Client, candidate any) { + +} + +func (t *TestMcuListener) OnIceCompleted(client sfu.Client) { + +} + +func (t *TestMcuListener) SubscriberSidUpdated(subscriber sfu.Subscriber) { + +} + +func (t *TestMcuListener) PublisherClosed(publisher sfu.Publisher) { + +} + +func (t *TestMcuListener) SubscriberClosed(subscriber sfu.Subscriber) { + t.closed.Store(true) +} + +type TestMcuController struct { + id api.PublicSessionId +} + +func (c *TestMcuController) PublisherId() api.PublicSessionId { + return c.id +} + +func (c *TestMcuController) StartPublishing(ctx context.Context, publisher sfu.RemotePublisherProperties) error { + // TODO: Check parameters? + return nil +} + +func (c *TestMcuController) StopPublishing(ctx context.Context, publisher sfu.RemotePublisherProperties) error { + // TODO: Check parameters? + return nil +} + +func (c *TestMcuController) GetStreams(ctx context.Context) ([]sfu.PublisherStream, error) { + streams := []sfu.PublisherStream{ + { + Mid: "0", + Mindex: 0, + Type: "audio", + Codec: "opus", + }, + } + return streams, nil +} + +type TestMcuInitiator struct { + country geoip.Country +} + +func (i *TestMcuInitiator) Country() geoip.Country { + return i.country +} + +func Test_JanusPublisherFilterOffer(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + mcu, gateway := newMcuJanusForTesting(t) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "configure": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + if assert.NotNil(jsep) { + // The SDP received by Janus will be filtered from blocked candidates. + if sdpValue, found := jsep["sdp"]; assert.True(found) { + sdpText, ok := sdpValue.(string) + if assert.True(ok) { + assert.Equal(mock.MockSdpOfferAudioOnlyNoFilter, strings.ReplaceAll(sdpText, "\r\n", "\n")) + } + } + } + + return &janus.EventMsg{ + Jsep: api.StringMap{ + "sdp": mock.MockSdpAnswerAudioOnly, + }, + }, nil + }, + "trickle": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + return &janus.AckMsg{}, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + defer pub.Close(context.Background()) + + // Send offer containing candidates that will be blocked / filtered. + data := &api.MessageClientMessageData{ + Type: "offer", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioOnly, + }, + } + require.NoError(data.CheckValid()) + + var wg sync.WaitGroup + wg.Add(1) + pub.SendMessage(ctx, &api.MessageClientMessage{}, data, func(err error, m api.StringMap) { + defer wg.Done() + + if assert.NoError(err) { + if sdpValue, found := m["sdp"]; assert.True(found) { + sdpText, ok := sdpValue.(string) + if assert.True(ok) { + assert.Equal(mock.MockSdpAnswerAudioOnly, strings.ReplaceAll(sdpText, "\r\n", "\n")) + } + } + } + }) + wg.Wait() + + data = &api.MessageClientMessageData{ + Type: "candidate", + Payload: api.StringMap{ + "candidate": api.StringMap{ + "candidate": "candidate:1 1 UDP 1685987071 192.168.0.1 49203 typ srflx raddr 198.51.100.7 rport 51556", + }, + }, + } + require.NoError(data.CheckValid()) + wg.Add(1) + pub.SendMessage(ctx, &api.MessageClientMessage{}, data, func(err error, m api.StringMap) { + defer wg.Done() + + assert.ErrorContains(err, "filtered") + assert.Empty(m) + }) + wg.Wait() + + data = &api.MessageClientMessageData{ + Type: "candidate", + Payload: api.StringMap{ + "candidate": api.StringMap{ + "candidate": "candidate:0 1 UDP 2122194687 198.51.100.7 51556 typ host", + }, + }, + } + require.NoError(data.CheckValid()) + wg.Add(1) + pub.SendMessage(ctx, &api.MessageClientMessage{}, data, func(err error, m api.StringMap) { + defer wg.Done() + + assert.NoError(err) + assert.Empty(m) + }) + wg.Wait() +} + +func Test_JanusSubscriberFilterAnswer(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + mcu, gateway := newMcuJanusForTesting(t) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "start": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + if assert.NotNil(jsep) { + // The SDP received by Janus will be filtered from blocked candidates. + if sdpValue, found := jsep["sdp"]; assert.True(found) { + sdpText, ok := sdpValue.(string) + if assert.True(ok) { + assert.Equal(mock.MockSdpAnswerAudioOnlyNoFilter, strings.ReplaceAll(sdpText, "\r\n", "\n")) + } + } + } + + return &janus.EventMsg{ + Plugindata: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{ + "room": room.Id(), + "started": true, + "videoroom": "event", + }, + }, + }, nil + }, + "trickle": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + return &janus.AckMsg{}, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + defer pub.Close(context.Background()) + + listener2 := &TestMcuListener{ + id: pubId, + } + + initiator2 := &TestMcuInitiator{ + country: "DE", + } + sub, err := mcu.NewSubscriber(ctx, listener2, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + defer sub.Close(context.Background()) + + // Send answer containing candidates that will be blocked / filtered. + data := &api.MessageClientMessageData{ + Type: "answer", + Payload: api.StringMap{ + "sdp": mock.MockSdpAnswerAudioOnly, + }, + } + require.NoError(data.CheckValid()) + + var wg sync.WaitGroup + wg.Add(1) + sub.SendMessage(ctx, &api.MessageClientMessage{}, data, func(err error, m api.StringMap) { + defer wg.Done() + + if assert.NoError(err) { + assert.Empty(m) + } + }) + wg.Wait() + + data = &api.MessageClientMessageData{ + Type: "candidate", + Payload: api.StringMap{ + "candidate": api.StringMap{ + "candidate": "candidate:1 1 UDP 1685987071 192.168.0.1 49203 typ srflx raddr 198.51.100.7 rport 51556", + }, + }, + } + require.NoError(data.CheckValid()) + wg.Add(1) + sub.SendMessage(ctx, &api.MessageClientMessage{}, data, func(err error, m api.StringMap) { + defer wg.Done() + + assert.ErrorContains(err, "filtered") + assert.Empty(m) + }) + wg.Wait() + + data = &api.MessageClientMessageData{ + Type: "candidate", + Payload: api.StringMap{ + "candidate": api.StringMap{ + "candidate": "candidate:0 1 UDP 2122194687 198.51.100.7 51556 typ host", + }, + }, + } + require.NoError(data.CheckValid()) + wg.Add(1) + sub.SendMessage(ctx, &api.MessageClientMessage{}, data, func(err error, m api.StringMap) { + defer wg.Done() + + assert.NoError(err) + assert.Empty(m) + }) + wg.Wait() +} + +func Test_JanusPublisherGetStreamsAudioOnly(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + mcu, gateway := newMcuJanusForTesting(t) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "configure": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + if assert.NotNil(jsep) { + if sdpValue, found := jsep["sdp"]; assert.True(found) { + sdpText, ok := sdpValue.(string) + if assert.True(ok) { + assert.Equal(mock.MockSdpOfferAudioOnly, strings.ReplaceAll(sdpText, "\r\n", "\n")) + } + } + } + + return &janus.EventMsg{ + Jsep: api.StringMap{ + "sdp": mock.MockSdpAnswerAudioOnly, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + defer pub.Close(context.Background()) + + data := &api.MessageClientMessageData{ + Type: "offer", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioOnly, + }, + } + require.NoError(data.CheckValid()) + + done := make(chan struct{}) + pub.SendMessage(ctx, &api.MessageClientMessage{}, data, func(err error, m api.StringMap) { + defer close(done) + + if assert.NoError(err) { + if sdpValue, found := m["sdp"]; assert.True(found) { + sdpText, ok := sdpValue.(string) + if assert.True(ok) { + assert.Equal(mock.MockSdpAnswerAudioOnly, strings.ReplaceAll(sdpText, "\r\n", "\n")) + } + } + } + }) + <-done + + if sb, ok := pub.(*janusPublisher); assert.True(ok, "expected publisher with streams support, got %T", pub) { + if streams, err := sb.GetStreams(ctx); assert.NoError(err) { + if assert.Len(streams, 1) { + stream := streams[0] + assert.Equal("audio", stream.Type) + assert.Equal("audio", stream.Mid) + assert.Equal(0, stream.Mindex) + assert.False(stream.Disabled) + assert.Equal("opus", stream.Codec) + assert.False(stream.Stereo) + assert.False(stream.Fec) + assert.False(stream.Dtx) + } + } + } +} + +func Test_JanusPublisherGetStreamsAudioVideo(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + mcu, gateway := newMcuJanusForTesting(t) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "configure": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + if assert.NotNil(jsep) { + _, found := jsep["sdp"] + assert.True(found) + } + + return &janus.EventMsg{ + Jsep: api.StringMap{ + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + defer pub.Close(context.Background()) + + data := &api.MessageClientMessageData{ + Type: "offer", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + } + require.NoError(data.CheckValid()) + + // Defer sending of offer / answer so "GetStreams" will wait. + go func() { + done := make(chan struct{}) + pub.SendMessage(ctx, &api.MessageClientMessage{}, data, func(err error, m api.StringMap) { + defer close(done) + + if assert.NoError(err) { + if sdpValue, found := m["sdp"]; assert.True(found) { + sdpText, ok := sdpValue.(string) + if assert.True(ok) { + assert.Equal(mock.MockSdpAnswerAudioAndVideo, strings.ReplaceAll(sdpText, "\r\n", "\n")) + } + } + } + }) + <-done + }() + + if sb, ok := pub.(*janusPublisher); assert.True(ok, "expected publisher with streams support, got %T", pub) { + if streams, err := sb.GetStreams(ctx); assert.NoError(err) { + if assert.Len(streams, 2) { + stream := streams[0] + assert.Equal("audio", stream.Type) + assert.Equal("audio", stream.Mid) + assert.Equal(0, stream.Mindex) + assert.False(stream.Disabled) + assert.Equal("opus", stream.Codec) + assert.False(stream.Stereo) + assert.False(stream.Fec) + assert.False(stream.Dtx) + + stream = streams[1] + assert.Equal("video", stream.Type) + assert.Equal("video", stream.Mid) + assert.Equal(1, stream.Mindex) + assert.False(stream.Disabled) + assert.Equal("H264", stream.Codec) + assert.Equal("4d0028", stream.ProfileH264) + } + } + } +} + +type mockBandwidthStats struct { + incoming uint64 + outgoing uint64 +} + +func (s *mockBandwidthStats) SetBandwidth(incoming uint64, outgoing uint64) { + s.incoming = incoming + s.outgoing = outgoing +} + +func Test_JanusPublisherSubscriber(t *testing.T) { + t.Parallel() + + stats := &mockBandwidthStats{} + require := require.New(t) + assert := assert.New(t) + + mcu, gateway := newMcuJanusForTesting(t) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{}) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + // Bandwidth for unknown handles is ignored. + mcu.UpdateBandwidth(1234, "video", api.BandwidthFromBytes(100), api.BandwidthFromBytes(200)) + mcu.updateBandwidthStats(stats) + assert.EqualValues(0, stats.incoming) + assert.EqualValues(0, stats.outgoing) + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + defer pub.Close(context.Background()) + + janusPub, ok := pub.(*janusPublisher) + require.True(ok) + + assert.Nil(mcu.Bandwidth()) + assert.Nil(janusPub.Bandwidth()) + mcu.UpdateBandwidth(janusPub.Handle(), "video", api.BandwidthFromBytes(1000), api.BandwidthFromBytes(2000)) + if bw := janusPub.Bandwidth(); assert.NotNil(bw) { + assert.Equal(api.BandwidthFromBytes(1000), bw.Sent) + assert.Equal(api.BandwidthFromBytes(2000), bw.Received) + } + if bw := mcu.Bandwidth(); assert.NotNil(bw) { + assert.Equal(api.BandwidthFromBytes(1000), bw.Sent) + assert.Equal(api.BandwidthFromBytes(2000), bw.Received) + } + mcu.updateBandwidthStats(stats) + assert.EqualValues(2000, stats.incoming) + assert.EqualValues(1000, stats.outgoing) + + listener2 := &TestMcuListener{ + id: pubId, + } + + initiator2 := &TestMcuInitiator{ + country: "DE", + } + sub, err := mcu.NewSubscriber(ctx, listener2, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + defer sub.Close(context.Background()) + + janusSub, ok := sub.(*janusSubscriber) + require.True(ok) + + assert.Nil(janusSub.Bandwidth()) + mcu.UpdateBandwidth(janusSub.Handle(), "video", api.BandwidthFromBytes(3000), api.BandwidthFromBytes(4000)) + if bw := janusSub.Bandwidth(); assert.NotNil(bw) { + assert.Equal(api.BandwidthFromBytes(3000), bw.Sent) + assert.Equal(api.BandwidthFromBytes(4000), bw.Received) + } + if bw := mcu.Bandwidth(); assert.NotNil(bw) { + assert.Equal(api.BandwidthFromBytes(4000), bw.Sent) + assert.Equal(api.BandwidthFromBytes(6000), bw.Received) + } + assert.EqualValues(2000, stats.incoming) + assert.EqualValues(1000, stats.outgoing) + mcu.updateBandwidthStats(stats) + assert.EqualValues(6000, stats.incoming) + assert.EqualValues(4000, stats.outgoing) +} + +func Test_JanusSubscriberPublisher(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + mcu, gateway := newMcuJanusForTesting(t) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{}) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + ready := make(chan struct{}) + done := make(chan struct{}) + + go func() { + defer close(done) + time.Sleep(100 * time.Millisecond) + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + if !assert.NoError(err) { + return + } + + defer func() { + <-ready + pub.Close(context.Background()) + }() + }() + + listener2 := &TestMcuListener{ + id: pubId, + } + + initiator2 := &TestMcuInitiator{ + country: "DE", + } + sub, err := mcu.NewSubscriber(ctx, listener2, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + defer sub.Close(context.Background()) + close(ready) + <-done +} + +func Test_JanusSubscriberRequestOffer(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + var originalOffer atomic.Value + + mcu, gateway := newMcuJanusForTesting(t) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "configure": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + if assert.NotNil(jsep) { + if sdp, found := jsep["sdp"]; assert.True(found) { + originalOffer.Store(strings.ReplaceAll(sdp.(string), "\r\n", "\n")) + } + } + + return &janus.EventMsg{ + Jsep: api.StringMap{ + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + defer pub.Close(context.Background()) + + listener2 := &TestMcuListener{ + id: pubId, + } + + initiator2 := &TestMcuInitiator{ + country: "DE", + } + sub, err := mcu.NewSubscriber(ctx, listener2, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + defer sub.Close(context.Background()) + + go func() { + data := &api.MessageClientMessageData{ + Type: "offer", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + } + if !assert.NoError(data.CheckValid()) { + return + } + + done := make(chan struct{}) + pub.SendMessage(ctx, &api.MessageClientMessage{}, data, func(err error, m api.StringMap) { + defer close(done) + + if assert.NoError(err) { + if sdpValue, found := m["sdp"]; assert.True(found) { + sdpText, ok := sdpValue.(string) + if assert.True(ok) { + assert.Equal(mock.MockSdpAnswerAudioAndVideo, strings.ReplaceAll(sdpText, "\r\n", "\n")) + } + } + } + }) + <-done + }() + + data := &api.MessageClientMessageData{ + Type: "requestoffer", + } + require.NoError(data.CheckValid()) + + done := make(chan struct{}) + sub.SendMessage(ctx, &api.MessageClientMessage{}, data, func(err error, m api.StringMap) { + defer close(done) + + if assert.NoError(err) { + if sdpValue, found := m["sdp"]; assert.True(found) { + sdpText, ok := sdpValue.(string) + if assert.True(ok) { + if sdp := originalOffer.Load(); assert.NotNil(sdp) { + assert.Equal(sdp.(string), strings.ReplaceAll(sdpText, "\r\n", "\n")) + } + } + } + } + }) + <-done +} + +func Test_JanusRemotePublisher(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + var added atomic.Int32 + var removed atomic.Int32 + + mcu, gateway := newMcuJanusForTesting(t) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "add_remote_publisher": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + assert.Nil(jsep) + if streams := body["streams"].([]any); assert.Len(streams, 1) { + if stream, ok := api.ConvertStringMap(streams[0]); assert.True(ok, "not a string map: %+v", streams[0]) { + assert.Equal("0", stream["mid"]) + assert.EqualValues(0, stream["mindex"]) + assert.Equal("audio", stream["type"]) + assert.Equal("opus", stream["codec"]) + } + } + added.Add(1) + return &janus.SuccessMsg{ + PluginData: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{ + "id": 12345, + "port": 10000, + "rtcp_port": 10001, + }, + }, + }, nil + }, + "remove_remote_publisher": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + assert.Nil(jsep) + removed.Add(1) + return &janus.SuccessMsg{ + PluginData: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{}, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + listener1 := &TestMcuListener{ + id: "publisher-id", + } + + controller := &TestMcuController{ + id: listener1.id, + } + + pub, err := mcu.NewRemotePublisher(ctx, listener1, controller, sfu.StreamTypeVideo) + require.NoError(err) + defer pub.Close(context.Background()) + + assert.EqualValues(1, added.Load()) + assert.EqualValues(0, removed.Load()) + + listener2 := &TestMcuListener{ + id: "subscriber-id", + } + + sub, err := mcu.NewRemoteSubscriber(ctx, listener2, pub) + require.NoError(err) + defer sub.Close(context.Background()) + + pub.Close(context.Background()) + + assert.EqualValues(1, added.Load()) + // The publisher is ref-counted, and still referenced by the subscriber. + assert.EqualValues(0, removed.Load()) + + sub.Close(context.Background()) + + assert.EqualValues(1, added.Load()) + assert.EqualValues(1, removed.Load()) +} + +type mockJanusStats struct { + called atomic.Bool + + mu sync.Mutex + // +checklocks:mu + value map[sfu.StreamType]int +} + +func (s *mockJanusStats) Value(streamType sfu.StreamType) int { + s.mu.Lock() + defer s.mu.Unlock() + + return s.value[streamType] +} + +func (s *mockJanusStats) IncSubscriber(streamType sfu.StreamType) { + s.called.Store(true) + + s.mu.Lock() + defer s.mu.Unlock() + + if s.value == nil { + s.value = make(map[sfu.StreamType]int) + } + s.value[streamType]++ +} + +func (s *mockJanusStats) DecSubscriber(streamType sfu.StreamType) { + s.called.Store(true) + + s.mu.Lock() + defer s.mu.Unlock() + + if s.value == nil { + s.value = make(map[sfu.StreamType]int) + } + s.value[streamType]-- +} + +func Test_SubscriberNoSuchRoom(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + stats := &mockJanusStats{} + + t.Cleanup(func() { + if !t.Failed() { + assert.True(stats.called.Load(), "stats were not called") + assert.Equal(0, stats.Value("video")) + } + }) + + mcu, gateway := newMcuJanusForTesting(t) + mcu.SetStats(stats) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "configure": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + return &janus.EventMsg{ + Jsep: api.StringMap{ + "type": "answer", + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + + defer pub.Close(context.Background()) + + msgData := &api.MessageClientMessageData{ + Type: "offer", + RoomType: "video", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + } + require.NoError(msgData.CheckValid()) + payload, err := json.Marshal(msgData) + require.NoError(err) + msg := &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + ch := make(chan struct{}) + pub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpAnswerAudioAndVideo, sm["sdp"]) + }) + <-ch + + listener2 := &TestMcuListener{ + id: pubId, + } + initiator2 := &TestMcuInitiator{ + country: "DE", + } + + sub, err := mcu.NewSubscriber(ctx, listener2, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + + defer sub.Close(context.Background()) + + msgData = &api.MessageClientMessageData{ + Type: "requestoffer", + RoomType: "video", + } + require.NoError(msgData.CheckValid()) + payload, err = json.Marshal(msgData) + require.NoError(err) + msg = &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + sub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.ErrorContains(err, "not created yet for") + assert.Empty(sm) + }) + <-ch + + assert.True(listener2.closed.Load()) + + listener3 := &TestMcuListener{ + id: pubId, + } + + sub, err = mcu.NewSubscriber(ctx, listener3, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + + defer sub.Close(context.Background()) + + sub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpOfferAudioAndVideo, sm["sdp"]) + }) + <-ch + + assert.False(listener3.closed.Load()) +} + +func test_JanusSubscriberAlreadyJoined(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + stats := &mockJanusStats{} + + t.Cleanup(func() { + if !t.Failed() { + assert.True(stats.called.Load(), "stats were not called") + assert.Equal(0, stats.Value("video")) + } + }) + + mcu, gateway := newMcuJanusForTesting(t) + mcu.SetStats(stats) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "configure": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + return &janus.EventMsg{ + Jsep: api.StringMap{ + "type": "answer", + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + + defer pub.Close(context.Background()) + + msgData := &api.MessageClientMessageData{ + Type: "offer", + RoomType: "video", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + } + require.NoError(msgData.CheckValid()) + payload, err := json.Marshal(msgData) + require.NoError(err) + msg := &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + ch := make(chan struct{}) + pub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpAnswerAudioAndVideo, sm["sdp"]) + }) + <-ch + + listener2 := &TestMcuListener{ + id: pubId, + } + initiator2 := &TestMcuInitiator{ + country: "DE", + } + + sub, err := mcu.NewSubscriber(ctx, listener2, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + + defer sub.Close(context.Background()) + + msgData = &api.MessageClientMessageData{ + Type: "requestoffer", + RoomType: "video", + } + require.NoError(msgData.CheckValid()) + payload, err = json.Marshal(msgData) + require.NoError(err) + msg = &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + sub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + if strings.Contains(t.Name(), "AttachError") { + assert.ErrorContains(err, "already connected as subscriber for") + assert.Empty(sm) + } else { + assert.NoError(err) + assert.Equal(mock.MockSdpOfferAudioAndVideo, sm["sdp"]) + } + }) + <-ch + + if strings.Contains(t.Name(), "AttachError") { + assert.True(listener2.closed.Load()) + + listener3 := &TestMcuListener{ + id: pubId, + } + + sub, err := mcu.NewSubscriber(ctx, listener3, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + + defer sub.Close(context.Background()) + + sub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpOfferAudioAndVideo, sm["sdp"]) + }) + <-ch + } +} + +func Test_SubscriberAlreadyJoined(t *testing.T) { + t.Parallel() + test_JanusSubscriberAlreadyJoined(t) +} + +func Test_SubscriberAlreadyJoinedAttachError(t *testing.T) { + t.Parallel() + test_JanusSubscriberAlreadyJoined(t) +} + +func Test_SubscriberTimeout(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + stats := &mockJanusStats{} + + t.Cleanup(func() { + if !t.Failed() { + assert.True(stats.called.Load(), "stats were not called") + assert.Equal(0, stats.Value("video")) + } + }) + + mcu, gateway := newMcuJanusForTesting(t) + mcu.SetStats(stats) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "configure": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + return &janus.EventMsg{ + Jsep: api.StringMap{ + "type": "answer", + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + + defer pub.Close(context.Background()) + + msgData := &api.MessageClientMessageData{ + Type: "offer", + RoomType: "video", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + } + require.NoError(msgData.CheckValid()) + payload, err := json.Marshal(msgData) + require.NoError(err) + msg := &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + ch := make(chan struct{}) + pub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpAnswerAudioAndVideo, sm["sdp"]) + }) + <-ch + + oldTimeout := mcu.Settings().Timeout() + mcu.Settings().SetTimeout(100 * time.Millisecond) + + listener2 := &TestMcuListener{ + id: pubId, + } + initiator2 := &TestMcuInitiator{ + country: "DE", + } + + sub, err := mcu.NewSubscriber(ctx, listener2, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + + defer sub.Close(context.Background()) + + msgData = &api.MessageClientMessageData{ + Type: "requestoffer", + RoomType: "video", + } + require.NoError(msgData.CheckValid()) + payload, err = json.Marshal(msgData) + require.NoError(err) + msg = &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + sub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.ErrorIs(err, context.DeadlineExceeded) + assert.Empty(sm) + }) + <-ch + + assert.True(listener2.closed.Load()) + + mcu.Settings().SetTimeout(oldTimeout) + + listener3 := &TestMcuListener{ + id: pubId, + } + + sub, err = mcu.NewSubscriber(ctx, listener3, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + + defer sub.Close(context.Background()) + + sub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpOfferAudioAndVideo, sm["sdp"]) + }) + <-ch +} + +func Test_SubscriberCloseEmptyStreams(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + stats := &mockJanusStats{} + + t.Cleanup(func() { + if !t.Failed() { + assert.True(stats.called.Load(), "stats were not called") + assert.Equal(0, stats.Value("video")) + } + }) + + mcu, gateway := newMcuJanusForTesting(t) + mcu.SetStats(stats) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "configure": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + return &janus.EventMsg{ + Jsep: api.StringMap{ + "type": "answer", + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + + defer pub.Close(context.Background()) + + msgData := &api.MessageClientMessageData{ + Type: "offer", + RoomType: "video", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + } + require.NoError(msgData.CheckValid()) + payload, err := json.Marshal(msgData) + require.NoError(err) + msg := &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + ch := make(chan struct{}) + pub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpAnswerAudioAndVideo, sm["sdp"]) + }) + <-ch + + listener2 := &TestMcuListener{ + id: pubId, + } + initiator2 := &TestMcuInitiator{ + country: "DE", + } + + sub, err := mcu.NewSubscriber(ctx, listener2, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + + defer sub.Close(context.Background()) + + msgData = &api.MessageClientMessageData{ + Type: "requestoffer", + RoomType: "video", + } + require.NoError(msgData.CheckValid()) + payload, err = json.Marshal(msgData) + require.NoError(err) + msg = &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + sub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpOfferAudioAndVideo, sm["sdp"]) + }) + <-ch + + subscriber, ok := sub.(*janusSubscriber) + require.True(ok) + handle := subscriber.JanusHandle() + require.NotNil(handle) + + for ctx.Err() == nil { + if handle = subscriber.JanusHandle(); handle == nil && listener2.closed.Load() { + break + } + + time.Sleep(time.Millisecond) + } + + assert.Nil(handle, "subscriber should have been closed") + assert.True(listener2.closed.Load()) +} + +func Test_SubscriberRoomDestroyed(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + stats := &mockJanusStats{} + + t.Cleanup(func() { + if !t.Failed() { + assert.True(stats.called.Load(), "stats were not called") + assert.Equal(0, stats.Value("video")) + } + }) + + mcu, gateway := newMcuJanusForTesting(t) + mcu.SetStats(stats) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "configure": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + return &janus.EventMsg{ + Jsep: api.StringMap{ + "type": "answer", + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + + defer pub.Close(context.Background()) + + msgData := &api.MessageClientMessageData{ + Type: "offer", + RoomType: "video", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + } + require.NoError(msgData.CheckValid()) + payload, err := json.Marshal(msgData) + require.NoError(err) + msg := &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + ch := make(chan struct{}) + pub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpAnswerAudioAndVideo, sm["sdp"]) + }) + <-ch + + listener2 := &TestMcuListener{ + id: pubId, + } + initiator2 := &TestMcuInitiator{ + country: "DE", + } + + sub, err := mcu.NewSubscriber(ctx, listener2, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + + defer sub.Close(context.Background()) + + msgData = &api.MessageClientMessageData{ + Type: "requestoffer", + RoomType: "video", + } + require.NoError(msgData.CheckValid()) + payload, err = json.Marshal(msgData) + require.NoError(err) + msg = &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + sub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpOfferAudioAndVideo, sm["sdp"]) + }) + <-ch + + subscriber, ok := sub.(*janusSubscriber) + require.True(ok) + handle := subscriber.JanusHandle() + require.NotNil(handle) + + for ctx.Err() == nil { + if handle = subscriber.JanusHandle(); handle == nil && listener2.closed.Load() { + break + } + + time.Sleep(time.Millisecond) + } + + assert.Nil(handle, "subscriber should have been closed") + assert.True(listener2.closed.Load()) +} + +func Test_SubscriberUpdateOffer(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + stats := &mockJanusStats{} + + t.Cleanup(func() { + if !t.Failed() { + assert.True(stats.called.Load(), "stats were not called") + assert.Equal(0, stats.Value("video")) + } + }) + + mcu, gateway := newMcuJanusForTesting(t) + mcu.SetStats(stats) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "configure": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + assert.EqualValues(1, room.Id()) + return &janus.EventMsg{ + Jsep: api.StringMap{ + "type": "answer", + "sdp": mock.MockSdpAnswerAudioAndVideo, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + + defer pub.Close(context.Background()) + + msgData := &api.MessageClientMessageData{ + Type: "offer", + RoomType: "video", + Payload: api.StringMap{ + "sdp": mock.MockSdpOfferAudioAndVideo, + }, + } + require.NoError(msgData.CheckValid()) + payload, err := json.Marshal(msgData) + require.NoError(err) + msg := &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + ch := make(chan struct{}) + pub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpAnswerAudioAndVideo, sm["sdp"]) + }) + <-ch + + listener2 := NewTestMcuListener(pubId) + initiator2 := &TestMcuInitiator{ + country: "DE", + } + + sub, err := mcu.NewSubscriber(ctx, listener2, pubId, sfu.StreamTypeVideo, initiator2) + require.NoError(err) + + defer sub.Close(context.Background()) + + msgData = &api.MessageClientMessageData{ + Type: "requestoffer", + RoomType: "video", + } + require.NoError(msgData.CheckValid()) + payload, err = json.Marshal(msgData) + require.NoError(err) + msg = &api.MessageClientMessage{ + Recipient: api.MessageClientMessageRecipient{ + Type: "session", + SessionId: pubId, + }, + Data: payload, + } + + sub.SendMessage(ctx, msg, msgData, func(err error, sm api.StringMap) { + defer func() { + ch <- struct{}{} + }() + + assert.NoError(err) + assert.Equal(mock.MockSdpOfferAudioAndVideo, sm["sdp"]) + }) + <-ch + + // Test MCU will trigger an updated offer. + select { + case offer := <-listener2.updatedOffer: + assert.Equal(mock.MockSdpOfferAudioOnly, offer["sdp"]) + case <-ctx.Done(): + assert.NoError(ctx.Err()) + } +} diff --git a/mcu_janus_publisher.go b/sfu/janus/publisher.go similarity index 57% rename from mcu_janus_publisher.go rename to sfu/janus/publisher.go index 9e82d80..3a2a511 100644 --- a/mcu_janus_publisher.go +++ b/sfu/janus/publisher.go @@ -19,19 +19,23 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package janus import ( "context" "errors" "fmt" - "log" "strconv" "strings" "sync/atomic" - "github.com/notedit/janus-go" "github.com/pion/sdp/v3" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + sfuinternal "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/janus" ) const ( @@ -44,61 +48,65 @@ const ( sdpHasAnswer = 2 ) -type mcuJanusPublisher struct { - mcuJanusClient +type janusPublisher struct { + janusClient - id string - settings NewPublisherSettings + id api.PublicSessionId + settings sfu.NewPublisherSettings stats publisherStatsCounter - sdpFlags Flags - sdpReady *Closer + sdpFlags internal.Flags + sdpReady *internal.Closer offerSdp atomic.Pointer[sdp.SessionDescription] answerSdp atomic.Pointer[sdp.SessionDescription] } -func (p *mcuJanusPublisher) handleEvent(event *janus.EventMsg) { +func (p *janusPublisher) PublisherId() api.PublicSessionId { + return p.id +} + +func (p *janusPublisher) handleEvent(event *janus.EventMsg) { if videoroom := getPluginStringValue(event.Plugindata, pluginVideoRoom, "videoroom"); videoroom != "" { ctx := context.TODO() switch videoroom { case "destroyed": - log.Printf("Publisher %d: associated room has been destroyed, closing", p.handleId) + p.logger.Printf("Publisher %d: associated room has been destroyed, closing", p.handleId.Load()) go p.Close(ctx) case "slow_link": // Ignore, processed through "handleSlowLink" in the general events. default: - log.Printf("Unsupported videoroom publisher event in %d: %+v", p.handleId, event) + p.logger.Printf("Unsupported videoroom publisher event in %d: %+v", p.handleId.Load(), event) } } else { - log.Printf("Unsupported publisher event in %d: %+v", p.handleId, event) + p.logger.Printf("Unsupported publisher event in %d: %+v", p.handleId.Load(), event) } } -func (p *mcuJanusPublisher) handleHangup(event *janus.HangupMsg) { - log.Printf("Publisher %d received hangup (%s), closing", p.handleId, event.Reason) +func (p *janusPublisher) handleHangup(event *janus.HangupMsg) { + p.logger.Printf("Publisher %d received hangup (%s), closing", p.handleId.Load(), event.Reason) go p.Close(context.Background()) } -func (p *mcuJanusPublisher) handleDetached(event *janus.DetachedMsg) { - log.Printf("Publisher %d received detached, closing", p.handleId) +func (p *janusPublisher) handleDetached(event *janus.DetachedMsg) { + p.logger.Printf("Publisher %d received detached, closing", p.handleId.Load()) go p.Close(context.Background()) } -func (p *mcuJanusPublisher) handleConnected(event *janus.WebRTCUpMsg) { - log.Printf("Publisher %d received connected", p.handleId) - p.mcu.publisherConnected.Notify(getStreamId(p.id, p.streamType)) +func (p *janusPublisher) handleConnected(event *janus.WebRTCUpMsg) { + p.logger.Printf("Publisher %d received connected", p.handleId.Load()) + p.mcu.notifyPublisherConnected(p.id, p.streamType) } -func (p *mcuJanusPublisher) handleSlowLink(event *janus.SlowLinkMsg) { +func (p *janusPublisher) handleSlowLink(event *janus.SlowLinkMsg) { if event.Uplink { - log.Printf("Publisher %s (%d) is reporting %d lost packets on the uplink (Janus -> client)", p.listener.PublicId(), p.handleId, event.Lost) + p.logger.Printf("Publisher %s (%d) is reporting %d lost packets on the uplink (Janus -> client)", p.listener.PublicId(), p.handleId.Load(), event.Lost) } else { - log.Printf("Publisher %s (%d) is reporting %d lost packets on the downlink (client -> Janus)", p.listener.PublicId(), p.handleId, event.Lost) + p.logger.Printf("Publisher %s (%d) is reporting %d lost packets on the downlink (client -> Janus)", p.listener.PublicId(), p.handleId.Load(), event.Lost) } } -func (p *mcuJanusPublisher) handleMedia(event *janus.MediaMsg) { - mediaType := StreamType(event.Type) - if mediaType == StreamTypeVideo && p.streamType == StreamTypeScreen { +func (p *janusPublisher) handleMedia(event *janus.MediaMsg) { + mediaType := sfu.StreamType(event.Type) + if mediaType == sfu.StreamTypeVideo && p.streamType == sfu.StreamTypeScreen { // We want to differentiate between audio, video and screensharing mediaType = p.streamType } @@ -106,46 +114,50 @@ func (p *mcuJanusPublisher) handleMedia(event *janus.MediaMsg) { p.stats.EnableStream(mediaType, event.Receiving) } -func (p *mcuJanusPublisher) HasMedia(mt MediaType) bool { +func (p *janusPublisher) HasMedia(mt sfu.MediaType) bool { return (p.settings.MediaTypes & mt) == mt } -func (p *mcuJanusPublisher) SetMedia(mt MediaType) { +func (p *janusPublisher) SetMedia(mt sfu.MediaType) { p.settings.MediaTypes = mt } -func (p *mcuJanusPublisher) NotifyReconnected() { +func (p *janusPublisher) NotifyReconnected() { ctx := context.TODO() handle, session, roomId, _, err := p.mcu.getOrCreatePublisherHandle(ctx, p.id, p.streamType, p.settings) if err != nil { - log.Printf("Could not reconnect publisher %s: %s", p.id, err) + p.logger.Printf("Could not reconnect publisher %s: %s", p.id, err) // TODO(jojo): Retry return } - p.handle = handle - p.handleId = handle.Id + if prev := p.handle.Swap(handle); prev != nil { + if _, err := prev.Detach(context.Background()); err != nil { + p.logger.Printf("Error detaching old publisher handle %d: %s", prev.Id, err) + } + } + p.handleId.Store(handle.Id) p.session = session p.roomId = roomId - log.Printf("Publisher %s reconnected on handle %d", p.id, p.handleId) + p.logger.Printf("Publisher %s reconnected on handle %d", p.id, p.handleId.Load()) } -func (p *mcuJanusPublisher) Close(ctx context.Context) { +func (p *janusPublisher) Close(ctx context.Context) { notify := false p.mu.Lock() - if handle := p.handle; handle != nil && p.roomId != 0 { - destroy_msg := map[string]interface{}{ + if handle := p.handle.Load(); handle != nil && p.roomId != 0 { + destroy_msg := api.StringMap{ "request": "destroy", "room": p.roomId, } if _, err := handle.Request(ctx, destroy_msg); err != nil { - log.Printf("Error destroying room %d: %s", p.roomId, err) + p.logger.Printf("Error destroying room %d: %s", p.roomId, err) } else { - log.Printf("Room %d destroyed", p.roomId) + p.logger.Printf("Room %d destroyed", p.roomId) } p.mcu.mu.Lock() - delete(p.mcu.publishers, getStreamId(p.id, p.streamType)) + delete(p.mcu.publishers, sfu.GetStreamId(p.id, p.streamType)) p.mcu.mu.Unlock() p.roomId = 0 notify = true @@ -156,26 +168,37 @@ func (p *mcuJanusPublisher) Close(ctx context.Context) { p.stats.Reset() if notify { - statsPublishersCurrent.WithLabelValues(string(p.streamType)).Dec() + sfuinternal.StatsPublishersCurrent.WithLabelValues(string(p.streamType)).Dec() p.mcu.unregisterClient(p) p.listener.PublisherClosed(p) } - p.mcuJanusClient.Close(ctx) + p.janusClient.Close(ctx) } -func (p *mcuJanusPublisher) SendMessage(ctx context.Context, message *MessageClientMessage, data *MessageClientMessageData, callback func(error, map[string]interface{})) { - statsMcuMessagesTotal.WithLabelValues(data.Type).Inc() +func (p *janusPublisher) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { + sfuinternal.StatsMcuMessagesTotal.WithLabelValues(data.Type).Inc() jsep_msg := data.Payload switch data.Type { case "offer": p.deferred <- func() { - if data.offerSdp == nil { + if data.OfferSdp == nil { // Should have been checked before. - go callback(errors.New("No sdp found in offer"), nil) + go callback(errors.New("no sdp found in offer"), nil) return } - p.offerSdp.Store(data.offerSdp) + if api.FilterSDPCandidates(data.OfferSdp, p.mcu.settings.allowedCandidates.Load(), p.mcu.settings.blockedCandidates.Load()) { + // Update request with filtered SDP. + marshalled, err := data.OfferSdp.Marshal() + if err != nil { + go callback(fmt.Errorf("could not marshal filtered offer: %w", err), nil) + return + } + + jsep_msg["sdp"] = string(marshalled) + } + + p.offerSdp.Store(data.OfferSdp) p.sdpFlags.Add(sdpHasOffer) if p.sdpFlags.Get() == sdpHasAnswer|sdpHasOffer { p.sdpReady.Close() @@ -186,32 +209,25 @@ func (p *mcuJanusPublisher) SendMessage(ctx context.Context, message *MessageCli msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) defer cancel() - p.sendOffer(msgctx, jsep_msg, func(err error, jsep map[string]interface{}) { + p.sendOffer(msgctx, jsep_msg, func(err error, jsep api.StringMap) { if err != nil { callback(err, jsep) return } - sdpData, found := jsep["sdp"] + sdpString, found := api.GetStringMapEntry[string](jsep, "sdp") if !found { - log.Printf("No sdp found in answer %+v", jsep) + p.logger.Printf("No/invalid sdp found in answer %+v", jsep) + } else if answerSdp, err := api.ParseSDP(sdpString); err != nil { + p.logger.Printf("Error parsing answer sdp %+v: %s", sdpString, err) + p.answerSdp.Store(nil) + p.sdpFlags.Remove(sdpHasAnswer) } else { - sdpString, ok := sdpData.(string) - if !ok { - log.Printf("Invalid sdp found in answer %+v", jsep) - } else { - var answerSdp sdp.SessionDescription - if err := answerSdp.UnmarshalString(sdpString); err != nil { - log.Printf("Error parsing answer sdp %+v: %s", sdpString, err) - p.answerSdp.Store(nil) - p.sdpFlags.Remove(sdpHasAnswer) - } else { - p.answerSdp.Store(&answerSdp) - p.sdpFlags.Add(sdpHasAnswer) - if p.sdpFlags.Get() == sdpHasAnswer|sdpHasOffer { - p.sdpReady.Close() - } - } + // Note: we don't need to filter the SDP received from Janus. + p.answerSdp.Store(answerSdp) + p.sdpFlags.Add(sdpHasAnswer) + if p.sdpFlags.Get() == sdpHasAnswer|sdpHasOffer { + p.sdpReady.Close() } } @@ -219,6 +235,11 @@ func (p *mcuJanusPublisher) SendMessage(ctx context.Context, message *MessageCli }) } case "candidate": + if api.FilterCandidate(data.Candidate, p.mcu.settings.allowedCandidates.Load(), p.mcu.settings.blockedCandidates.Load()) { + go callback(api.ErrCandidateFiltered, nil) + return + } + p.deferred <- func() { msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) defer cancel() @@ -226,19 +247,18 @@ func (p *mcuJanusPublisher) SendMessage(ctx context.Context, message *MessageCli if data.Sid == "" || data.Sid == p.Sid() { p.sendCandidate(msgctx, jsep_msg["candidate"], callback) } else { - go callback(fmt.Errorf("Candidate message sid (%s) does not match publisher sid (%s)", data.Sid, p.Sid()), nil) + go callback(fmt.Errorf("candidate message sid (%s) does not match publisher sid (%s)", data.Sid, p.Sid()), nil) } } case "endOfCandidates": // Ignore default: - go callback(fmt.Errorf("Unsupported message type: %s", data.Type), nil) + go callback(fmt.Errorf("unsupported message type: %s", data.Type), nil) } } func getFmtpValue(fmtp string, key string) (string, bool) { - parts := strings.Split(fmtp, ";") - for _, part := range parts { + for part := range internal.SplitEntries(fmtp, ";") { kv := strings.SplitN(part, "=", 2) if len(kv) != 2 { continue @@ -252,7 +272,7 @@ func getFmtpValue(fmtp string, key string) (string, bool) { return "", false } -func (p *mcuJanusPublisher) GetStreams(ctx context.Context) ([]PublisherStream, error) { +func (p *janusPublisher) GetStreams(ctx context.Context) ([]sfu.PublisherStream, error) { offerSdp := p.offerSdp.Load() answerSdp := p.answerSdp.Load() if offerSdp == nil || answerSdp == nil { @@ -269,14 +289,14 @@ func (p *mcuJanusPublisher) GetStreams(ctx context.Context) ([]PublisherStream, } } - var streams []PublisherStream + var streams []sfu.PublisherStream for idx, m := range answerSdp.MediaDescriptions { mid, found := m.Attribute(sdp.AttrKeyMID) if !found { continue } - s := PublisherStream{ + s := sfu.PublisherStream{ Mid: mid, Mindex: idx, Type: m.MediaName.Media, @@ -302,7 +322,8 @@ func (p *mcuJanusPublisher) GetStreams(ctx context.Context) ([]PublisherStream, continue } - if strings.EqualFold(s.Type, "audio") { + switch { + case strings.EqualFold(s.Type, "audio"): s.Codec = answerCodec.Name if value, found := getFmtpValue(answerCodec.Fmtp, "useinbandfec"); found && value == "1" { s.Fec = true @@ -313,7 +334,7 @@ func (p *mcuJanusPublisher) GetStreams(ctx context.Context) ([]PublisherStream, if value, found := getFmtpValue(answerCodec.Fmtp, "stereo"); found && value == "1" { s.Stereo = true } - } else if strings.EqualFold(s.Type, "video") { + case strings.EqualFold(s.Type, "video"): s.Codec = answerCodec.Name // TODO: Determine if SVC is used. s.Svc = false @@ -337,7 +358,7 @@ func (p *mcuJanusPublisher) GetStreams(ctx context.Context) ([]PublisherStream, switch a.Key { case sdp.AttrKeyExtMap: if err := extmap.Unmarshal(extmap.Name() + ":" + a.Value); err != nil { - log.Printf("Error parsing extmap %s: %s", a.Value, err) + p.logger.Printf("Error parsing extmap %s: %s", a.Value, err) continue } @@ -367,10 +388,10 @@ func (p *mcuJanusPublisher) GetStreams(ctx context.Context) ([]PublisherStream, } } - } else if strings.EqualFold(s.Type, "data") { // nolint + case strings.EqualFold(s.Type, "data"): // Already handled above. - } else { - log.Printf("Skip type %s", s.Type) + default: + p.logger.Printf("Skip type %s", s.Type) continue } @@ -380,12 +401,17 @@ func (p *mcuJanusPublisher) GetStreams(ctx context.Context) ([]PublisherStream, return streams, nil } -func getPublisherRemoteId(id string, remoteId string, hostname string, port int, rtcpPort int) string { +func getPublisherRemoteId(id api.PublicSessionId, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) string { return fmt.Sprintf("%s-%s@%s:%d:%d", id, remoteId, hostname, port, rtcpPort) } -func (p *mcuJanusPublisher) PublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error { - msg := map[string]interface{}{ +func (p *janusPublisher) PublishRemote(ctx context.Context, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) error { + handle := p.handle.Load() + if handle == nil { + return sfu.ErrNotConnected + } + + msg := api.StringMap{ "request": "publish_remotely", "room": p.roomId, "publisher_id": streamTypeUserIds[p.streamType], @@ -394,13 +420,13 @@ func (p *mcuJanusPublisher) PublishRemote(ctx context.Context, remoteId string, "port": port, "rtcp_port": rtcpPort, } - response, err := p.handle.Request(ctx, msg) + response, err := handle.Request(ctx, msg) if err != nil { return err } errorMessage := getPluginStringValue(response.PluginData, pluginVideoRoom, "error") - errorCode := getPluginIntValue(response.PluginData, pluginVideoRoom, "error_code") + errorCode := getPluginIntValue(p.logger, response.PluginData, pluginVideoRoom, "error_code") if errorMessage != "" || errorCode != 0 { if errorCode == 0 { errorCode = 500 @@ -417,24 +443,29 @@ func (p *mcuJanusPublisher) PublishRemote(ctx context.Context, remoteId string, } } - log.Printf("Publishing %s to %s (port=%d, rtcpPort=%d) for %s", p.id, hostname, port, rtcpPort, remoteId) + p.logger.Printf("Publishing %s to %s (port=%d, rtcpPort=%d) for %s", p.id, hostname, port, rtcpPort, remoteId) return nil } -func (p *mcuJanusPublisher) UnpublishRemote(ctx context.Context, remoteId string, hostname string, port int, rtcpPort int) error { - msg := map[string]interface{}{ +func (p *janusPublisher) UnpublishRemote(ctx context.Context, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) error { + handle := p.handle.Load() + if handle == nil { + return sfu.ErrNotConnected + } + + msg := api.StringMap{ "request": "unpublish_remotely", "room": p.roomId, "publisher_id": streamTypeUserIds[p.streamType], "remote_id": getPublisherRemoteId(p.id, remoteId, hostname, port, rtcpPort), } - response, err := p.handle.Request(ctx, msg) + response, err := handle.Request(ctx, msg) if err != nil { return err } errorMessage := getPluginStringValue(response.PluginData, pluginVideoRoom, "error") - errorCode := getPluginIntValue(response.PluginData, pluginVideoRoom, "error_code") + errorCode := getPluginIntValue(p.logger, response.PluginData, pluginVideoRoom, "error_code") if errorMessage != "" || errorCode != 0 { if errorCode == 0 { errorCode = 500 @@ -451,6 +482,6 @@ func (p *mcuJanusPublisher) UnpublishRemote(ctx context.Context, remoteId string } } - log.Printf("Unpublished remote %s for %s", p.id, remoteId) + p.logger.Printf("Unpublished remote %s for %s", p.id, remoteId) return nil } diff --git a/sfu/janus/publisher_stats_counter.go b/sfu/janus/publisher_stats_counter.go new file mode 100644 index 0000000..57ebeed --- /dev/null +++ b/sfu/janus/publisher_stats_counter.go @@ -0,0 +1,164 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "sync" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" +) + +type publisherStatsCounterStats interface { + IncPublisherStream(streamType sfu.StreamType) + DecPublisherStream(streamType sfu.StreamType) + IncSubscriberStream(streamType sfu.StreamType) + DecSubscriberStream(streamType sfu.StreamType) + AddSubscriberStreams(streamType sfu.StreamType, count int) + SubSubscriberStreams(streamType sfu.StreamType, count int) +} + +type prometheusPublisherStats struct{} + +func (s *prometheusPublisherStats) IncPublisherStream(streamType sfu.StreamType) { + statsMcuPublisherStreamTypesCurrent.WithLabelValues(string(streamType)).Inc() +} + +func (s *prometheusPublisherStats) DecPublisherStream(streamType sfu.StreamType) { + statsMcuPublisherStreamTypesCurrent.WithLabelValues(string(streamType)).Dec() +} + +func (s *prometheusPublisherStats) IncSubscriberStream(streamType sfu.StreamType) { + statsMcuSubscriberStreamTypesCurrent.WithLabelValues(string(streamType)).Inc() +} + +func (s *prometheusPublisherStats) DecSubscriberStream(streamType sfu.StreamType) { + statsMcuSubscriberStreamTypesCurrent.WithLabelValues(string(streamType)).Dec() +} + +func (s *prometheusPublisherStats) AddSubscriberStreams(streamType sfu.StreamType, count int) { + statsMcuSubscriberStreamTypesCurrent.WithLabelValues(string(streamType)).Add(float64(count)) +} + +func (s *prometheusPublisherStats) SubSubscriberStreams(streamType sfu.StreamType, count int) { + statsMcuSubscriberStreamTypesCurrent.WithLabelValues(string(streamType)).Sub(float64(count)) +} + +var ( + defaultPublisherStats = &prometheusPublisherStats{} // +checklocksignore: Global readonly variable. +) + +type publisherStatsCounter struct { + mu sync.Mutex + + // +checklocks:mu + streamTypes map[sfu.StreamType]bool + // +checklocks:mu + subscribers map[string]bool + // +checklocks:mu + stats publisherStatsCounterStats +} + +func (c *publisherStatsCounter) Reset() { + c.mu.Lock() + defer c.mu.Unlock() + + stats := c.stats + if stats == nil { + stats = defaultPublisherStats + } + + count := len(c.subscribers) + for streamType := range c.streamTypes { + stats.DecPublisherStream(streamType) + stats.SubSubscriberStreams(streamType, count) + } + c.streamTypes = nil + c.subscribers = nil +} + +func (c *publisherStatsCounter) EnableStream(streamType sfu.StreamType, enable bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if enable == c.streamTypes[streamType] { + return + } + + stats := c.stats + if stats == nil { + stats = defaultPublisherStats + } + + if enable { + if c.streamTypes == nil { + c.streamTypes = make(map[sfu.StreamType]bool) + } + c.streamTypes[streamType] = true + stats.IncPublisherStream(streamType) + stats.AddSubscriberStreams(streamType, len(c.subscribers)) + } else { + delete(c.streamTypes, streamType) + stats.DecPublisherStream(streamType) + stats.SubSubscriberStreams(streamType, len(c.subscribers)) + } +} + +func (c *publisherStatsCounter) AddSubscriber(id string) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.subscribers[id] { + return + } + + stats := c.stats + if stats == nil { + stats = defaultPublisherStats + } + + if c.subscribers == nil { + c.subscribers = make(map[string]bool) + } + c.subscribers[id] = true + for streamType := range c.streamTypes { + stats.IncSubscriberStream(streamType) + } +} + +func (c *publisherStatsCounter) RemoveSubscriber(id string) { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.subscribers[id] { + return + } + + stats := c.stats + if stats == nil { + stats = defaultPublisherStats + } + + delete(c.subscribers, id) + for streamType := range c.streamTypes { + stats.DecSubscriberStream(streamType) + } +} diff --git a/sfu/janus/publisher_stats_counter_test.go b/sfu/janus/publisher_stats_counter_test.go new file mode 100644 index 0000000..3356440 --- /dev/null +++ b/sfu/janus/publisher_stats_counter_test.go @@ -0,0 +1,179 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" +) + +type mockPublisherStats struct { + publishers map[sfu.StreamType]int + subscribers map[sfu.StreamType]int +} + +func (s *mockPublisherStats) IncPublisherStream(streamType sfu.StreamType) { + if s.publishers == nil { + s.publishers = make(map[sfu.StreamType]int) + } + s.publishers[streamType]++ +} + +func (s *mockPublisherStats) DecPublisherStream(streamType sfu.StreamType) { + if s.publishers == nil { + s.publishers = make(map[sfu.StreamType]int) + } + s.publishers[streamType]-- +} + +func (s *mockPublisherStats) IncSubscriberStream(streamType sfu.StreamType) { + if s.subscribers == nil { + s.subscribers = make(map[sfu.StreamType]int) + } + s.subscribers[streamType]++ +} + +func (s *mockPublisherStats) DecSubscriberStream(streamType sfu.StreamType) { + if s.subscribers == nil { + s.subscribers = make(map[sfu.StreamType]int) + } + s.subscribers[streamType]-- +} + +func (s *mockPublisherStats) AddSubscriberStreams(streamType sfu.StreamType, count int) { + if s.subscribers == nil { + s.subscribers = make(map[sfu.StreamType]int) + } + s.subscribers[streamType] += count +} + +func (s *mockPublisherStats) SubSubscriberStreams(streamType sfu.StreamType, count int) { + if s.subscribers == nil { + s.subscribers = make(map[sfu.StreamType]int) + } + s.subscribers[streamType] -= count +} + +func (s *mockPublisherStats) Publishers(streamType sfu.StreamType) int { + return s.publishers[streamType] +} + +func (s *mockPublisherStats) Subscribers(streamType sfu.StreamType) int { + return s.subscribers[streamType] +} + +func TestPublisherStatsPrometheus(t *testing.T) { + t.Parallel() + + RegisterStats() + UnregisterStats() +} + +func TestPublisherStatsCounter(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + stats := &mockPublisherStats{} + c := publisherStatsCounter{ + stats: stats, + } + + c.Reset() + assert.Equal(0, stats.Publishers("audio")) + c.EnableStream("audio", false) + assert.Equal(0, stats.Publishers("audio")) + c.EnableStream("audio", true) + assert.Equal(1, stats.Publishers("audio")) + c.EnableStream("audio", true) + assert.Equal(1, stats.Publishers("audio")) + c.EnableStream("video", true) + assert.Equal(1, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + c.EnableStream("audio", false) + assert.Equal(0, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + c.EnableStream("audio", false) + assert.Equal(0, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + + c.AddSubscriber("1") + assert.Equal(0, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + assert.Equal(0, stats.Subscribers("audio")) + assert.Equal(1, stats.Subscribers("video")) + c.EnableStream("audio", true) + assert.Equal(1, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + assert.Equal(1, stats.Subscribers("audio")) + assert.Equal(1, stats.Subscribers("video")) + c.AddSubscriber("1") + assert.Equal(1, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + assert.Equal(1, stats.Subscribers("audio")) + assert.Equal(1, stats.Subscribers("video")) + + c.AddSubscriber("2") + assert.Equal(1, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + assert.Equal(2, stats.Subscribers("audio")) + assert.Equal(2, stats.Subscribers("video")) + + c.RemoveSubscriber("3") + assert.Equal(1, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + assert.Equal(2, stats.Subscribers("audio")) + assert.Equal(2, stats.Subscribers("video")) + + c.RemoveSubscriber("1") + assert.Equal(1, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + assert.Equal(1, stats.Subscribers("audio")) + assert.Equal(1, stats.Subscribers("video")) + + c.AddSubscriber("1") + assert.Equal(1, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + assert.Equal(2, stats.Subscribers("audio")) + assert.Equal(2, stats.Subscribers("video")) + + c.EnableStream("audio", false) + assert.Equal(0, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + assert.Equal(0, stats.Subscribers("audio")) + assert.Equal(2, stats.Subscribers("video")) + + c.EnableStream("audio", true) + assert.Equal(1, stats.Publishers("audio")) + assert.Equal(1, stats.Publishers("video")) + assert.Equal(2, stats.Subscribers("audio")) + assert.Equal(2, stats.Subscribers("video")) + + c.EnableStream("audio", false) + c.EnableStream("video", false) + assert.Equal(0, stats.Publishers("audio")) + assert.Equal(0, stats.Publishers("video")) + assert.Equal(0, stats.Subscribers("audio")) + assert.Equal(0, stats.Subscribers("video")) +} diff --git a/sfu/janus/publisher_test.go b/sfu/janus/publisher_test.go new file mode 100644 index 0000000..6dd5078 --- /dev/null +++ b/sfu/janus/publisher_test.go @@ -0,0 +1,180 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2024 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/janus" + janustest "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/test" +) + +func TestGetFmtpValueH264(t *testing.T) { + t.Parallel() + assert := assert.New(t) + testcases := []struct { + fmtp string + profile string + }{ + { + "", + "", + }, + { + "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f", + "42001f", + }, + { + "level-asymmetry-allowed=1;packetization-mode=0", + "", + }, + { + "level-asymmetry-allowed=1; packetization-mode=0; profile-level-id = 42001f", + "42001f", + }, + } + + for _, tc := range testcases { + value, found := getFmtpValue(tc.fmtp, "profile-level-id") + if !found && tc.profile != "" { + assert.Fail("did not find profile", "profile \"%s\" in \"%s\"", tc.profile, tc.fmtp) + } else if found && tc.profile == "" { + assert.Fail("did not expect profile", "in \"%s\" but got \"%s\"", tc.fmtp, value) + } else if found && tc.profile != value { + assert.Fail("expected profile", "profile \"%s\" in \"%s\" but got \"%s\"", tc.profile, tc.fmtp, value) + } + } +} + +func TestGetFmtpValueVP9(t *testing.T) { + t.Parallel() + assert := assert.New(t) + testcases := []struct { + fmtp string + profile string + }{ + { + "", + "", + }, + { + "profile-id=0", + "0", + }, + { + "profile-id = 0", + "0", + }, + } + + for _, tc := range testcases { + value, found := getFmtpValue(tc.fmtp, "profile-id") + if !found && tc.profile != "" { + assert.Fail("did not find profile", "profile \"%s\" in \"%s\"", tc.profile, tc.fmtp) + } else if found && tc.profile == "" { + assert.Fail("did not expect profile", "in \"%s\" but got \"%s\"", tc.fmtp, value) + } else if found && tc.profile != value { + assert.Fail("expected profile", "profile \"%s\" in \"%s\" but got \"%s\"", tc.profile, tc.fmtp, value) + } + } +} + +func TestJanusPublisherRemote(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + var remotePublishId atomic.Value + + remoteId := api.PublicSessionId("the-remote-id") + hostname := "remote.server" + port := 12345 + rtcpPort := 23456 + + mcu, gateway := newMcuJanusForTesting(t) + gateway.RegisterHandlers(map[string]janustest.JanusHandler{ + "publish_remotely": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + if value, found := api.GetStringMapString[string](body, "host"); assert.True(found) { + assert.Equal(hostname, value) + } + if value, found := api.GetStringMapEntry[float64](body, "port"); assert.True(found) { + assert.InEpsilon(port, value, 0.0001) + } + if value, found := api.GetStringMapEntry[float64](body, "rtcp_port"); assert.True(found) { + assert.InEpsilon(rtcpPort, value, 0.0001) + } + if value, found := api.GetStringMapString[string](body, "remote_id"); assert.True(found) { + prev := remotePublishId.Swap(value) + assert.Nil(prev, "should not have previous value") + } + + return &janus.SuccessMsg{ + Data: janus.SuccessData{ + ID: 1, + }, + }, nil + }, + "unpublish_remotely": func(room *janustest.JanusRoom, body, jsep api.StringMap) (any, *janus.ErrorMsg) { + if value, found := api.GetStringMapString[string](body, "remote_id"); assert.True(found) { + if prev := remotePublishId.Load(); assert.NotNil(prev, "should have previous value") { + assert.Equal(prev, value) + } + } + return &janus.SuccessMsg{ + Data: janus.SuccessData{ + ID: 1, + }, + }, nil + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("publisher-id") + listener1 := &TestMcuListener{ + id: pubId, + } + + settings1 := sfu.NewPublisherSettings{} + initiator1 := &TestMcuInitiator{ + country: "DE", + } + + pub, err := mcu.NewPublisher(ctx, listener1, pubId, "sid", sfu.StreamTypeVideo, settings1, initiator1) + require.NoError(err) + defer pub.Close(context.Background()) + + require.Implements((*sfu.RemoteAwarePublisher)(nil), pub) + remotePub, _ := pub.(sfu.RemoteAwarePublisher) + + if assert.NoError(remotePub.PublishRemote(ctx, remoteId, hostname, port, rtcpPort)) { + assert.NoError(remotePub.UnpublishRemote(ctx, remoteId, hostname, port, rtcpPort)) + } +} diff --git a/sfu/janus/remote_publisher.go b/sfu/janus/remote_publisher.go new file mode 100644 index 0000000..dd85657 --- /dev/null +++ b/sfu/janus/remote_publisher.go @@ -0,0 +1,162 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2024 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "context" + "sync/atomic" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/janus" +) + +type janusRemotePublisher struct { + janusPublisher + + ref atomic.Int64 + + controller sfu.RemotePublisherController + + port int + rtcpPort int +} + +func (p *janusRemotePublisher) addRef() int64 { + return p.ref.Add(1) +} + +func (p *janusRemotePublisher) release() bool { + return p.ref.Add(-1) == 0 +} + +func (p *janusRemotePublisher) Port() int { + return p.port +} + +func (p *janusRemotePublisher) RtcpPort() int { + return p.rtcpPort +} + +func (p *janusRemotePublisher) handleEvent(event *janus.EventMsg) { + if videoroom := getPluginStringValue(event.Plugindata, pluginVideoRoom, "videoroom"); videoroom != "" { + ctx := context.TODO() + switch videoroom { + case "destroyed": + p.logger.Printf("Remote publisher %d: associated room has been destroyed, closing", p.handleId.Load()) + go p.Close(ctx) + case "slow_link": + // Ignore, processed through "handleSlowLink" in the general events. + default: + p.logger.Printf("Unsupported videoroom remote publisher event in %d: %+v", p.handleId.Load(), event) + } + } else { + p.logger.Printf("Unsupported remote publisher event in %d: %+v", p.handleId.Load(), event) + } +} + +func (p *janusRemotePublisher) handleHangup(event *janus.HangupMsg) { + p.logger.Printf("Remote publisher %d received hangup (%s), closing", p.handleId.Load(), event.Reason) + go p.Close(context.Background()) +} + +func (p *janusRemotePublisher) handleDetached(event *janus.DetachedMsg) { + p.logger.Printf("Remote publisher %d received detached, closing", p.handleId.Load()) + go p.Close(context.Background()) +} + +func (p *janusRemotePublisher) handleConnected(event *janus.WebRTCUpMsg) { + p.logger.Printf("Remote publisher %d received connected", p.handleId.Load()) + p.mcu.notifyPublisherConnected(p.id, p.streamType) +} + +func (p *janusRemotePublisher) handleSlowLink(event *janus.SlowLinkMsg) { + if event.Uplink { + p.logger.Printf("Remote publisher %s (%d) is reporting %d lost packets on the uplink (Janus -> client)", p.listener.PublicId(), p.handleId.Load(), event.Lost) + } else { + p.logger.Printf("Remote publisher %s (%d) is reporting %d lost packets on the downlink (client -> Janus)", p.listener.PublicId(), p.handleId.Load(), event.Lost) + } +} + +func (p *janusRemotePublisher) NotifyReconnected() { + ctx := context.TODO() + handle, session, roomId, _, err := p.mcu.getOrCreatePublisherHandle(ctx, p.id, p.streamType, p.settings) + if err != nil { + p.logger.Printf("Could not reconnect remote publisher %s: %s", p.id, err) + // TODO(jojo): Retry + return + } + + if prev := p.handle.Swap(handle); prev != nil { + if _, err := prev.Detach(context.Background()); err != nil { + p.logger.Printf("Error detaching old remote publisher handle %d: %s", prev.Id, err) + } + } + p.handleId.Store(handle.Id) + p.session = session + p.roomId = roomId + + p.logger.Printf("Remote publisher %s reconnected on handle %d", p.id, p.handleId.Load()) +} + +func (p *janusRemotePublisher) Close(ctx context.Context) { + if !p.release() { + return + } + + if err := p.controller.StopPublishing(ctx, p); err != nil { + p.logger.Printf("Error stopping remote publisher %s in room %d: %s", p.id, p.roomId, err) + } + + p.mu.Lock() + defer p.mu.Unlock() + + if handle := p.handle.Load(); handle != nil { + response, err := handle.Request(ctx, api.StringMap{ + "request": "remove_remote_publisher", + "room": p.roomId, + "id": streamTypeUserIds[p.streamType], + }) + if err != nil { + p.logger.Printf("Error removing remote publisher %s in room %d: %s", p.id, p.roomId, err) + } else { + p.logger.Printf("Removed remote publisher: %+v", response) + } + if p.roomId != 0 { + destroy_msg := api.StringMap{ + "request": "destroy", + "room": p.roomId, + } + if _, err := handle.Request(ctx, destroy_msg); err != nil { + p.logger.Printf("Error destroying room %d: %s", p.roomId, err) + } else { + p.logger.Printf("Room %d destroyed", p.roomId) + } + p.mcu.mu.Lock() + delete(p.mcu.remotePublishers, sfu.GetStreamId(p.id, p.streamType)) + p.mcu.mu.Unlock() + p.roomId = 0 + } + } + + p.closeClient(ctx) +} diff --git a/mcu_janus_remote_subscriber.go b/sfu/janus/remote_subscriber.go similarity index 51% rename from mcu_janus_remote_subscriber.go rename to sfu/janus/remote_subscriber.go index 8f7fe5e..a8c12e1 100644 --- a/mcu_janus_remote_subscriber.go +++ b/sfu/janus/remote_subscriber.go @@ -19,29 +19,28 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package janus import ( "context" - "log" "strconv" "sync/atomic" - "github.com/notedit/janus-go" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/janus" ) -type mcuJanusRemoteSubscriber struct { - mcuJanusSubscriber +type janusRemoteSubscriber struct { + janusSubscriber - remote atomic.Pointer[mcuJanusRemotePublisher] + remote atomic.Pointer[janusRemotePublisher] } -func (p *mcuJanusRemoteSubscriber) handleEvent(event *janus.EventMsg) { +func (p *janusRemoteSubscriber) handleEvent(event *janus.EventMsg) { if videoroom := getPluginStringValue(event.Plugindata, pluginVideoRoom, "videoroom"); videoroom != "" { ctx := context.TODO() switch videoroom { case "destroyed": - log.Printf("Remote subscriber %d: associated room has been destroyed, closing", p.handleId) + p.logger.Printf("Remote subscriber %d: associated room has been destroyed, closing", p.handleId.Load()) go p.Close(ctx) case "event": // Handle renegotiations, but ignore other events like selected @@ -53,61 +52,65 @@ func (p *mcuJanusRemoteSubscriber) handleEvent(event *janus.EventMsg) { case "slow_link": // Ignore, processed through "handleSlowLink" in the general events. default: - log.Printf("Unsupported videoroom event %s for remote subscriber %d: %+v", videoroom, p.handleId, event) + p.logger.Printf("Unsupported videoroom event %s for remote subscriber %d: %+v", videoroom, p.handleId.Load(), event) } } else { - log.Printf("Unsupported event for remote subscriber %d: %+v", p.handleId, event) + p.logger.Printf("Unsupported event for remote subscriber %d: %+v", p.handleId.Load(), event) } } -func (p *mcuJanusRemoteSubscriber) handleHangup(event *janus.HangupMsg) { - log.Printf("Remote subscriber %d received hangup (%s), closing", p.handleId, event.Reason) +func (p *janusRemoteSubscriber) handleHangup(event *janus.HangupMsg) { + p.logger.Printf("Remote subscriber %d received hangup (%s), closing", p.handleId.Load(), event.Reason) go p.Close(context.Background()) } -func (p *mcuJanusRemoteSubscriber) handleDetached(event *janus.DetachedMsg) { - log.Printf("Remote subscriber %d received detached, closing", p.handleId) +func (p *janusRemoteSubscriber) handleDetached(event *janus.DetachedMsg) { + p.logger.Printf("Remote subscriber %d received detached, closing", p.handleId.Load()) go p.Close(context.Background()) } -func (p *mcuJanusRemoteSubscriber) handleConnected(event *janus.WebRTCUpMsg) { - log.Printf("Remote subscriber %d received connected", p.handleId) +func (p *janusRemoteSubscriber) handleConnected(event *janus.WebRTCUpMsg) { + p.logger.Printf("Remote subscriber %d received connected", p.handleId.Load()) p.mcu.SubscriberConnected(p.Id(), p.publisher, p.streamType) } -func (p *mcuJanusRemoteSubscriber) handleSlowLink(event *janus.SlowLinkMsg) { +func (p *janusRemoteSubscriber) handleSlowLink(event *janus.SlowLinkMsg) { if event.Uplink { - log.Printf("Remote subscriber %s (%d) is reporting %d lost packets on the uplink (Janus -> client)", p.listener.PublicId(), p.handleId, event.Lost) + p.logger.Printf("Remote subscriber %s (%d) is reporting %d lost packets on the uplink (Janus -> client)", p.listener.PublicId(), p.handleId.Load(), event.Lost) } else { - log.Printf("Remote subscriber %s (%d) is reporting %d lost packets on the downlink (client -> Janus)", p.listener.PublicId(), p.handleId, event.Lost) + p.logger.Printf("Remote subscriber %s (%d) is reporting %d lost packets on the downlink (client -> Janus)", p.listener.PublicId(), p.handleId.Load(), event.Lost) } } -func (p *mcuJanusRemoteSubscriber) handleMedia(event *janus.MediaMsg) { +func (p *janusRemoteSubscriber) handleMedia(event *janus.MediaMsg) { // Only triggered for publishers } -func (p *mcuJanusRemoteSubscriber) NotifyReconnected() { +func (p *janusRemoteSubscriber) NotifyReconnected() { ctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) defer cancel() handle, pub, err := p.mcu.getOrCreateSubscriberHandle(ctx, p.publisher, p.streamType) if err != nil { // TODO(jojo): Retry? - log.Printf("Could not reconnect remote subscriber for publisher %s: %s", p.publisher, err) + p.logger.Printf("Could not reconnect remote subscriber for publisher %s: %s", p.publisher, err) p.Close(context.Background()) return } - p.handle = handle - p.handleId = handle.Id + if prev := p.handle.Swap(handle); prev != nil { + if _, err := prev.Detach(context.Background()); err != nil { + p.logger.Printf("Error detaching old remote subscriber handle %d: %s", prev.Id, err) + } + } + p.handleId.Store(handle.Id) p.roomId = pub.roomId p.sid = strconv.FormatUint(handle.Id, 10) p.listener.SubscriberSidUpdated(p) - log.Printf("Subscriber %d for publisher %s reconnected on handle %d", p.id, p.publisher, p.handleId) + p.logger.Printf("Subscriber %d for publisher %s reconnected on handle %d", p.id, p.publisher, p.handleId.Load()) } -func (p *mcuJanusRemoteSubscriber) Close(ctx context.Context) { - p.mcuJanusSubscriber.Close(ctx) +func (p *janusRemoteSubscriber) Close(ctx context.Context) { + p.janusSubscriber.Close(ctx) if remote := p.remote.Swap(nil); remote != nil { remote.Close(context.Background()) diff --git a/sfu/janus/stats_prometheus.go b/sfu/janus/stats_prometheus.go new file mode 100644 index 0000000..312f11d --- /dev/null +++ b/sfu/janus/stats_prometheus.go @@ -0,0 +1,152 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/internal" +) + +var ( + statsMcuSubscriberStreamTypesCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "subscriber_streams", + Help: "The current number of subscribed media streams", + }, []string{"type"}) + statsMcuPublisherStreamTypesCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "publisher_streams", + Help: "The current number of published media streams", + }, []string{"type"}) + statsJanusBandwidthCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "bandwidth", + Help: "The current bandwidth in bytes per second", + }, []string{"direction"}) + statsJanusSelectedCandidateTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "selected_candidate_total", + Help: "Total number of selected candidates", + }, []string{"origin", "type", "transport", "family"}) + statsJanusPeerConnectionStateTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "peerconnection_state_total", + Help: "Total number of PeerConnections states", + }, []string{"state", "reason"}) + statsJanusICEStateTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "ice_state_total", + Help: "Total number of ICE connection states", + }, []string{"state"}) + statsJanusDTLSStateTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "dtls_state_total", + Help: "Total number of DTLS connection states", + }, []string{"state"}) + statsJanusSlowLinkTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "slow_link_total", + Help: "Total number of slow link events", + }, []string{"media", "direction"}) + statsJanusMediaRTT = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "media_rtt", + Help: "The roundtrip time of WebRTC media in milliseconds", + Buckets: prometheus.ExponentialBucketsRange(1, 10000, 25), + }, []string{"media"}) + statsJanusMediaJitter = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "media_jitter", + Help: "The jitter of WebRTC media in milliseconds", + Buckets: prometheus.ExponentialBucketsRange(1, 2000, 20), + }, []string{"media", "origin"}) + statsJanusMediaCodecsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "media_codecs_total", + Help: "The total number of codecs", + }, []string{"media", "codec"}) + statsJanusMediaNACKTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "media_nacks_total", + Help: "The total number of NACKs", + }, []string{"media", "direction"}) + statsJanusMediaRetransmissionsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "media_retransmissions_total", + Help: "The total number of received retransmissions", + }, []string{"media"}) + statsJanusMediaBytesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "media_bytes_total", + Help: "The total number of media bytes sent / received", + }, []string{"media", "direction"}) + statsJanusMediaLostTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "media_lost_total", + Help: "The total number of lost media packets", + }, []string{"media", "origin"}) + + janusMcuStats = []prometheus.Collector{ + statsMcuSubscriberStreamTypesCurrent, + statsMcuPublisherStreamTypesCurrent, + statsJanusBandwidthCurrent, + statsJanusSelectedCandidateTotal, + statsJanusPeerConnectionStateTotal, + statsJanusICEStateTotal, + statsJanusDTLSStateTotal, + statsJanusSlowLinkTotal, + statsJanusMediaRTT, + statsJanusMediaJitter, + statsJanusMediaCodecsTotal, + statsJanusMediaNACKTotal, + statsJanusMediaRetransmissionsTotal, + statsJanusMediaBytesTotal, + statsJanusMediaLostTotal, + } +) + +func RegisterStats() { + internal.RegisterCommonStats() + metrics.RegisterAll(janusMcuStats...) +} + +func UnregisterStats() { + internal.UnregisterCommonStats() + metrics.UnregisterAll(janusMcuStats...) +} diff --git a/mcu_janus_stream_selection.go b/sfu/janus/stream_selection.go similarity index 52% rename from mcu_janus_stream_selection.go rename to sfu/janus/stream_selection.go index 9381ef3..f6430f7 100644 --- a/mcu_janus_stream_selection.go +++ b/sfu/janus/stream_selection.go @@ -19,90 +19,84 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package janus import ( - "database/sql" "fmt" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" ) type streamSelection struct { - substream sql.NullInt16 - temporal sql.NullInt16 - audio sql.NullBool - video sql.NullBool + substream *int + temporal *int + audio *bool + video *bool } func (s *streamSelection) HasValues() bool { - return s.substream.Valid || s.temporal.Valid || s.audio.Valid || s.video.Valid + return s.substream != nil || s.temporal != nil || s.audio != nil || s.video != nil } -func (s *streamSelection) AddToMessage(message map[string]interface{}) { - if s.substream.Valid { - message["substream"] = s.substream.Int16 +func (s *streamSelection) AddToMessage(message api.StringMap) { + if s.substream != nil { + message["substream"] = *s.substream } - if s.temporal.Valid { - message["temporal"] = s.temporal.Int16 + if s.temporal != nil { + message["temporal"] = *s.temporal } - if s.audio.Valid { - message["audio"] = s.audio.Bool + if s.audio != nil { + message["audio"] = *s.audio } - if s.video.Valid { - message["video"] = s.video.Bool + if s.video != nil { + message["video"] = *s.video } } -func parseStreamSelection(payload map[string]interface{}) (*streamSelection, error) { +func parseStreamSelection(payload api.StringMap) (*streamSelection, error) { var stream streamSelection if value, found := payload["substream"]; found { switch value := value.(type) { case int: - stream.substream.Valid = true - stream.substream.Int16 = int16(value) + stream.substream = &value case float32: - stream.substream.Valid = true - stream.substream.Int16 = int16(value) + stream.substream = internal.MakePtr(int(value)) case float64: - stream.substream.Valid = true - stream.substream.Int16 = int16(value) + stream.substream = internal.MakePtr(int(value)) default: - return nil, fmt.Errorf("Unsupported substream value: %v", value) + return nil, fmt.Errorf("unsupported substream value: %v", value) } } if value, found := payload["temporal"]; found { switch value := value.(type) { case int: - stream.temporal.Valid = true - stream.temporal.Int16 = int16(value) + stream.temporal = &value case float32: - stream.temporal.Valid = true - stream.temporal.Int16 = int16(value) + stream.temporal = internal.MakePtr(int(value)) case float64: - stream.temporal.Valid = true - stream.temporal.Int16 = int16(value) + stream.temporal = internal.MakePtr(int(value)) default: - return nil, fmt.Errorf("Unsupported temporal value: %v", value) + return nil, fmt.Errorf("unsupported temporal value: %v", value) } } if value, found := payload["audio"]; found { switch value := value.(type) { case bool: - stream.audio.Valid = true - stream.audio.Bool = value + stream.audio = &value default: - return nil, fmt.Errorf("Unsupported audio value: %v", value) + return nil, fmt.Errorf("unsupported audio value: %v", value) } } if value, found := payload["video"]; found { switch value := value.(type) { case bool: - stream.video.Valid = true - stream.video.Bool = value + stream.video = &value default: - return nil, fmt.Errorf("Unsupported video value: %v", value) + return nil, fmt.Errorf("unsupported video value: %v", value) } } diff --git a/sfu/janus/stream_selection_test.go b/sfu/janus/stream_selection_test.go new file mode 100644 index 0000000..cc0ed1d --- /dev/null +++ b/sfu/janus/stream_selection_test.go @@ -0,0 +1,92 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" +) + +func TestStreamSelection(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + testcases := []api.StringMap{ + {}, + { + "substream": 1.0, + }, + { + "temporal": 1.0, + }, + { + "substream": float32(1.0), + }, + { + "temporal": float32(1.0), + }, + { + "substream": 1, + "temporal": 3, + }, + { + "substream": 1, + "audio": true, + "video": false, + }, + } + + for idx, tc := range testcases { + parsed, err := parseStreamSelection(tc) + if assert.NoError(err, "failed for testcase %d: %+v", idx, tc) { + assert.Equal(len(tc) > 0, parsed.HasValues(), "failed for testcase %d: %+v", idx, tc) + m := make(api.StringMap) + parsed.AddToMessage(m) + for k, v := range tc { + assert.EqualValues(v, m[k], "failed for key %s in testcase %d", k, idx) + } + } + } + + _, err := parseStreamSelection(api.StringMap{ + "substream": "foo", + }) + assert.ErrorContains(err, "unsupported substream value") + + _, err = parseStreamSelection(api.StringMap{ + "temporal": "foo", + }) + assert.ErrorContains(err, "unsupported temporal value") + + _, err = parseStreamSelection(api.StringMap{ + "audio": 1, + }) + assert.ErrorContains(err, "unsupported audio value") + + _, err = parseStreamSelection(api.StringMap{ + "video": "true", + }) + assert.ErrorContains(err, "unsupported video value") +} diff --git a/sfu/janus/subscriber.go b/sfu/janus/subscriber.go new file mode 100644 index 0000000..0d3bd73 --- /dev/null +++ b/sfu/janus/subscriber.go @@ -0,0 +1,384 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package janus + +import ( + "context" + "fmt" + "strconv" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + sfuinternal "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/janus" +) + +type janusSubscriber struct { + janusClient + + publisher api.PublicSessionId +} + +func (p *janusSubscriber) JanusHandle() *janus.Handle { + return p.handle.Load() +} + +func (p *janusSubscriber) Publisher() api.PublicSessionId { + return p.publisher +} + +func (p *janusSubscriber) handleEvent(event *janus.EventMsg) { + if videoroom := getPluginStringValue(event.Plugindata, pluginVideoRoom, "videoroom"); videoroom != "" { + ctx := context.TODO() + switch videoroom { + case "destroyed": + p.logger.Printf("Subscriber %d: associated room has been destroyed, closing", p.handleId.Load()) + go p.Close(ctx) + case "updated": + streams, ok := getPluginValue(event.Plugindata, pluginVideoRoom, "streams").([]any) + if !ok || len(streams) == 0 { + // The streams list will be empty if no stream was changed. + return + } + + for _, stream := range streams { + if stream, ok := api.ConvertStringMap(stream); ok { + if (stream["type"] == "audio" || stream["type"] == "video") && stream["active"] != false { + return + } + } + } + + p.logger.Printf("Subscriber %d: received updated event with no active media streams, closing", p.handleId.Load()) + go p.Close(ctx) + case "event": + // Handle renegotiations, but ignore other events like selected + // substream / temporal layer. + if getPluginStringValue(event.Plugindata, pluginVideoRoom, "configured") == "ok" && + event.Jsep != nil && event.Jsep["type"] == "offer" && event.Jsep["sdp"] != nil { + p.logger.Printf("Subscriber %d: received updated offer", p.handleId.Load()) + p.listener.OnUpdateOffer(p, event.Jsep) + } else { + p.logger.Printf("Subscriber %d: received unsupported event %+v", p.handleId.Load(), event) + } + case "slow_link": + // Ignore, processed through "handleSlowLink" in the general events. + default: + p.logger.Printf("Unsupported videoroom event %s for subscriber %d: %+v", videoroom, p.handleId.Load(), event) + } + } else { + p.logger.Printf("Unsupported event for subscriber %d: %+v", p.handleId.Load(), event) + } +} + +func (p *janusSubscriber) handleHangup(event *janus.HangupMsg) { + p.logger.Printf("Subscriber %d received hangup (%s), closing", p.handleId.Load(), event.Reason) + go p.Close(context.Background()) +} + +func (p *janusSubscriber) handleDetached(event *janus.DetachedMsg) { + p.logger.Printf("Subscriber %d received detached, closing", p.handleId.Load()) + go p.Close(context.Background()) +} + +func (p *janusSubscriber) handleConnected(event *janus.WebRTCUpMsg) { + p.logger.Printf("Subscriber %d received connected", p.handleId.Load()) + p.mcu.SubscriberConnected(p.Id(), p.publisher, p.streamType) +} + +func (p *janusSubscriber) handleSlowLink(event *janus.SlowLinkMsg) { + if event.Uplink { + p.logger.Printf("Subscriber %s (%d) is reporting %d lost packets on the uplink (Janus -> client)", p.listener.PublicId(), p.handleId.Load(), event.Lost) + } else { + p.logger.Printf("Subscriber %s (%d) is reporting %d lost packets on the downlink (client -> Janus)", p.listener.PublicId(), p.handleId.Load(), event.Lost) + } +} + +func (p *janusSubscriber) handleMedia(event *janus.MediaMsg) { + // Only triggered for publishers +} + +func (p *janusSubscriber) NotifyReconnected() { + ctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) + defer cancel() + handle, pub, err := p.mcu.getOrCreateSubscriberHandle(ctx, p.publisher, p.streamType) + if err != nil { + // TODO(jojo): Retry? + p.logger.Printf("Could not reconnect subscriber for publisher %s: %s", p.publisher, err) + p.Close(context.Background()) + return + } + + if prev := p.handle.Swap(handle); prev != nil { + if _, err := prev.Detach(context.Background()); err != nil { + p.logger.Printf("Error detaching old subscriber handle %d: %s", prev.Id, err) + } + } + p.handleId.Store(handle.Id) + p.roomId = pub.roomId + p.sid = strconv.FormatUint(handle.Id, 10) + p.listener.SubscriberSidUpdated(p) + p.logger.Printf("Subscriber %d for publisher %s reconnected on handle %d", p.id, p.publisher, p.handleId.Load()) +} + +func (p *janusSubscriber) closeClient(ctx context.Context) bool { + if !p.janusClient.closeClient(ctx) { + return false + } + + p.mcu.stats.DecSubscriber(p.streamType) + return true +} + +func (p *janusSubscriber) Close(ctx context.Context) { + p.mu.Lock() + closed := p.closeClient(ctx) + p.mu.Unlock() + + if closed { + p.mcu.SubscriberDisconnected(p.Id(), p.publisher, p.streamType) + } + p.mcu.unregisterClient(p) + p.listener.SubscriberClosed(p) + p.janusClient.Close(ctx) +} + +func (p *janusSubscriber) joinRoom(ctx context.Context, stream *streamSelection, callback func(error, api.StringMap)) { + handle := p.handle.Load() + if handle == nil { + callback(sfu.ErrNotConnected, nil) + return + } + + waiter, stop := p.mcu.newPublisherConnectedWaiter(p.publisher, p.streamType) + defer stop() + + loggedNotPublishingYet := false +retry: + join_msg := api.StringMap{ + "request": "join", + "ptype": "subscriber", + "room": p.roomId, + } + if p.mcu.isMultistream() { + join_msg["streams"] = []api.StringMap{ + { + "feed": streamTypeUserIds[p.streamType], + }, + } + } else { + join_msg["feed"] = streamTypeUserIds[p.streamType] + } + if stream != nil { + stream.AddToMessage(join_msg) + } + join_response, err := handle.Message(ctx, join_msg, nil) + if err != nil { + callback(err, nil) + return + } + + if error_code := getPluginIntValue(p.logger, join_response.Plugindata, pluginVideoRoom, "error_code"); error_code > 0 { + switch error_code { + case janus.JANUS_VIDEOROOM_ERROR_ALREADY_JOINED: + // The subscriber is already connected to the room. This can happen + // if a client leaves a call but keeps the subscriber objects active. + // On joining the call again, the subscriber tries to join on the + // MCU which will fail because he is still connected. + // To get a new Offer SDP, we have to tear down the session on the + // MCU and join again. + p.mu.Lock() + p.closeClient(ctx) + p.mu.Unlock() + + var pub *janusPublisher + handle, pub, err = p.mcu.getOrCreateSubscriberHandle(ctx, p.publisher, p.streamType) + if err != nil { + // Reconnection didn't work, need to unregister/remove subscriber + // so a new object will be created if the request is retried. + p.mcu.unregisterClient(p) + p.listener.SubscriberClosed(p) + callback(fmt.Errorf("already connected as subscriber for %s, error during re-joining: %s", p.streamType, err), nil) + return + } + + if prev := p.handle.Swap(handle); prev != nil { + if _, err := prev.Detach(context.Background()); err != nil { + p.logger.Printf("Error detaching old subscriber handle %d: %s", prev.Id, err) + } + } + p.handleId.Store(handle.Id) + p.roomId = pub.roomId + p.sid = strconv.FormatUint(handle.Id, 10) + p.listener.SubscriberSidUpdated(p) + p.closeChan = make(chan struct{}, 1) + p.mcu.stats.IncSubscriber(p.streamType) + go p.run(handle, p.closeChan) + p.logger.Printf("Already connected subscriber %d for %s, leaving and re-joining on handle %d", p.id, p.streamType, p.handleId.Load()) + goto retry + case janus.JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM: + fallthrough + case janus.JANUS_VIDEOROOM_ERROR_NO_SUCH_FEED: + switch error_code { + case janus.JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM: + p.logger.Printf("Publisher %s not created yet for %s, not joining room %d as subscriber", p.publisher, p.streamType, p.roomId) + p.Close(context.Background()) + callback(fmt.Errorf("Publisher %s not created yet for %s", p.publisher, p.streamType), nil) + return + case janus.JANUS_VIDEOROOM_ERROR_NO_SUCH_FEED: + p.logger.Printf("Publisher %s not sending yet for %s, wait and retry to join room %d as subscriber", p.publisher, p.streamType, p.roomId) + } + + if !loggedNotPublishingYet { + loggedNotPublishingYet = true + sfuinternal.StatsWaitingForPublisherTotal.WithLabelValues(string(p.streamType)).Inc() + } + + if err := waiter.Wait(ctx); err != nil { + p.Close(context.Background()) + callback(err, nil) + return + } + p.logger.Printf("Retry subscribing %s from %s", p.streamType, p.publisher) + goto retry + default: + // TODO(jojo): Should we handle other errors, too? + callback(fmt.Errorf("error joining room as subscriber: %+v", join_response), nil) + return + } + } + // p.logger.Println("Joined as listener", join_response) + + p.session = join_response.Session + callback(nil, join_response.Jsep) +} + +func (p *janusSubscriber) update(ctx context.Context, stream *streamSelection, callback func(error, api.StringMap)) { + handle := p.handle.Load() + if handle == nil { + callback(sfu.ErrNotConnected, nil) + return + } + + configure_msg := api.StringMap{ + "request": "configure", + "update": true, + } + if stream != nil { + stream.AddToMessage(configure_msg) + } + configure_response, err := handle.Message(ctx, configure_msg, nil) + if err != nil { + callback(err, nil) + return + } + + callback(nil, configure_response.Jsep) +} + +func (p *janusSubscriber) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { + sfuinternal.StatsMcuMessagesTotal.WithLabelValues(data.Type).Inc() + jsep_msg := data.Payload + switch data.Type { + case "requestoffer": + fallthrough + case "sendoffer": + p.deferred <- func() { + msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) + defer cancel() + + stream, err := parseStreamSelection(jsep_msg) + if err != nil { + go callback(err, nil) + return + } + + if data.Sid == "" || data.Sid != p.Sid() { + p.joinRoom(msgctx, stream, callback) + } else { + p.update(msgctx, stream, callback) + } + } + case "answer": + p.deferred <- func() { + if api.FilterSDPCandidates(data.AnswerSdp, p.mcu.settings.allowedCandidates.Load(), p.mcu.settings.blockedCandidates.Load()) { + // Update request with filtered SDP. + marshalled, err := data.AnswerSdp.Marshal() + if err != nil { + go callback(fmt.Errorf("could not marshal filtered answer: %w", err), nil) + return + } + + jsep_msg["sdp"] = string(marshalled) + } + + msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) + defer cancel() + + if data.Sid == "" || data.Sid == p.Sid() { + p.sendAnswer(msgctx, jsep_msg, callback) + } else { + go callback(fmt.Errorf("answer message sid (%s) does not match subscriber sid (%s)", data.Sid, p.Sid()), nil) + } + } + case "candidate": + if api.FilterCandidate(data.Candidate, p.mcu.settings.allowedCandidates.Load(), p.mcu.settings.blockedCandidates.Load()) { + go callback(api.ErrCandidateFiltered, nil) + return + } + + p.deferred <- func() { + msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) + defer cancel() + + if data.Sid == "" || data.Sid == p.Sid() { + p.sendCandidate(msgctx, jsep_msg["candidate"], callback) + } else { + go callback(fmt.Errorf("candidate message sid (%s) does not match subscriber sid (%s)", data.Sid, p.Sid()), nil) + } + } + case "endOfCandidates": + // Ignore + case "selectStream": + stream, err := parseStreamSelection(jsep_msg) + if err != nil { + go callback(err, nil) + return + } + + if stream == nil || !stream.HasValues() { + // Nothing to do + go callback(nil, nil) + return + } + + p.deferred <- func() { + msgctx, cancel := context.WithTimeout(context.Background(), p.mcu.settings.Timeout()) + defer cancel() + + p.selectStream(msgctx, stream, callback) + } + default: + // Return error asynchronously + go callback(fmt.Errorf("unsupported message type: %s", data.Type), nil) + } +} diff --git a/sfu/janus/test/janus.go b/sfu/janus/test/janus.go new file mode 100644 index 0000000..319ea23 --- /dev/null +++ b/sfu/janus/test/janus.go @@ -0,0 +1,588 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "context" + "encoding/json" + "maps" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/mock" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/janus/janus" +) + +const ( + pluginVideoRoom = "janus.plugin.videoroom" + eventWebsocket = "janus.eventhandler.wsevh" +) + +type JanusHandle struct { + id uint64 + + sdp atomic.Value +} + +type JanusRoom struct { + id uint64 + + publisher atomic.Pointer[JanusHandle] +} + +func (r *JanusRoom) Id() uint64 { + return r.id +} + +type JanusHandler func(room *JanusRoom, body api.StringMap, jsep api.StringMap) (any, *janus.ErrorMsg) + +type JanusGateway struct { + t *testing.T + + sid atomic.Uint64 + tid atomic.Uint64 + hid atomic.Uint64 // +checklocksignore: Atomic + rid atomic.Uint64 // +checklocksignore: Atomic + mu sync.Mutex + + // +checklocks:mu + sessions map[uint64]*janus.Session + // +checklocks:mu + transactions map[uint64]*janus.Transaction + // +checklocks:mu + handles map[uint64]*JanusHandle + // +checklocks:mu + rooms map[uint64]*JanusRoom + // +checklocks:mu + handlers map[string]JanusHandler + + // +checklocks:mu + attachCount int + // +checklocks:mu + joinCount int + + // +checklocks:mu + handleRooms map[*JanusHandle]*JanusRoom +} + +func NewJanusGateway(t *testing.T) *JanusGateway { + gateway := &JanusGateway{ + t: t, + + sessions: make(map[uint64]*janus.Session), + transactions: make(map[uint64]*janus.Transaction), + handles: make(map[uint64]*JanusHandle), + rooms: make(map[uint64]*JanusRoom), + handlers: make(map[string]JanusHandler), + + handleRooms: make(map[*JanusHandle]*JanusRoom), + } + + t.Cleanup(func() { + assert := assert.New(t) + gateway.mu.Lock() + defer gateway.mu.Unlock() + assert.Empty(gateway.sessions) + assert.Empty(gateway.transactions) + assert.Empty(gateway.handles) + assert.Empty(gateway.rooms) + assert.Empty(gateway.handleRooms) + }) + + return gateway +} + +func (g *JanusGateway) RegisterHandlers(handlers map[string]JanusHandler) { + g.mu.Lock() + defer g.mu.Unlock() + maps.Copy(g.handlers, handlers) +} + +func (g *JanusGateway) Info(ctx context.Context) (*janus.InfoMsg, error) { + return &janus.InfoMsg{ + Name: "TestJanus", + Version: 1400, + VersionString: "1.4.0", + Author: "struktur AG", + DataChannels: true, + EventHandlers: true, + FullTrickle: true, + Plugins: map[string]janus.PluginInfo{ + pluginVideoRoom: { + Name: "Test VideoRoom plugin", + VersionString: "0.0.0", + Author: "struktur AG", + }, + }, + Events: map[string]janus.PluginInfo{ + eventWebsocket: { + Name: "Test Websocket events", + VersionString: "0.0.0", + Author: "struktur AG", + }, + }, + }, nil +} + +func (g *JanusGateway) Create(ctx context.Context) (*janus.Session, error) { + sid := g.sid.Add(1) + session := janus.NewSession(sid, g) + g.mu.Lock() + defer g.mu.Unlock() + g.sessions[sid] = session + return session, nil +} + +func (g *JanusGateway) Close() error { + return nil +} + +func (g *JanusGateway) simulateEvent(delay time.Duration, session *janus.Session, handle *JanusHandle, event any) { + go func() { + time.Sleep(delay) + session.Lock() + h, found := session.Handles[handle.id] + session.Unlock() + if found { + h.Events <- event + } + }() +} + +// +checklocks:g.mu +func (g *JanusGateway) processMessage(session *janus.Session, handle *JanusHandle, body api.StringMap, jsep api.StringMap) any { + request := body["request"].(string) + switch request { + case "create": + room := &JanusRoom{ + id: g.rid.Add(1), + } + g.rooms[room.id] = room + + return &janus.SuccessMsg{ + PluginData: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{ + "room": room.id, + }, + }, + } + case "join": + rid := body["room"].(float64) + room := g.rooms[uint64(rid)] + error_code := janus.JANUS_OK + if body["ptype"] == "subscriber" { + if strings.Contains(g.t.Name(), "NoSuchRoom") { + g.joinCount++ + if g.joinCount == 1 { + error_code = janus.JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM + } + } else if strings.Contains(g.t.Name(), "AlreadyJoined") { + g.joinCount++ + if g.joinCount == 1 { + error_code = janus.JANUS_VIDEOROOM_ERROR_ALREADY_JOINED + } + } else if strings.Contains(g.t.Name(), "SubscriberTimeout") { + g.joinCount++ + if g.joinCount == 1 { + error_code = janus.JANUS_VIDEOROOM_ERROR_NO_SUCH_FEED + } + } + } + if error_code != janus.JANUS_OK { + return &janus.EventMsg{ + Plugindata: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{ + "error_code": error_code, + }, + }, + } + } + + if room == nil { + return &janus.EventMsg{ + Plugindata: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{ + "error_code": janus.JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM, + }, + }, + } + } + + g.handleRooms[handle] = room + switch body["ptype"] { + case "publisher": + if !assert.True(g.t, room.publisher.CompareAndSwap(nil, handle)) { + return &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: janus.JANUS_VIDEOROOM_ERROR_ALREADY_PUBLISHED, + Reason: "Already publisher in this room", + }, + } + } + + return &janus.EventMsg{ + Session: session.Id, + Handle: handle.id, + Plugindata: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{ + "room": room.id, + }, + }, + } + case "subscriber": + publisher := room.publisher.Load() + if publisher == nil || publisher.sdp.Load() == nil { + return &janus.EventMsg{ + Plugindata: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{ + "error_code": janus.JANUS_VIDEOROOM_ERROR_NO_SUCH_FEED, + }, + }, + } + } + + sdp := publisher.sdp.Load() + + // Simulate "connected" event for subscriber. + g.simulateEvent(15*time.Millisecond, session, handle, &janus.WebRTCUpMsg{ + Session: session.Id, + Handle: handle.id, + }) + + if strings.Contains(g.t.Name(), "CloseEmptyStreams") { + // Simulate stream update event with no active streams. + g.simulateEvent(20*time.Millisecond, session, handle, &janus.EventMsg{ + Session: session.Id, + Handle: handle.id, + Plugindata: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{ + "videoroom": "updated", + "streams": []any{ + api.StringMap{ + "type": "audio", + "active": false, + }, + }, + }, + }, + }) + } + + if strings.Contains(g.t.Name(), "SubscriberRoomDestroyed") { + // Simulate event that subscriber room has been destroyed. + g.simulateEvent(20*time.Millisecond, session, handle, &janus.EventMsg{ + Session: session.Id, + Handle: handle.id, + Plugindata: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{ + "videoroom": "destroyed", + }, + }, + }) + } + + if strings.Contains(g.t.Name(), "SubscriberUpdateOffer") { + // Simulate event that subscriber receives new offer. + g.simulateEvent(20*time.Millisecond, session, handle, &janus.EventMsg{ + Session: session.Id, + Handle: handle.id, + Plugindata: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{ + "videoroom": "event", + "configured": "ok", + }, + }, + Jsep: map[string]any{ + "type": "offer", + "sdp": mock.MockSdpOfferAudioOnly, + }, + }) + } + + return &janus.EventMsg{ + Jsep: api.StringMap{ + "type": "offer", + "sdp": sdp.(string), + }, + } + } + case "destroy": + rid := body["room"].(float64) + room := g.rooms[uint64(rid)] + if room == nil { + return &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: janus.JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM, + Reason: "Room not found", + }, + } + } + + assert.Equal(g.t, room.id, uint64(rid)) + delete(g.rooms, uint64(rid)) + for h, r := range g.handleRooms { + if r.id == room.id { + delete(g.handleRooms, h) + } + } + + return &janus.SuccessMsg{ + PluginData: janus.PluginData{ + Plugin: pluginVideoRoom, + Data: api.StringMap{}, + }, + } + default: + var room *JanusRoom + if roomId, found := body["room"]; found { + rid := roomId.(float64) + if room = g.rooms[uint64(rid)]; room == nil { + return &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: janus.JANUS_VIDEOROOM_ERROR_NO_SUCH_ROOM, + Reason: "Room not found", + }, + } + } + } else { + if room, found = g.handleRooms[handle]; !found { + return &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: janus.JANUS_VIDEOROOM_ERROR_INVALID_REQUEST, + Reason: "No joined to a room yet.", + }, + } + } + } + + handler, found := g.handlers[request] + if found { + var err *janus.ErrorMsg + result, err := handler(room, body, jsep) + if err != nil { + result = err + } else { + switch request { + case "start": + g.handleRooms[handle] = room + case "configure": + if sdp, found := jsep["sdp"]; found { + handle.sdp.Store(sdp.(string)) + if !strings.Contains(g.t.Name(), "SubscriberTimeout") { + // Simulate "connected" event for publisher. + g.simulateEvent(10*time.Millisecond, session, handle, &janus.WebRTCUpMsg{ + Session: session.Id, + Handle: handle.id, + }) + } + } + } + } + return result + } + } + + return nil +} + +func (g *JanusGateway) processRequest(msg api.StringMap) any { + method, found := msg["janus"] + if !found { + return nil + } + + sid := msg["session_id"].(float64) + g.mu.Lock() + defer g.mu.Unlock() + session := g.sessions[uint64(sid)] + if session == nil { + return &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: janus.JANUS_ERROR_SESSION_NOT_FOUND, + Reason: "Session not found", + }, + } + } + + switch method { + case "attach": + if strings.Contains(g.t.Name(), "AlreadyJoinedAttachError") { + g.attachCount++ + if g.attachCount == 4 { + return &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: janus.JANUS_ERROR_UNKNOWN, + Reason: "Fail for test", + }, + } + } + } + + handle := &JanusHandle{ + id: g.hid.Add(1), + } + + g.handles[handle.id] = handle + + return &janus.SuccessMsg{ + Data: janus.SuccessData{ + ID: handle.id, + }, + } + case "detach": + hid := msg["handle_id"].(float64) + handle, found := g.handles[uint64(hid)] + if found { + delete(g.handles, handle.id) + } + if handle == nil { + return &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: janus.JANUS_ERROR_HANDLE_NOT_FOUND, + Reason: "Handle not found", + }, + } + } + + return &janus.AckMsg{} + case "destroy": + delete(g.sessions, session.Id) + return &janus.AckMsg{} + case "message", "trickle": + hid := msg["handle_id"].(float64) + handle, found := g.handles[uint64(hid)] + if !found { + return &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: janus.JANUS_ERROR_HANDLE_NOT_FOUND, + Reason: "Handle not found", + }, + } + } + + var result any + switch method { + case "message": + body, ok := api.ConvertStringMap(msg["body"]) + assert.True(g.t, ok, "not a string map: %+v", msg["body"]) + if jsepOb, found := msg["jsep"]; found { + if jsep, ok := api.ConvertStringMap(jsepOb); assert.True(g.t, ok, "not a string map: %+v", jsepOb) { + result = g.processMessage(session, handle, body, jsep) + } + } else { + result = g.processMessage(session, handle, body, nil) + } + case "trickle": + room, found := g.handleRooms[handle] + if !found { + return &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: janus.JANUS_VIDEOROOM_ERROR_INVALID_REQUEST, + Reason: "No joined to a room yet.", + }, + } + } + + handler, found := g.handlers[method.(string)] + if found { + var err *janus.ErrorMsg + result, err = handler(room, msg, nil) + if err != nil { + result = err + } + } + } + + if ev, ok := result.(*janus.EventMsg); ok { + if ev.Session == 0 { + ev.Session = uint64(sid) + } + if ev.Handle == 0 { + ev.Handle = handle.id + } + } + return result + } + + return nil +} + +func (g *JanusGateway) Send(msg api.StringMap, t *janus.Transaction) (uint64, error) { + tid := g.tid.Add(1) + + data, err := json.Marshal(msg) + require.NoError(g.t, err) + err = json.Unmarshal(data, &msg) + require.NoError(g.t, err) + + go t.Run() + + g.mu.Lock() + defer g.mu.Unlock() + g.transactions[tid] = t + + go func() { + result := g.processRequest(msg) + if !assert.NotNil(g.t, result, "Unsupported request %+v", msg) { + result = &janus.ErrorMsg{ + Err: janus.ErrorData{ + Code: janus.JANUS_ERROR_UNKNOWN, + Reason: "Not implemented", + }, + } + } + + t.Add(result) + }() + + return tid, nil +} + +func (g *JanusGateway) RemoveTransaction(id uint64) { + g.mu.Lock() + defer g.mu.Unlock() + if t, found := g.transactions[id]; found { + delete(g.transactions, id) + t.Quit() + } +} + +func (g *JanusGateway) RemoveSession(session *janus.Session) { + g.mu.Lock() + defer g.mu.Unlock() + delete(g.sessions, session.Id) +} diff --git a/sfu/mock/mock.go b/sfu/mock/mock.go new file mode 100644 index 0000000..0f47340 --- /dev/null +++ b/sfu/mock/mock.go @@ -0,0 +1,80 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package mock + +import ( + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" +) + +type Listener struct { + publicId api.PublicSessionId +} + +func NewListener(publicId api.PublicSessionId) *Listener { + return &Listener{ + publicId: publicId, + } +} + +func (m *Listener) PublicId() api.PublicSessionId { + return m.publicId +} + +func (m *Listener) OnUpdateOffer(client sfu.Client, offer api.StringMap) { + +} + +func (m *Listener) OnIceCandidate(client sfu.Client, candidate any) { + +} + +func (m *Listener) OnIceCompleted(client sfu.Client) { + +} + +func (m *Listener) SubscriberSidUpdated(subscriber sfu.Subscriber) { + +} + +func (m *Listener) PublisherClosed(publisher sfu.Publisher) { + +} + +func (m *Listener) SubscriberClosed(subscriber sfu.Subscriber) { + +} + +type Initiator struct { + country geoip.Country +} + +func NewInitiator(country geoip.Country) *Initiator { + return &Initiator{ + country: country, + } +} + +func (m *Initiator) Country() geoip.Country { + return m.country +} diff --git a/proxy_config.go b/sfu/proxy/config.go similarity index 95% rename from proxy_config.go rename to sfu/proxy/config.go index 2a4102c..5453f97 100644 --- a/proxy_config.go +++ b/sfu/proxy/config.go @@ -19,13 +19,13 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package proxy import ( "github.com/dlintw/goconf" ) -type ProxyConfig interface { +type Config interface { Start() error Stop() diff --git a/proxy_config_etcd.go b/sfu/proxy/config_etcd.go similarity index 52% rename from proxy_config_etcd.go rename to sfu/proxy/config_etcd.go index 35ccade..2714231 100644 --- a/proxy_config_etcd.go +++ b/sfu/proxy/config_etcd.go @@ -19,49 +19,72 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package proxy import ( "context" "encoding/json" "errors" - "log" "sync" + "sync/atomic" "time" "github.com/dlintw/goconf" clientv3 "go.etcd.io/etcd/client/v3" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/async" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/proxy" ) -type proxyConfigEtcd struct { - mu sync.Mutex - proxy McuProxy +const ( + initialWaitDelay = time.Second + maxWaitDelay = 8 * time.Second +) - client *EtcdClient +type configEtcd struct { + logger log.Logger + mu sync.Mutex + proxy McuProxy // +checklocksignore: Only written to from constructor. + + client etcd.Client keyPrefix string - keyInfos map[string]*ProxyInformationEtcd - urlToKey map[string]string + // +checklocks:mu + keyInfos map[string]*proxy.InformationEtcd + // +checklocks:mu + urlToKey map[string]string closeCtx context.Context closeFunc context.CancelFunc + + initializing atomic.Bool + initializedCtx context.Context + initializedFunc context.CancelFunc + runningDone sync.WaitGroup } -func NewProxyConfigEtcd(config *goconf.ConfigFile, etcdClient *EtcdClient, proxy McuProxy) (ProxyConfig, error) { +func NewConfigEtcd(logger log.Logger, config *goconf.ConfigFile, etcdClient etcd.Client, sfuProxy McuProxy) (Config, error) { if !etcdClient.IsConfigured() { - return nil, errors.New("No etcd endpoints configured") + return nil, errors.New("no etcd endpoints configured") } + initializedCtx, initializedFunc := context.WithCancel(context.Background()) closeCtx, closeFunc := context.WithCancel(context.Background()) - result := &proxyConfigEtcd{ - proxy: proxy, + result := &configEtcd{ + logger: logger, + proxy: sfuProxy, client: etcdClient, - keyInfos: make(map[string]*ProxyInformationEtcd), + keyInfos: make(map[string]*proxy.InformationEtcd), urlToKey: make(map[string]string), closeCtx: closeCtx, closeFunc: closeFunc, + + initializedCtx: initializedCtx, + initializedFunc: initializedFunc, } if err := result.configure(config, false); err != nil { return nil, err @@ -69,7 +92,7 @@ func NewProxyConfigEtcd(config *goconf.ConfigFile, etcdClient *EtcdClient, proxy return result, nil } -func (p *proxyConfigEtcd) configure(config *goconf.ConfigFile, fromReload bool) error { +func (p *configEtcd) configure(config *goconf.ConfigFile, fromReload bool) error { keyPrefix, _ := config.GetString("mcu", "keyprefix") if keyPrefix == "" { keyPrefix = "/%s" @@ -79,23 +102,38 @@ func (p *proxyConfigEtcd) configure(config *goconf.ConfigFile, fromReload bool) return nil } -func (p *proxyConfigEtcd) Start() error { +func (p *configEtcd) Start() error { p.client.AddListener(p) return nil } -func (p *proxyConfigEtcd) Reload(config *goconf.ConfigFile) error { +func (p *configEtcd) Reload(config *goconf.ConfigFile) error { // not implemented return nil } -func (p *proxyConfigEtcd) Stop() { - p.client.RemoveListener(p) +func (p *configEtcd) Stop() { + firstStop := p.closeCtx.Err() == nil p.closeFunc() + p.client.RemoveListener(p) + if firstStop { + if p.initializing.Load() { + <-p.initializedCtx.Done() + } + p.runningDone.Wait() + } } -func (p *proxyConfigEtcd) EtcdClientCreated(client *EtcdClient) { - go func() { +func (p *configEtcd) EtcdClientCreated(client etcd.Client) { + p.initializing.Store(true) + if p.closeCtx.Err() != nil { + // Stopped before etcd client was connected. + p.initializedFunc() + return + } + + p.runningDone.Go(func() { + defer p.initializedFunc() if err := client.WaitForConnection(p.closeCtx); err != nil { if errors.Is(err, context.Canceled) { return @@ -104,7 +142,7 @@ func (p *proxyConfigEtcd) EtcdClientCreated(client *EtcdClient) { panic(err) } - backoff, err := NewExponentialBackoff(initialWaitDelay, maxWaitDelay) + backoff, err := async.NewExponentialBackoff(initialWaitDelay, maxWaitDelay) if err != nil { panic(err) } @@ -116,9 +154,9 @@ func (p *proxyConfigEtcd) EtcdClientCreated(client *EtcdClient) { if errors.Is(err, context.Canceled) { return } else if errors.Is(err, context.DeadlineExceeded) { - log.Printf("Timeout getting initial list of proxy URLs, retry in %s", backoff.NextWait()) + p.logger.Printf("Timeout getting initial list of proxy URLs, retry in %s", backoff.NextWait()) } else { - log.Printf("Could not get initial list of proxy URLs, retry in %s: %s", backoff.NextWait(), err) + p.logger.Printf("Could not get initial list of proxy URLs, retry in %s: %s", backoff.NextWait(), err) } backoff.Wait(p.closeCtx) @@ -131,13 +169,14 @@ func (p *proxyConfigEtcd) EtcdClientCreated(client *EtcdClient) { nextRevision = response.Header.Revision + 1 break } + p.initializedFunc() prevRevision := nextRevision backoff.Reset() for p.closeCtx.Err() == nil { var err error if nextRevision, err = client.Watch(p.closeCtx, p.keyPrefix, nextRevision, p, clientv3.WithPrefix()); err != nil { - log.Printf("Error processing watch for %s (%s), retry in %s", p.keyPrefix, err, backoff.NextWait()) + p.logger.Printf("Error processing watch for %s (%s), retry in %s", p.keyPrefix, err, backoff.NextWait()) backoff.Wait(p.closeCtx) continue } @@ -146,31 +185,31 @@ func (p *proxyConfigEtcd) EtcdClientCreated(client *EtcdClient) { backoff.Reset() prevRevision = nextRevision } else { - log.Printf("Processing watch for %s interrupted, retry in %s", p.keyPrefix, backoff.NextWait()) + p.logger.Printf("Processing watch for %s interrupted, retry in %s", p.keyPrefix, backoff.NextWait()) backoff.Wait(p.closeCtx) } } - }() + }) } -func (p *proxyConfigEtcd) EtcdWatchCreated(client *EtcdClient, key string) { +func (p *configEtcd) EtcdWatchCreated(client etcd.Client, key string) { } -func (p *proxyConfigEtcd) getProxyUrls(ctx context.Context, client *EtcdClient, keyPrefix string) (*clientv3.GetResponse, error) { +func (p *configEtcd) getProxyUrls(ctx context.Context, client etcd.Client, keyPrefix string) (*clientv3.GetResponse, error) { ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() return client.Get(ctx, keyPrefix, clientv3.WithPrefix()) } -func (p *proxyConfigEtcd) EtcdKeyUpdated(client *EtcdClient, key string, data []byte, prevValue []byte) { - var info ProxyInformationEtcd +func (p *configEtcd) EtcdKeyUpdated(client etcd.Client, key string, data []byte, prevValue []byte) { + var info proxy.InformationEtcd if err := json.Unmarshal(data, &info); err != nil { - log.Printf("Could not decode proxy information %s: %s", string(data), err) + p.logger.Printf("Could not decode proxy information %s: %s", string(data), err) return } if err := info.CheckValid(); err != nil { - log.Printf("Received invalid proxy information %s: %s", string(data), err) + p.logger.Printf("Received invalid proxy information %s: %s", string(data), err) return } @@ -185,7 +224,7 @@ func (p *proxyConfigEtcd) EtcdKeyUpdated(client *EtcdClient, key string, data [] } if otherKey, otherFound := p.urlToKey[info.Address]; otherFound && otherKey != key { - log.Printf("Address %s is already registered for key %s, ignoring %s", info.Address, otherKey, key) + p.logger.Printf("Address %s is already registered for key %s, ignoring %s", info.Address, otherKey, key) return } @@ -194,24 +233,25 @@ func (p *proxyConfigEtcd) EtcdKeyUpdated(client *EtcdClient, key string, data [] p.proxy.KeepConnection(info.Address) } else { if err := p.proxy.AddConnection(false, info.Address); err != nil { - log.Printf("Could not create proxy connection to %s: %s", info.Address, err) + p.logger.Printf("Could not create proxy connection to %s: %s", info.Address, err) return } - log.Printf("Added new connection to %s (from %s)", info.Address, key) + p.logger.Printf("Added new connection to %s (from %s)", info.Address, key) p.keyInfos[key] = &info p.urlToKey[info.Address] = key } } -func (p *proxyConfigEtcd) EtcdKeyDeleted(client *EtcdClient, key string, prevValue []byte) { +func (p *configEtcd) EtcdKeyDeleted(client etcd.Client, key string, prevValue []byte) { p.mu.Lock() defer p.mu.Unlock() p.removeEtcdProxyLocked(key) } -func (p *proxyConfigEtcd) removeEtcdProxyLocked(key string) { +// +checklocks:p.mu +func (p *configEtcd) removeEtcdProxyLocked(key string) { info, found := p.keyInfos[key] if !found { return @@ -220,6 +260,6 @@ func (p *proxyConfigEtcd) removeEtcdProxyLocked(key string) { delete(p.keyInfos, key) delete(p.urlToKey, info.Address) - log.Printf("Removing connection to %s (from %s)", info.Address, key) + p.logger.Printf("Removing connection to %s (from %s)", info.Address, key) p.proxy.RemoveConnection(info.Address) } diff --git a/proxy_config_etcd_test.go b/sfu/proxy/config_etcd_test.go similarity index 66% rename from proxy_config_etcd_test.go rename to sfu/proxy/config_etcd_test.go index 353f690..553fb52 100644 --- a/proxy_config_etcd_test.go +++ b/sfu/proxy/config_etcd_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package proxy import ( "context" @@ -29,7 +29,9 @@ import ( "github.com/dlintw/goconf" "github.com/stretchr/testify/require" - "go.etcd.io/etcd/server/v3/embed" + + etcdtest "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd/test" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" ) type TestProxyInformationEtcd struct { @@ -38,36 +40,35 @@ type TestProxyInformationEtcd struct { OtherData string `json:"otherdata,omitempty"` } -func newProxyConfigEtcd(t *testing.T, proxy McuProxy) (*embed.Etcd, ProxyConfig) { +func newProxyConfigEtcd(t *testing.T, proxy McuProxy) (*etcdtest.Server, Config) { t.Helper() - etcd, client := NewEtcdClientForTest(t) + embedEtcd, client := etcdtest.NewClientForTest(t) cfg := goconf.NewConfigFile() cfg.AddOption("mcu", "keyprefix", "proxies/") - p, err := NewProxyConfigEtcd(cfg, client, proxy) + logger := logtest.NewLoggerForTest(t) + p, err := NewConfigEtcd(logger, cfg, client, proxy) require.NoError(t, err) t.Cleanup(func() { p.Stop() }) - return etcd, p + return embedEtcd, p } -func SetEtcdProxy(t *testing.T, etcd *embed.Etcd, path string, proxy *TestProxyInformationEtcd) { +func SetEtcdProxy(t *testing.T, server *etcdtest.Server, path string, proxy *TestProxyInformationEtcd) { t.Helper() - data, err := json.Marshal(proxy) - require.NoError(t, err) - SetEtcdValue(etcd, path, data) + data, _ := json.Marshal(proxy) + server.SetValue(path, data) } func TestProxyConfigEtcd(t *testing.T) { t.Parallel() - CatchLogForTest(t) proxy := newMcuProxyForConfig(t) - etcd, config := newProxyConfigEtcd(t, proxy) + embedEtcd, config := newProxyConfigEtcd(t, proxy) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) defer cancel() - SetEtcdProxy(t, etcd, "proxies/a", &TestProxyInformationEtcd{ + SetEtcdProxy(t, embedEtcd, "proxies/a", &TestProxyInformationEtcd{ Address: "https://foo/", }) proxy.Expect("add", "https://foo/") @@ -75,31 +76,31 @@ func TestProxyConfigEtcd(t *testing.T) { proxy.WaitForEvents(ctx) proxy.Expect("add", "https://bar/") - SetEtcdProxy(t, etcd, "proxies/b", &TestProxyInformationEtcd{ + SetEtcdProxy(t, embedEtcd, "proxies/b", &TestProxyInformationEtcd{ Address: "https://bar/", }) proxy.WaitForEvents(ctx) proxy.Expect("keep", "https://bar/") - SetEtcdProxy(t, etcd, "proxies/b", &TestProxyInformationEtcd{ + SetEtcdProxy(t, embedEtcd, "proxies/b", &TestProxyInformationEtcd{ Address: "https://bar/", OtherData: "ignore-me", }) proxy.WaitForEvents(ctx) proxy.Expect("remove", "https://foo/") - DeleteEtcdValue(etcd, "proxies/a") + embedEtcd.DeleteValue("proxies/a") proxy.WaitForEvents(ctx) proxy.Expect("remove", "https://bar/") proxy.Expect("add", "https://baz/") - SetEtcdProxy(t, etcd, "proxies/b", &TestProxyInformationEtcd{ + SetEtcdProxy(t, embedEtcd, "proxies/b", &TestProxyInformationEtcd{ Address: "https://baz/", }) proxy.WaitForEvents(ctx) // Adding the same hostname multiple times should not trigger an event. - SetEtcdProxy(t, etcd, "proxies/c", &TestProxyInformationEtcd{ + SetEtcdProxy(t, embedEtcd, "proxies/c", &TestProxyInformationEtcd{ Address: "https://baz/", }) time.Sleep(100 * time.Millisecond) diff --git a/proxy_config_static.go b/sfu/proxy/config_static.go similarity index 65% rename from proxy_config_static.go rename to sfu/proxy/config_static.go index eda67d7..1db4ad3 100644 --- a/proxy_config_static.go +++ b/sfu/proxy/config_static.go @@ -19,38 +19,46 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package proxy import ( "errors" - "log" + "maps" "net" "net/url" - "strings" "sync" "github.com/dlintw/goconf" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) type ipList struct { hostname string - entry *DnsMonitorEntry + entry *dns.MonitorEntry ips []net.IP } -type proxyConfigStatic struct { - mu sync.Mutex - proxy McuProxy +type configStatic struct { + logger log.Logger + mu sync.Mutex + proxy McuProxy - dnsMonitor *DnsMonitor + dnsMonitor *dns.Monitor // +checklocksignore: Only written to from constructor. + // +checklocks:mu dnsDiscovery bool + // +checklocks:mu connectionsMap map[string]*ipList } -func NewProxyConfigStatic(config *goconf.ConfigFile, proxy McuProxy, dnsMonitor *DnsMonitor) (ProxyConfig, error) { - result := &proxyConfigStatic{ +func NewConfigStatic(logger log.Logger, config *goconf.ConfigFile, proxy McuProxy, dnsMonitor *dns.Monitor) (Config, error) { + result := &configStatic{ + logger: logger, proxy: proxy, dnsMonitor: dnsMonitor, connectionsMap: make(map[string]*ipList), @@ -59,16 +67,16 @@ func NewProxyConfigStatic(config *goconf.ConfigFile, proxy McuProxy, dnsMonitor return nil, err } if len(result.connectionsMap) == 0 { - return nil, errors.New("No MCU proxy connections configured") + return nil, errors.New("no MCU proxy connections configured") } return result, nil } -func (p *proxyConfigStatic) configure(config *goconf.ConfigFile, fromReload bool) error { +func (p *configStatic) configure(cfg *goconf.ConfigFile, fromReload bool) error { p.mu.Lock() defer p.mu.Unlock() - dnsDiscovery, _ := config.GetBool("mcu", "dnsdiscovery") + dnsDiscovery, _ := cfg.GetBool("mcu", "dnsdiscovery") if dnsDiscovery != p.dnsDiscovery { if !dnsDiscovery { for _, ips := range p.connectionsMap { @@ -81,18 +89,10 @@ func (p *proxyConfigStatic) configure(config *goconf.ConfigFile, fromReload bool p.dnsDiscovery = dnsDiscovery } - remove := make(map[string]*ipList) - for u, ips := range p.connectionsMap { - remove[u] = ips - } - - mcuUrl, _ := GetStringOptionWithEnv(config, "mcu", "url") - for _, u := range strings.Split(mcuUrl, " ") { - u = strings.TrimSpace(u) - if u == "" { - continue - } + remove := maps.Clone(p.connectionsMap) + mcuUrl, _ := config.GetStringOptionWithEnv(cfg, "mcu", "url") + for u := range internal.SplitEntries(mcuUrl, " ") { if existing, found := remove[u]; found { // Proxy connection still exists in new configuration delete(remove, u) @@ -106,7 +106,7 @@ func (p *proxyConfigStatic) configure(config *goconf.ConfigFile, fromReload bool return err } - log.Printf("Could not parse URL %s: %s", u, err) + p.logger.Printf("Could not parse URL %s: %s", u, err) continue } @@ -115,7 +115,7 @@ func (p *proxyConfigStatic) configure(config *goconf.ConfigFile, fromReload bool } if dnsDiscovery { - p.connectionsMap[u] = &ipList{ + p.connectionsMap[u] = &ipList{ // +checklocksignore: Not supported for iter loops yet, see https://github.com/google/gvisor/issues/12176 hostname: parsed.Host, } continue @@ -127,12 +127,12 @@ func (p *proxyConfigStatic) configure(config *goconf.ConfigFile, fromReload bool return err } - log.Printf("Could not create proxy connection to %s: %s", u, err) + p.logger.Printf("Could not create proxy connection to %s: %s", u, err) continue } } - p.connectionsMap[u] = &ipList{ + p.connectionsMap[u] = &ipList{ // +checklocksignore: Not supported for iter loops yet, see https://github.com/google/gvisor/issues/12176 hostname: parsed.Host, } } @@ -145,7 +145,7 @@ func (p *proxyConfigStatic) configure(config *goconf.ConfigFile, fromReload bool return nil } -func (p *proxyConfigStatic) Start() error { +func (p *configStatic) Start() error { p.mu.Lock() defer p.mu.Unlock() @@ -173,7 +173,7 @@ func (p *proxyConfigStatic) Start() error { return nil } -func (p *proxyConfigStatic) Stop() { +func (p *configStatic) Stop() { p.mu.Lock() defer p.mu.Unlock() @@ -189,11 +189,11 @@ func (p *proxyConfigStatic) Stop() { } } -func (p *proxyConfigStatic) Reload(config *goconf.ConfigFile) error { +func (p *configStatic) Reload(config *goconf.ConfigFile) error { return p.configure(config, true) } -func (p *proxyConfigStatic) onLookup(entry *DnsMonitorEntry, all []net.IP, added []net.IP, keep []net.IP, removed []net.IP) { +func (p *configStatic) onLookup(entry *dns.MonitorEntry, all []net.IP, added []net.IP, keep []net.IP, removed []net.IP) { p.mu.Lock() defer p.mu.Unlock() @@ -204,7 +204,7 @@ func (p *proxyConfigStatic) onLookup(entry *DnsMonitorEntry, all []net.IP, added if len(added) > 0 { if err := p.proxy.AddConnection(true, u, added...); err != nil { - log.Printf("Could not add proxy connection to %s with %+v: %s", u, added, err) + p.logger.Printf("Could not add proxy connection to %s with %+v: %s", u, added, err) } } diff --git a/proxy_config_static_test.go b/sfu/proxy/config_static_test.go similarity index 74% rename from proxy_config_static_test.go rename to sfu/proxy/config_static_test.go index 70884e8..50e5b74 100644 --- a/proxy_config_static_test.go +++ b/sfu/proxy/config_static_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package proxy import ( "net" @@ -29,16 +29,21 @@ import ( "github.com/dlintw/goconf" "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns" + dnstest "github.com/strukturag/nextcloud-spreed-signaling/v2/dns/test" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" ) -func newProxyConfigStatic(t *testing.T, proxy McuProxy, dns bool, urls ...string) (ProxyConfig, *DnsMonitor) { +func newProxyConfigStatic(t *testing.T, proxy McuProxy, dnsDiscovery bool, lookup *dnstest.MockLookup, urls ...string) (Config, *dns.Monitor) { cfg := goconf.NewConfigFile() cfg.AddOption("mcu", "url", strings.Join(urls, " ")) - if dns { + if dnsDiscovery { cfg.AddOption("mcu", "dnsdiscovery", "true") } - dnsMonitor := newDnsMonitorForTest(t, time.Hour) // will be updated manually - p, err := NewProxyConfigStatic(cfg, proxy, dnsMonitor) + dnsMonitor := dnstest.NewMonitorForTest(t, time.Hour, lookup) // will be updated manually + logger := logtest.NewLoggerForTest(t) + p, err := NewConfigStatic(logger, cfg, proxy, dnsMonitor) require.NoError(t, err) t.Cleanup(func() { p.Stop() @@ -46,7 +51,7 @@ func newProxyConfigStatic(t *testing.T, proxy McuProxy, dns bool, urls ...string return p, dnsMonitor } -func updateProxyConfigStatic(t *testing.T, config ProxyConfig, dns bool, urls ...string) { +func updateProxyConfigStatic(t *testing.T, config Config, dns bool, urls ...string) { cfg := goconf.NewConfigFile() cfg.AddOption("mcu", "url", strings.Join(urls, " ")) if dns { @@ -56,9 +61,9 @@ func updateProxyConfigStatic(t *testing.T, config ProxyConfig, dns bool, urls .. } func TestProxyConfigStaticSimple(t *testing.T) { - CatchLogForTest(t) + t.Parallel() proxy := newMcuProxyForConfig(t) - config, _ := newProxyConfigStatic(t, proxy, false, "https://foo/") + config, _ := newProxyConfigStatic(t, proxy, false, nil, "https://foo/") proxy.Expect("add", "https://foo/") require.NoError(t, config.Start()) @@ -73,10 +78,10 @@ func TestProxyConfigStaticSimple(t *testing.T) { } func TestProxyConfigStaticDNS(t *testing.T) { - CatchLogForTest(t) - lookup := newMockDnsLookupForTest(t) + t.Parallel() + lookup := dnstest.NewMockLookup() proxy := newMcuProxyForConfig(t) - config, dnsMonitor := newProxyConfigStatic(t, proxy, true, "https://foo/") + config, dnsMonitor := newProxyConfigStatic(t, proxy, true, lookup, "https://foo/") require.NoError(t, config.Start()) time.Sleep(time.Millisecond) @@ -86,7 +91,7 @@ func TestProxyConfigStaticDNS(t *testing.T) { net.ParseIP("10.1.2.3"), }) proxy.Expect("add", "https://foo/", lookup.Get("foo")...) - dnsMonitor.checkHostnames() + dnsMonitor.CheckHostnames() lookup.Set("foo", []net.IP{ net.ParseIP("192.168.0.1"), @@ -96,7 +101,7 @@ func TestProxyConfigStaticDNS(t *testing.T) { proxy.Expect("keep", "https://foo/", net.ParseIP("192.168.0.1")) proxy.Expect("add", "https://foo/", net.ParseIP("192.168.1.1"), net.ParseIP("192.168.1.2")) proxy.Expect("remove", "https://foo/", net.ParseIP("10.1.2.3")) - dnsMonitor.checkHostnames() + dnsMonitor.CheckHostnames() proxy.Expect("add", "https://bar/") proxy.Expect("remove", "https://foo/", lookup.Get("foo")...) diff --git a/proxy_config_test.go b/sfu/proxy/config_test.go similarity index 75% rename from proxy_config_test.go rename to sfu/proxy/config_test.go index 47f51ad..887989d 100644 --- a/proxy_config_test.go +++ b/sfu/proxy/config_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package proxy import ( "context" @@ -52,10 +52,12 @@ type proxyConfigEvent struct { } type mcuProxyForConfig struct { - t *testing.T + t *testing.T + mu sync.Mutex + // +checklocks:mu expected []proxyConfigEvent - mu sync.Mutex - waiters []chan struct{} + // +checklocks:mu + waiters []chan struct{} } func newMcuProxyForConfig(t *testing.T) *mcuProxyForConfig { @@ -63,6 +65,8 @@ func newMcuProxyForConfig(t *testing.T) *mcuProxyForConfig { t: t, } t.Cleanup(func() { + proxy.mu.Lock() + defer proxy.mu.Unlock() assert.Empty(t, proxy.expected) }) return proxy @@ -83,20 +87,29 @@ func (p *mcuProxyForConfig) Expect(action string, url string, ips ...net.IP) { }) } -func (p *mcuProxyForConfig) WaitForEvents(ctx context.Context) { +func (p *mcuProxyForConfig) addWaiter() chan struct{} { p.t.Helper() p.mu.Lock() defer p.mu.Unlock() if len(p.expected) == 0 { - return + return nil } waiter := make(chan struct{}) p.waiters = append(p.waiters, waiter) - p.mu.Unlock() - defer p.mu.Lock() + return waiter +} + +func (p *mcuProxyForConfig) WaitForEvents(ctx context.Context) { + p.t.Helper() + + waiter := p.addWaiter() + if waiter == nil { + return + } + select { case <-ctx.Done(): assert.NoError(p.t, ctx.Err()) @@ -104,6 +117,32 @@ func (p *mcuProxyForConfig) WaitForEvents(ctx context.Context) { } } +func (p *mcuProxyForConfig) getWaitersIfEmpty() []chan struct{} { + p.mu.Lock() + defer p.mu.Unlock() + + if len(p.expected) != 0 { + return nil + } + + waiters := p.waiters + p.waiters = nil + return waiters +} + +func (p *mcuProxyForConfig) getExpectedEvent() *proxyConfigEvent { + p.mu.Lock() + defer p.mu.Unlock() + + if len(p.expected) == 0 { + return nil + } + + expected := p.expected[0] + p.expected = p.expected[1:] + return &expected +} + func (p *mcuProxyForConfig) checkEvent(event *proxyConfigEvent) { p.t.Helper() pc := make([]uintptr, 32) @@ -121,31 +160,23 @@ func (p *mcuProxyForConfig) checkEvent(event *proxyConfigEvent) { } } - p.mu.Lock() - defer p.mu.Unlock() - - if len(p.expected) == 0 { - assert.Fail(p.t, "no event expected, got %+v from %s:%d", event, caller.File, caller.Line) + expected := p.getExpectedEvent() + if expected == nil { + assert.Fail(p.t, "no event expected", "received %+v from %s:%d", event, caller.File, caller.Line) return } - defer func() { - if len(p.expected) == 0 { - waiters := p.waiters - p.waiters = nil - p.mu.Unlock() - defer p.mu.Lock() + if !reflect.DeepEqual(expected, event) { + assert.Fail(p.t, "wrong event", "expected %+v, received %+v from %s:%d", expected, event, caller.File, caller.Line) + } - for _, ch := range waiters { - ch <- struct{}{} - } - } - }() + waiters := p.getWaitersIfEmpty() + if len(waiters) == 0 { + return + } - expected := p.expected[0] - p.expected = p.expected[1:] - if !reflect.DeepEqual(expected, *event) { - assert.Fail(p.t, "expected %+v, got %+v from %s:%d", expected, event, caller.File, caller.Line) + for _, ch := range waiters { + ch <- struct{}{} } } diff --git a/sfu/proxy/proxy.go b/sfu/proxy/proxy.go new file mode 100644 index 0000000..52278c9 --- /dev/null +++ b/sfu/proxy/proxy.go @@ -0,0 +1,2463 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2020 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package proxy + +import ( + "context" + "crypto/rsa" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "iter" + "math/rand/v2" + "net" + "net/http" + "net/url" + "os" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/dlintw/goconf" + "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/websocket" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/dns" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/proxy" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + sfuinternal "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +const ( + closeTimeout = time.Second + + proxyDebugMessages = false + + // Very high value so the connections get sorted at the end. + loadNotConnected = 1000000 + + // Sort connections by load every 10 publishing requests or once per second. + connectionSortRequests = 10 + connectionSortInterval = time.Second + + proxyUrlTypeStatic = "static" + proxyUrlTypeEtcd = "etcd" + + initialReconnectInterval = 1 * time.Second + maxReconnectInterval = 16 * time.Second + + // Time allowed to write a message to the peer. + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 60 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 + + defaultProxyTimeoutSeconds = 2 + + rttLogDuration = 500 * time.Millisecond +) + +type ContinentsMap map[geoip.Continent][]geoip.Continent + +type McuProxy interface { + AddConnection(ignoreErrors bool, url string, ips ...net.IP) error + KeepConnection(url string, ips ...net.IP) + RemoveConnection(url string, ips ...net.IP) +} + +type proxyPubSubCommon struct { + logger log.Logger + + sid string + streamType sfu.StreamType + maxBitrate api.Bandwidth + proxyId string + conn *proxyConnection + listener sfu.Listener +} + +func (c *proxyPubSubCommon) Id() string { + return c.proxyId +} + +func (c *proxyPubSubCommon) Sid() string { + return c.sid +} + +func (c *proxyPubSubCommon) StreamType() sfu.StreamType { + return c.streamType +} + +func (c *proxyPubSubCommon) MaxBitrate() api.Bandwidth { + return c.maxBitrate +} + +func (c *proxyPubSubCommon) doSendMessage(ctx context.Context, msg *proxy.ClientMessage, callback func(error, api.StringMap)) { + c.conn.performAsyncRequest(ctx, msg, func(err error, response *proxy.ServerMessage) { + if err != nil { + callback(err, nil) + return + } + + if proxyDebugMessages { + c.logger.Printf("Response from %s: %+v", c.conn, response) + } + if response.Type == "error" { + callback(response.Error, nil) + } else if response.Payload != nil { + callback(nil, response.Payload.Payload) + } else { + callback(nil, nil) + } + }) +} + +func (c *proxyPubSubCommon) doProcessPayload(client sfu.Client, msg *proxy.PayloadServerMessage) { + switch msg.Type { + case "offer": + offer, ok := api.ConvertStringMap(msg.Payload["offer"]) + if !ok { + c.logger.Printf("Unsupported payload from %s: %+v", c.conn, msg) + return + } + + c.listener.OnUpdateOffer(client, offer) + case "candidate": + c.listener.OnIceCandidate(client, msg.Payload["candidate"]) + default: + c.logger.Printf("Unsupported payload from %s: %+v", c.conn, msg) + } +} + +type proxyPublisher struct { + proxyPubSubCommon + + id api.PublicSessionId + settings sfu.NewPublisherSettings +} + +func newProxyPublisher(logger log.Logger, id api.PublicSessionId, sid string, streamType sfu.StreamType, maxBitrate api.Bandwidth, settings sfu.NewPublisherSettings, proxyId string, conn *proxyConnection, listener sfu.Listener) *proxyPublisher { + return &proxyPublisher{ + proxyPubSubCommon: proxyPubSubCommon{ + logger: logger, + + sid: sid, + streamType: streamType, + maxBitrate: maxBitrate, + proxyId: proxyId, + conn: conn, + listener: listener, + }, + id: id, + settings: settings, + } +} + +func (p *proxyPublisher) GetConnectionURL() (string, net.IP) { + return p.conn.rawUrl, p.conn.ip +} + +func (p *proxyPublisher) PublisherId() api.PublicSessionId { + return p.id +} + +func (p *proxyPublisher) HasMedia(mt sfu.MediaType) bool { + return (p.settings.MediaTypes & mt) == mt +} + +func (p *proxyPublisher) SetMedia(mt sfu.MediaType) { + // TODO: Also update mediaTypes on proxy. + p.settings.MediaTypes = mt +} + +func (p *proxyPublisher) NotifyClosed() { + p.logger.Printf("Publisher %s at %s was closed", p.proxyId, p.conn) + p.listener.PublisherClosed(p) + p.conn.removePublisher(p) +} + +func (p *proxyPublisher) Close(ctx context.Context) { + p.NotifyClosed() + + msg := &proxy.ClientMessage{ + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "delete-publisher", + ClientId: p.proxyId, + }, + } + + if response, _, err := p.conn.performSyncRequest(ctx, msg); err != nil { + p.logger.Printf("Could not delete publisher %s at %s: %s", p.proxyId, p.conn, err) + return + } else if response.Type == "error" { + p.logger.Printf("Could not delete publisher %s at %s: %s", p.proxyId, p.conn, response.Error) + return + } + + p.logger.Printf("Deleted publisher %s at %s", p.proxyId, p.conn) +} + +func (p *proxyPublisher) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { + msg := &proxy.ClientMessage{ + Type: "payload", + Payload: &proxy.PayloadClientMessage{ + Type: data.Type, + ClientId: p.proxyId, + Sid: data.Sid, + Payload: data.Payload, + }, + } + + p.doSendMessage(ctx, msg, callback) +} + +func (p *proxyPublisher) ProcessPayload(msg *proxy.PayloadServerMessage) { + p.doProcessPayload(p, msg) +} + +func (p *proxyPublisher) ProcessEvent(msg *proxy.EventServerMessage) { + switch msg.Type { + case "ice-completed": + p.listener.OnIceCompleted(p) + case "publisher-closed": + p.NotifyClosed() + default: + p.logger.Printf("Unsupported event from %s: %+v", p.conn, msg) + } +} + +type proxySubscriber struct { + proxyPubSubCommon + + publisherId api.PublicSessionId + publisherConn *proxyConnection +} + +func newProxySubscriber(logger log.Logger, publisherId api.PublicSessionId, sid string, streamType sfu.StreamType, maxBitrate api.Bandwidth, proxyId string, conn *proxyConnection, listener sfu.Listener, publisherConn *proxyConnection) *proxySubscriber { + return &proxySubscriber{ + proxyPubSubCommon: proxyPubSubCommon{ + logger: logger, + + sid: sid, + streamType: streamType, + maxBitrate: maxBitrate, + proxyId: proxyId, + conn: conn, + listener: listener, + }, + + publisherId: publisherId, + publisherConn: publisherConn, + } +} + +func (s *proxySubscriber) GetConnectionURL() (string, net.IP) { + return s.conn.rawUrl, s.conn.ip +} + +func (s *proxySubscriber) Publisher() api.PublicSessionId { + return s.publisherId +} + +func (s *proxySubscriber) NotifyClosed() { + if s.publisherConn != nil { + s.logger.Printf("Remote subscriber %s at %s (forwarded to %s) was closed", s.proxyId, s.conn, s.publisherConn) + } else { + s.logger.Printf("Subscriber %s at %s was closed", s.proxyId, s.conn) + } + s.listener.SubscriberClosed(s) + s.conn.removeSubscriber(s) +} + +func (s *proxySubscriber) Close(ctx context.Context) { + s.NotifyClosed() + + msg := &proxy.ClientMessage{ + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "delete-subscriber", + ClientId: s.proxyId, + }, + } + + if response, _, err := s.conn.performSyncRequest(ctx, msg); err != nil { + if s.publisherConn != nil { + s.logger.Printf("Could not delete remote subscriber %s at %s (forwarded to %s): %s", s.proxyId, s.conn, s.publisherConn, err) + } else { + s.logger.Printf("Could not delete subscriber %s at %s: %s", s.proxyId, s.conn, err) + } + return + } else if response.Type == "error" { + if s.publisherConn != nil { + s.logger.Printf("Could not delete remote subscriber %s at %s (forwarded to %s): %s", s.proxyId, s.conn, s.publisherConn, response.Error) + } else { + s.logger.Printf("Could not delete subscriber %s at %s: %s", s.proxyId, s.conn, response.Error) + } + return + } + + if s.publisherConn != nil { + s.logger.Printf("Deleted remote subscriber %s at %s (forwarded to %s)", s.proxyId, s.conn, s.publisherConn) + } else { + s.logger.Printf("Deleted subscriber %s at %s", s.proxyId, s.conn) + } +} + +func (s *proxySubscriber) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { + msg := &proxy.ClientMessage{ + Type: "payload", + Payload: &proxy.PayloadClientMessage{ + Type: data.Type, + ClientId: s.proxyId, + Sid: data.Sid, + Payload: data.Payload, + }, + } + + s.doSendMessage(ctx, msg, callback) +} + +func (s *proxySubscriber) ProcessPayload(msg *proxy.PayloadServerMessage) { + s.doProcessPayload(s, msg) +} + +func (s *proxySubscriber) ProcessEvent(msg *proxy.EventServerMessage) { + switch msg.Type { + case "ice-completed": + s.listener.OnIceCompleted(s) + case "subscriber-sid-updated": + s.sid = msg.Sid + s.listener.SubscriberSidUpdated(s) + case "subscriber-closed": + s.NotifyClosed() + default: + s.logger.Printf("Unsupported event from %s: %+v", s.conn, msg) + } +} + +type mcuProxyCallback func(response *proxy.ServerMessage) + +type proxyConnection struct { + logger log.Logger + proxy *proxySFU + rawUrl string + url *url.URL + ip net.IP + connectToken string + + load atomic.Uint64 + bandwidth atomic.Pointer[proxy.EventServerBandwidth] + mu sync.Mutex + closer *internal.Closer + closedDone *internal.Closer + closed atomic.Bool + // +checklocks:mu + conn *websocket.Conn + + // +checklocks:mu + helloProcessed bool + connectedSince atomic.Int64 + reconnectTimer *time.Timer + reconnectInterval atomic.Int64 + shutdownScheduled atomic.Bool + closeScheduled atomic.Bool + trackClose atomic.Bool + temporary atomic.Bool + + connectedCond sync.Cond + + msgId atomic.Int64 + helloMsgId string + sessionId atomic.Value + country atomic.Value + version atomic.Value + features atomic.Value + + // +checklocks:mu + callbacks map[string]mcuProxyCallback + // +checklocks:mu + deferredCallbacks map[string]mcuProxyCallback + + publishersLock sync.RWMutex + // +checklocks:publishersLock + publishers map[string]*proxyPublisher + // +checklocks:publishersLock + publisherIds map[sfu.StreamId]api.PublicSessionId + + subscribersLock sync.RWMutex + // +checklocks:subscribersLock + subscribers map[string]*proxySubscriber +} + +func newProxyConnection(proxy *proxySFU, baseUrl string, ip net.IP, token string) (*proxyConnection, error) { + parsed, err := url.Parse(baseUrl) + if err != nil { + return nil, err + } + + conn := &proxyConnection{ + logger: proxy.logger, + proxy: proxy, + rawUrl: baseUrl, + url: parsed, + ip: ip, + connectToken: token, + closer: internal.NewCloser(), + closedDone: internal.NewCloser(), + callbacks: make(map[string]mcuProxyCallback), + publishers: make(map[string]*proxyPublisher), + publisherIds: make(map[sfu.StreamId]api.PublicSessionId), + subscribers: make(map[string]*proxySubscriber), + } + conn.connectedCond.L = &conn.mu + conn.reconnectInterval.Store(int64(initialReconnectInterval)) + conn.load.Store(loadNotConnected) + conn.bandwidth.Store(nil) + conn.country.Store(geoip.Country("")) + conn.version.Store("") + conn.features.Store([]string{}) + statsProxyBackendLoadCurrent.WithLabelValues(conn.url.String()).Set(0) + statsProxyUsageCurrent.WithLabelValues(conn.url.String(), "incoming").Set(0) + statsProxyUsageCurrent.WithLabelValues(conn.url.String(), "outgoing").Set(0) + statsProxyBandwidthCurrent.WithLabelValues(conn.url.String(), "incoming").Set(0) + statsProxyBandwidthCurrent.WithLabelValues(conn.url.String(), "outgoing").Set(0) + return conn, nil +} + +func (c *proxyConnection) String() string { + if c.ip != nil { + return fmt.Sprintf("%s (%s)", c.rawUrl, c.ip) + } + + return c.rawUrl +} + +func (c *proxyConnection) IsSameCountry(initiator sfu.Initiator) bool { + if initiator == nil { + return true + } + + initiatorCountry := initiator.Country() + if initiatorCountry == "" { + return true + } + + connCountry := c.Country() + if connCountry == "" { + return true + } + + return initiatorCountry == connCountry +} + +func (c *proxyConnection) IsSameContinent(initiator sfu.Initiator) bool { + if initiator == nil { + return true + } + + initiatorCountry := initiator.Country() + if initiatorCountry == "" { + return true + } + + connCountry := c.Country() + if connCountry == "" { + return true + } + + initiatorContinents, found := geoip.ContinentMap[initiatorCountry] + if found { + m := c.proxy.getContinentsMap() + // Map continents to other continents (e.g. use Europe for Africa). + for _, continent := range initiatorContinents { + if toAdd, found := m[continent]; found { + initiatorContinents = append(initiatorContinents, toAdd...) + } + } + + } + connContinents := geoip.ContinentMap[connCountry] + return ContinentsOverlap(initiatorContinents, connContinents) +} + +type proxyConnectionStats struct { + Url string `json:"url"` + IP net.IP `json:"ip,omitempty"` + Connected bool `json:"connected"` + Publishers int64 `json:"publishers"` + Clients int64 `json:"clients"` + Load *uint64 `json:"load,omitempty"` + Shutdown *bool `json:"shutdown,omitempty"` + Temporary *bool `json:"temporary,omitempty"` + Uptime *time.Time `json:"uptime,omitempty"` +} + +func (c *proxyConnection) GetStats() *proxyConnectionStats { + result := &proxyConnectionStats{ + Url: c.url.String(), + IP: c.ip, + } + c.mu.Lock() + if c.conn != nil { + result.Connected = true + if since := c.connectedSince.Load(); since != 0 { + t := time.UnixMicro(since) + result.Uptime = &t + } + load := c.Load() + result.Load = &load + shutdown := c.IsShutdownScheduled() + result.Shutdown = &shutdown + temporary := c.IsTemporary() + result.Temporary = &temporary + } + c.mu.Unlock() + c.publishersLock.RLock() + result.Publishers = int64(len(c.publishers)) + c.publishersLock.RUnlock() + c.subscribersLock.RLock() + result.Clients = int64(len(c.subscribers)) + c.subscribersLock.RUnlock() + result.Clients += result.Publishers + return result +} + +func (c *proxyConnection) Load() uint64 { + return c.load.Load() +} + +func (c *proxyConnection) Bandwidth() *proxy.EventServerBandwidth { + return c.bandwidth.Load() +} + +func (c *proxyConnection) Country() geoip.Country { + return c.country.Load().(geoip.Country) +} + +func (c *proxyConnection) Version() string { + return c.version.Load().(string) +} + +func (c *proxyConnection) Features() []string { + return c.features.Load().([]string) +} + +func (c *proxyConnection) SessionId() api.PublicSessionId { + sid := c.sessionId.Load() + if sid == nil { + return "" + } + + return sid.(api.PublicSessionId) +} + +func (c *proxyConnection) IsConnected() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.conn != nil && c.helloProcessed && c.SessionId() != "" +} + +func (c *proxyConnection) IsTemporary() bool { + return c.temporary.Load() +} + +func (c *proxyConnection) setTemporary() { + c.temporary.Store(true) +} + +func (c *proxyConnection) clearTemporary() { + c.temporary.Store(false) +} + +func (c *proxyConnection) IsShutdownScheduled() bool { + return c.shutdownScheduled.Load() || c.closeScheduled.Load() +} + +func (c *proxyConnection) readPump() { + defer func() { + if !c.closed.Load() { + c.scheduleReconnect() + } else { + c.closedDone.Close() + } + }() + defer c.close() + defer func() { + c.load.Store(loadNotConnected) + c.bandwidth.Store(nil) + }() + + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + conn.SetPongHandler(func(msg string) error { + now := time.Now() + conn.SetReadDeadline(now.Add(pongWait)) // nolint + if msg == "" { + return nil + } + if ts, err := strconv.ParseInt(msg, 10, 64); err == nil { + rtt := now.Sub(time.Unix(0, ts)) + if rtt >= rttLogDuration { + rtt_ms := rtt.Nanoseconds() / time.Millisecond.Nanoseconds() + c.logger.Printf("Proxy at %s has RTT of %d ms (%s)", c, rtt_ms, rtt) + } + } + return nil + }) + + for { + conn.SetReadDeadline(time.Now().Add(pongWait)) // nolint + _, message, err := conn.ReadMessage() + if err != nil { + if errors.Is(err, websocket.ErrCloseSent) { + break + } else if _, ok := err.(*websocket.CloseError); !ok || websocket.IsUnexpectedCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseNoStatusReceived) { + c.logger.Printf("Error reading from %s: %v", c, err) + } + break + } + + var msg proxy.ServerMessage + if err := json.Unmarshal(message, &msg); err != nil { + c.logger.Printf("Error unmarshaling %s from %s: %s", string(message), c, err) + continue + } + + c.processMessage(&msg) + } +} + +func (c *proxyConnection) sendPing() bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn == nil { + return false + } + + now := time.Now() + msg := strconv.FormatInt(now.UnixNano(), 10) + c.conn.SetWriteDeadline(now.Add(writeWait)) // nolint + if err := c.conn.WriteMessage(websocket.PingMessage, []byte(msg)); err != nil { + c.logger.Printf("Could not send ping to proxy at %s: %v", c, err) + go c.scheduleReconnect() + return false + } + + return true +} + +func (c *proxyConnection) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + }() + + c.reconnectTimer = time.NewTimer(0) + defer c.reconnectTimer.Stop() + for { + select { + case <-c.reconnectTimer.C: + c.reconnect() + case <-ticker.C: + c.sendPing() + case <-c.closer.C: + return + } + } +} + +func (c *proxyConnection) start() { + go c.writePump() +} + +func (c *proxyConnection) sendClose() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn == nil { + return sfu.ErrNotConnected + } + + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint + return c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) +} + +func (c *proxyConnection) stop(ctx context.Context) { + if !c.closed.CompareAndSwap(false, true) { + return + } + + c.closer.Close() + if err := c.sendClose(); err != nil { + if err != sfu.ErrNotConnected { + c.logger.Printf("Could not send close message to %s: %s", c, err) + } + c.close() + return + } + + select { + case <-c.closedDone.C: + case <-ctx.Done(): + if err := ctx.Err(); err != nil { + c.logger.Printf("Error waiting for connection to %s get closed: %s", c, err) + c.close() + } + } +} + +func (c *proxyConnection) close() { + c.mu.Lock() + defer c.mu.Unlock() + + c.helloProcessed = false + + if c.conn != nil { + c.conn.Close() + c.conn = nil + c.connectedSince.Store(0) + if c.trackClose.CompareAndSwap(true, false) { + statsConnectedProxyBackendsCurrent.WithLabelValues(string(c.Country())).Dec() + } + } +} + +func (c *proxyConnection) stopCloseIfEmpty() { + c.closeScheduled.Store(false) +} + +func (c *proxyConnection) closeIfEmpty() bool { + c.closeScheduled.Store(true) + + var total int64 + c.publishersLock.RLock() + total += int64(len(c.publishers)) + c.publishersLock.RUnlock() + c.subscribersLock.RLock() + total += int64(len(c.subscribers)) + c.subscribersLock.RUnlock() + if total > 0 { + // Connection will be closed once all clients have disconnected. + c.logger.Printf("Connection to %s is still used by %d clients, defer closing", c, total) + return false + } + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), closeTimeout) + defer cancel() + + c.logger.Printf("All clients disconnected, closing connection to %s", c) + c.stop(ctx) + + statsProxyBackendLoadCurrent.DeleteLabelValues(c.url.String()) + statsProxyUsageCurrent.DeleteLabelValues(c.url.String(), "incoming") + statsProxyUsageCurrent.DeleteLabelValues(c.url.String(), "outgoing") + statsProxyBandwidthCurrent.DeleteLabelValues(c.url.String(), "incoming") + statsProxyBandwidthCurrent.DeleteLabelValues(c.url.String(), "outgoing") + + c.proxy.removeConnection(c) + }() + return true +} + +func (c *proxyConnection) scheduleReconnect() { + if err := c.sendClose(); err != nil && err != sfu.ErrNotConnected { + c.logger.Printf("Could not send close message to %s: %s", c, err) + } + c.close() + + interval := c.reconnectInterval.Load() + // Prevent all servers from reconnecting at the same time in case of an + // interrupted connection to the proxy or a restart. + jitter := rand.Int64N(interval) - (interval / 2) + c.reconnectTimer.Reset(time.Duration(interval + jitter)) + + interval = min(interval*2, int64(maxReconnectInterval)) + c.reconnectInterval.Store(interval) +} + +func (c *proxyConnection) reconnect() { + u, err := c.url.Parse("proxy") + if err != nil { + c.logger.Printf("Could not resolve url to proxy at %s: %s", c, err) + c.scheduleReconnect() + return + } + switch u.Scheme { + case "http": + u.Scheme = "ws" + case "https": + u.Scheme = "wss" + } + + dialer := c.proxy.dialer + if c.ip != nil { + dialer = &websocket.Dialer{ + Proxy: http.ProxyFromEnvironment, + HandshakeTimeout: c.proxy.dialer.HandshakeTimeout, + TLSClientConfig: c.proxy.dialer.TLSClientConfig, + + // Override DNS lookup and connect to custom IP address. + NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + if _, port, err := net.SplitHostPort(addr); err == nil { + addr = net.JoinHostPort(c.ip.String(), port) + } + + return net.Dial(network, addr) + }, + } + } + conn, _, err := dialer.Dial(u.String(), nil) + if err != nil { + c.logger.Printf("Could not connect to %s: %s", c, err) + c.scheduleReconnect() + return + } + + c.logger.Printf("Connected to %s", c) + c.closed.Store(false) + c.connectedSince.Store(time.Now().UnixMicro()) + + c.mu.Lock() + c.helloProcessed = false + c.conn = conn + c.mu.Unlock() + + c.reconnectInterval.Store(int64(initialReconnectInterval)) + c.shutdownScheduled.Store(false) + if err := c.sendHello(); err != nil { + c.logger.Printf("Could not send hello request to %s: %s", c, err) + c.scheduleReconnect() + return + } + + if !c.sendPing() { + return + } + + go c.readPump() +} + +func (c *proxyConnection) waitUntilConnected(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn != nil { + return nil + } + + stop := context.AfterFunc(ctx, func() { + c.connectedCond.Broadcast() + }) + defer stop() + + for !c.helloProcessed { + if err := ctx.Err(); err != nil { + return err + } + + c.connectedCond.Wait() + } + + return nil +} + +func (c *proxyConnection) removePublisher(publisher *proxyPublisher) { + c.proxy.removePublisher(publisher) + + c.publishersLock.Lock() + defer c.publishersLock.Unlock() + + if _, found := c.publishers[publisher.proxyId]; found { + delete(c.publishers, publisher.proxyId) + sfuinternal.StatsPublishersCurrent.WithLabelValues(string(publisher.StreamType())).Dec() + } + delete(c.publisherIds, sfu.GetStreamId(publisher.id, publisher.StreamType())) + + if len(c.publishers) == 0 && (c.closeScheduled.Load() || c.IsTemporary()) { + go c.closeIfEmpty() + } +} + +func (c *proxyConnection) clearPublishers() { + c.publishersLock.Lock() + defer c.publishersLock.Unlock() + + go func(publishers map[string]*proxyPublisher) { + for _, publisher := range publishers { + publisher.NotifyClosed() + } + }(c.publishers) + // Can't use clear(...) here as the map is processed by the goroutine above. + c.publishers = make(map[string]*proxyPublisher) + clear(c.publisherIds) + + if c.closeScheduled.Load() || c.IsTemporary() { + go c.closeIfEmpty() + } +} + +func (c *proxyConnection) removeSubscriber(subscriber *proxySubscriber) { + c.subscribersLock.Lock() + defer c.subscribersLock.Unlock() + + if _, found := c.subscribers[subscriber.proxyId]; found { + delete(c.subscribers, subscriber.proxyId) + sfuinternal.StatsSubscribersCurrent.WithLabelValues(string(subscriber.StreamType())).Dec() + } + + if len(c.subscribers) == 0 && (c.closeScheduled.Load() || c.IsTemporary()) { + go c.closeIfEmpty() + } +} + +func (c *proxyConnection) clearSubscribers() { + c.subscribersLock.Lock() + defer c.subscribersLock.Unlock() + + go func(subscribers map[string]*proxySubscriber) { + for _, subscriber := range subscribers { + subscriber.NotifyClosed() + } + }(c.subscribers) + // Can't use clear(...) here as the map is processed by the goroutine above. + c.subscribers = make(map[string]*proxySubscriber) + + if c.closeScheduled.Load() || c.IsTemporary() { + go c.closeIfEmpty() + } +} + +func (c *proxyConnection) clearCallbacks() { + c.mu.Lock() + defer c.mu.Unlock() + + clear(c.callbacks) + clear(c.deferredCallbacks) +} + +func (c *proxyConnection) getCallback(id string) func(*proxy.ServerMessage) { + c.mu.Lock() + defer c.mu.Unlock() + + callback, found := c.callbacks[id] + if found { + delete(c.callbacks, id) + } + return callback +} + +func (c *proxyConnection) registerDeferredCallback(msgId string, callback mcuProxyCallback) { + if msgId == "" { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.callbacks, msgId) + if c.deferredCallbacks == nil { + c.deferredCallbacks = make(map[string]mcuProxyCallback) + } + c.deferredCallbacks[msgId] = callback +} + +func (c *proxyConnection) getDeferredCallback(msgId string) mcuProxyCallback { + c.mu.Lock() + defer c.mu.Unlock() + + result, found := c.deferredCallbacks[msgId] + if found { + delete(c.deferredCallbacks, msgId) + } + + return result +} + +func (c *proxyConnection) processMessage(msg *proxy.ServerMessage) { + if c.helloMsgId != "" && msg.Id == c.helloMsgId { + c.helloMsgId = "" + switch msg.Type { + case "error": + if msg.Error.Code == "no_such_session" { + c.logger.Printf("Session %s could not be resumed on %s, registering new", c.SessionId(), c) + c.clearPublishers() + c.clearSubscribers() + c.clearCallbacks() + c.sessionId.Store(api.PublicSessionId("")) + if err := c.sendHello(); err != nil { + c.logger.Printf("Could not send hello request to %s: %s", c, err) + c.scheduleReconnect() + } + return + } + + c.logger.Printf("Hello connection to %s failed with %+v, reconnecting", c, msg.Error) + c.scheduleReconnect() + case "hello": + resumed := c.SessionId() == msg.Hello.SessionId + c.sessionId.Store(msg.Hello.SessionId) + var country geoip.Country + if server := msg.Hello.Server; server != nil { + if country = server.Country; country != "" && !geoip.IsValidCountry(country) { + c.logger.Printf("Proxy %s sent invalid country %s in hello response", c, country) + country = "" + } + c.version.Store(server.Version) + if server.Features == nil { + server.Features = []string{} + } + c.features.Store(server.Features) + } else { + c.version.Store("") + c.features.Store([]string{}) + } + c.country.Store(country) + if resumed { + c.logger.Printf("Resumed session %s on %s", c.SessionId(), c) + } else if country != "" { + c.logger.Printf("Received session %s from %s (in %s)", c.SessionId(), c, country) + } else { + c.logger.Printf("Received session %s from %s", c.SessionId(), c) + } + if c.trackClose.CompareAndSwap(false, true) { + statsConnectedProxyBackendsCurrent.WithLabelValues(string(c.Country())).Inc() + } + + c.mu.Lock() + c.helloProcessed = true + c.connectedCond.Broadcast() + c.mu.Unlock() + default: + c.logger.Printf("Received unsupported hello response %+v from %s, reconnecting", msg, c) + c.scheduleReconnect() + } + return + } + + if proxyDebugMessages { + c.logger.Printf("Received from %s: %+v", c, msg) + } + if callback := c.getCallback(msg.Id); callback != nil { + callback(msg) + return + } else if callback := c.getDeferredCallback(msg.Id); callback != nil { + go callback(msg) + return + } + + switch msg.Type { + case "payload": + c.processPayload(msg) + case "event": + c.processEvent(msg) + case "bye": + c.processBye(msg) + default: + c.logger.Printf("Unsupported message received from %s: %+v", c, msg) + } +} + +func (c *proxyConnection) processPayload(msg *proxy.ServerMessage) { + payload := msg.Payload + c.publishersLock.RLock() + publisher, found := c.publishers[payload.ClientId] + c.publishersLock.RUnlock() + if found { + publisher.ProcessPayload(payload) + return + } + + c.subscribersLock.RLock() + subscriber, found := c.subscribers[payload.ClientId] + c.subscribersLock.RUnlock() + if found { + subscriber.ProcessPayload(payload) + return + } + + c.logger.Printf("Received payload for unknown client %+v from %s", payload, c) +} + +func (c *proxyConnection) processEvent(msg *proxy.ServerMessage) { + event := msg.Event + switch event.Type { + case "backend-disconnected": + c.logger.Printf("Upstream backend at %s got disconnected, reset MCU objects", c) + c.clearPublishers() + c.clearSubscribers() + c.clearCallbacks() + // TODO: Should we also reconnect? + return + case "backend-connected": + c.logger.Printf("Upstream backend at %s is connected", c) + return + case "update-load": + if proxyDebugMessages { + c.logger.Printf("Load of %s now at %d (%s)", c, event.Load, event.Bandwidth) + } + c.load.Store(event.Load) + c.bandwidth.Store(event.Bandwidth) + statsProxyBackendLoadCurrent.WithLabelValues(c.url.String()).Set(float64(event.Load)) + if bw := event.Bandwidth; bw != nil { + statsProxyBandwidthCurrent.WithLabelValues(c.url.String(), "incoming").Set(float64(bw.Received.Bytes())) + statsProxyBandwidthCurrent.WithLabelValues(c.url.String(), "outgoing").Set(float64(bw.Sent.Bytes())) + if bw.Incoming != nil { + statsProxyUsageCurrent.WithLabelValues(c.url.String(), "incoming").Set(*bw.Incoming) + } else { + statsProxyUsageCurrent.WithLabelValues(c.url.String(), "incoming").Set(0) + } + if bw.Outgoing != nil { + statsProxyUsageCurrent.WithLabelValues(c.url.String(), "outgoing").Set(*bw.Outgoing) + } else { + statsProxyUsageCurrent.WithLabelValues(c.url.String(), "outgoing").Set(0) + } + } + return + case "shutdown-scheduled": + c.logger.Printf("Proxy %s is scheduled to shutdown", c) + c.shutdownScheduled.Store(true) + return + } + + if proxyDebugMessages { + c.logger.Printf("Process event from %s: %+v", c, event) + } + c.publishersLock.RLock() + publisher, found := c.publishers[event.ClientId] + c.publishersLock.RUnlock() + if found { + publisher.ProcessEvent(event) + return + } + + c.subscribersLock.RLock() + subscriber, found := c.subscribers[event.ClientId] + c.subscribersLock.RUnlock() + if found { + subscriber.ProcessEvent(event) + return + } + + c.logger.Printf("Received event for unknown client %+v from %s", event, c) +} + +func (c *proxyConnection) processBye(msg *proxy.ServerMessage) { + bye := msg.Bye + switch bye.Reason { + case "session_resumed": + c.logger.Printf("Session %s on %s was resumed by other client, resetting", c.SessionId(), c) + case "session_expired": + c.logger.Printf("Session %s expired on %s, resetting", c.SessionId(), c) + case "session_closed": + c.logger.Printf("Session %s was closed on %s, resetting", c.SessionId(), c) + default: + c.logger.Printf("Received bye with unsupported reason from %s %+v", c, bye) + } + c.sessionId.Store(api.PublicSessionId("")) +} + +func (c *proxyConnection) sendHello() error { + c.helloMsgId = strconv.FormatInt(c.msgId.Add(1), 10) + msg := &proxy.ClientMessage{ + Id: c.helloMsgId, + Type: "hello", + Hello: &proxy.HelloClientMessage{ + Version: "1.0", + }, + } + if sessionId := c.SessionId(); sessionId != "" { + msg.Hello.ResumeId = sessionId + } else if c.connectToken != "" { + msg.Hello.Token = c.connectToken + } else { + tokenString, err := c.proxy.CreateToken("") + if err != nil { + return err + } + + msg.Hello.Token = tokenString + } + return c.sendMessage(msg) +} + +func (c *proxyConnection) sendMessage(msg *proxy.ClientMessage) error { + c.mu.Lock() + defer c.mu.Unlock() + + return c.sendMessageLocked(msg) +} + +// +checklocks:c.mu +func (c *proxyConnection) sendMessageLocked(msg *proxy.ClientMessage) error { + if proxyDebugMessages { + c.logger.Printf("Send message to %s: %+v", c, msg) + } + if c.conn == nil { + return sfu.ErrNotConnected + } + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint + return c.conn.WriteJSON(msg) +} + +func (c *proxyConnection) performAsyncRequest(ctx context.Context, msg *proxy.ClientMessage, callback func(err error, response *proxy.ServerMessage)) string { + msgId := strconv.FormatInt(c.msgId.Add(1), 10) + msg.Id = msgId + + c.mu.Lock() + defer c.mu.Unlock() + c.callbacks[msgId] = func(msg *proxy.ServerMessage) { + callback(nil, msg) + } + if err := c.sendMessageLocked(msg); err != nil { + delete(c.callbacks, msgId) + go callback(err, nil) + return "" + } + + context.AfterFunc(ctx, func() { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.callbacks, msgId) + }) + + return msgId +} + +func (c *proxyConnection) performSyncRequest(ctx context.Context, msg *proxy.ClientMessage) (*proxy.ServerMessage, string, error) { + if err := ctx.Err(); err != nil { + return nil, "", err + } + + errChan := make(chan error, 1) + responseChan := make(chan *proxy.ServerMessage, 1) + msgId := c.performAsyncRequest(ctx, msg, func(err error, response *proxy.ServerMessage) { + if err != nil { + errChan <- err + } else { + responseChan <- response + } + }) + + select { + case <-ctx.Done(): + return nil, msgId, ctx.Err() + case err := <-errChan: + return nil, msgId, err + case response := <-responseChan: + return response, msgId, nil + } +} + +func (c *proxyConnection) deferredDeletePublisher(id api.PublicSessionId, streamType sfu.StreamType, response *proxy.ServerMessage) { + if response.Type == "error" { + c.logger.Printf("Publisher for %s was not created at %s: %s", id, c, response.Error) + return + } + + proxyId := response.Command.Id + c.logger.Printf("Created unused %s publisher %s on %s for %s", streamType, proxyId, c, id) + msg := &proxy.ClientMessage{ + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "delete-publisher", + ClientId: proxyId, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), c.proxy.settings.Timeout()) + defer cancel() + + if response, _, err := c.performSyncRequest(ctx, msg); err != nil { + c.logger.Printf("Could not delete publisher %s at %s: %s", proxyId, c, err) + return + } else if response.Type == "error" { + c.logger.Printf("Could not delete publisher %s at %s: %s", proxyId, c, response.Error) + return + } + + c.logger.Printf("Deleted publisher %s at %s", proxyId, c) +} + +func (c *proxyConnection) newPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings) (sfu.Publisher, error) { + msg := &proxy.ClientMessage{ + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "create-publisher", + Sid: sid, + StreamType: streamType, + PublisherSettings: &settings, + // Include for older version of the signaling proxy. + Bitrate: settings.Bitrate, + MediaTypes: settings.MediaTypes, + }, + } + + response, msgId, err := c.performSyncRequest(ctx, msg) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + c.registerDeferredCallback(msgId, func(response *proxy.ServerMessage) { + c.deferredDeletePublisher(id, streamType, response) + }) + } + return nil, err + } else if response.Type == "error" { + return nil, fmt.Errorf("error creating %s publisher for %s on %s: %+v", streamType, id, c, response.Error) + } + + proxyId := response.Command.Id + c.logger.Printf("Created %s publisher %s on %s for %s", streamType, proxyId, c, id) + publisher := newProxyPublisher(c.logger, id, sid, streamType, response.Command.Bitrate, settings, proxyId, c, listener) + c.publishersLock.Lock() + c.publishers[proxyId] = publisher + c.publisherIds[sfu.GetStreamId(id, streamType)] = api.PublicSessionId(proxyId) + c.publishersLock.Unlock() + sfuinternal.StatsPublishersCurrent.WithLabelValues(string(streamType)).Inc() + sfuinternal.StatsPublishersTotal.WithLabelValues(string(streamType)).Inc() + return publisher, nil +} + +func (c *proxyConnection) deferredDeleteSubscriber(publisherSessionId api.PublicSessionId, streamType sfu.StreamType, publisherConn *proxyConnection, response *proxy.ServerMessage) { + if response.Type == "error" { + c.logger.Printf("Subscriber for %s was not created at %s: %s", publisherSessionId, c, response.Error) + return + } + + proxyId := response.Command.Id + c.logger.Printf("Created unused %s subscriber %s on %s for %s", streamType, proxyId, c, publisherSessionId) + + msg := &proxy.ClientMessage{ + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "delete-subscriber", + ClientId: proxyId, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), c.proxy.settings.Timeout()) + defer cancel() + + if response, _, err := c.performSyncRequest(ctx, msg); err != nil { + if publisherConn != nil { + c.logger.Printf("Could not delete remote subscriber %s at %s (forwarded to %s): %s", proxyId, c, publisherConn, err) + } else { + c.logger.Printf("Could not delete subscriber %s at %s: %s", proxyId, c, err) + } + return + } else if response.Type == "error" { + if publisherConn != nil { + c.logger.Printf("Could not delete remote subscriber %s at %s (forwarded to %s): %s", proxyId, c, publisherConn, response.Error) + } else { + c.logger.Printf("Could not delete subscriber %s at %s: %s", proxyId, c, response.Error) + } + return + } + + if publisherConn != nil { + c.logger.Printf("Deleted remote subscriber %s at %s (forwarded to %s)", proxyId, c, publisherConn) + } else { + c.logger.Printf("Deleted subscriber %s at %s", proxyId, c) + } +} + +func (c *proxyConnection) newSubscriber(ctx context.Context, listener sfu.Listener, publisherId api.PublicSessionId, publisherSessionId api.PublicSessionId, streamType sfu.StreamType) (sfu.Subscriber, error) { + msg := &proxy.ClientMessage{ + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "create-subscriber", + StreamType: streamType, + PublisherId: publisherId, + }, + } + + response, msgId, err := c.performSyncRequest(ctx, msg) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + c.registerDeferredCallback(msgId, func(response *proxy.ServerMessage) { + c.deferredDeleteSubscriber(publisherSessionId, streamType, nil, response) + }) + } + return nil, err + } else if response.Type == "error" { + return nil, fmt.Errorf("error creating %s subscriber for %s on %s: %+v", streamType, publisherSessionId, c, response.Error) + } + + proxyId := response.Command.Id + c.logger.Printf("Created %s subscriber %s on %s for %s", streamType, proxyId, c, publisherSessionId) + subscriber := newProxySubscriber(c.logger, publisherSessionId, response.Command.Sid, streamType, response.Command.Bitrate, proxyId, c, listener, nil) + c.subscribersLock.Lock() + c.subscribers[proxyId] = subscriber + c.subscribersLock.Unlock() + sfuinternal.StatsSubscribersCurrent.WithLabelValues(string(streamType)).Inc() + sfuinternal.StatsSubscribersTotal.WithLabelValues(string(streamType)).Inc() + return subscriber, nil +} + +func (c *proxyConnection) newRemoteSubscriber(ctx context.Context, listener sfu.Listener, publisherId api.PublicSessionId, publisherSessionId api.PublicSessionId, streamType sfu.StreamType, publisherConn *proxyConnection, remoteToken string) (sfu.Subscriber, error) { + if c == publisherConn { + return c.newSubscriber(ctx, listener, publisherId, publisherSessionId, streamType) + } + + if remoteToken == "" { + var err error + if remoteToken, err = c.proxy.CreateToken(string(publisherId)); err != nil { + return nil, err + } + } + + msg := &proxy.ClientMessage{ + Type: "command", + Command: &proxy.CommandClientMessage{ + Type: "create-subscriber", + StreamType: streamType, + PublisherId: publisherId, + + RemoteUrl: publisherConn.rawUrl, + RemoteToken: remoteToken, + }, + } + + response, msgId, err := c.performSyncRequest(ctx, msg) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + c.registerDeferredCallback(msgId, func(response *proxy.ServerMessage) { + c.deferredDeleteSubscriber(publisherSessionId, streamType, publisherConn, response) + }) + } + return nil, err + } else if response.Type == "error" { + return nil, fmt.Errorf("error creating remote %s subscriber for %s on %s (forwarded to %s): %+v", streamType, publisherSessionId, c, publisherConn, response.Error) + } + + proxyId := response.Command.Id + c.logger.Printf("Created remote %s subscriber %s on %s for %s (forwarded to %s)", streamType, proxyId, c, publisherSessionId, publisherConn) + subscriber := newProxySubscriber(c.logger, publisherSessionId, response.Command.Sid, streamType, response.Command.Bitrate, proxyId, c, listener, publisherConn) + c.subscribersLock.Lock() + c.subscribers[proxyId] = subscriber + c.subscribersLock.Unlock() + sfuinternal.StatsSubscribersCurrent.WithLabelValues(string(streamType)).Inc() + sfuinternal.StatsSubscribersTotal.WithLabelValues(string(streamType)).Inc() + return subscriber, nil +} + +type proxySettings struct { + sfuinternal.CommonSettings +} + +func newProxySettings(ctx context.Context, config *goconf.ConfigFile) (sfu.Settings, error) { + settings := &proxySettings{ + CommonSettings: sfuinternal.CommonSettings{ + Logger: log.LoggerFromContext(ctx), + }, + } + if err := settings.load(config); err != nil { + return nil, err + } + + return settings, nil +} + +func (s *proxySettings) load(config *goconf.ConfigFile) error { + if err := s.Load(config); err != nil { + return err + } + + proxyTimeoutSeconds, _ := config.GetInt("mcu", "proxytimeout") + if proxyTimeoutSeconds <= 0 { + proxyTimeoutSeconds = defaultProxyTimeoutSeconds + } + proxyTimeout := time.Duration(proxyTimeoutSeconds) * time.Second + s.Logger.Printf("Using a timeout of %s for proxy requests", proxyTimeout) + s.SetTimeout(proxyTimeout) + return nil +} + +func (s *proxySettings) Reload(config *goconf.ConfigFile) { + if err := s.load(config); err != nil { + s.Logger.Printf("Error reloading proxy settings: %s", err) + } +} + +type proxySFU struct { + logger log.Logger + urlType string + tokenId string + tokenKey *rsa.PrivateKey + config Config + + dialer *websocket.Dialer + connectionsMu sync.RWMutex + // +checklocks:connectionsMu + connections []*proxyConnection + // +checklocks:connectionsMu + connectionsMap map[string][]*proxyConnection + connRequests atomic.Int64 + nextSort atomic.Int64 + + settings sfu.Settings + + mu sync.RWMutex + // +checklocks:mu + publishers map[sfu.StreamId]*proxyConnection + publishersCond sync.Cond + + continentsMap atomic.Value + + rpcClients *grpc.Clients +} + +func NewProxySFU(ctx context.Context, config *goconf.ConfigFile, etcdClient etcd.Client, rpcClients *grpc.Clients, dnsMonitor *dns.Monitor) (sfu.SFU, error) { + logger := log.LoggerFromContext(ctx) + urlType, _ := config.GetString("mcu", "urltype") + if urlType == "" { + urlType = proxyUrlTypeStatic + } + + tokenId, _ := config.GetString("mcu", "token_id") + if tokenId == "" { + return nil, errors.New("no token id configured") + } + tokenKeyFilename, _ := config.GetString("mcu", "token_key") + if tokenKeyFilename == "" { + return nil, errors.New("no token key configured") + } + tokenKeyData, err := os.ReadFile(tokenKeyFilename) + if err != nil { + return nil, fmt.Errorf("could not read private key from %s: %s", tokenKeyFilename, err) + } + tokenKey, err := jwt.ParseRSAPrivateKeyFromPEM(tokenKeyData) + if err != nil { + return nil, fmt.Errorf("could not parse private key from %s: %s", tokenKeyFilename, err) + } + + settings, err := newProxySettings(ctx, config) + if err != nil { + return nil, err + } + + mcu := &proxySFU{ + logger: logger, + urlType: urlType, + tokenId: tokenId, + tokenKey: tokenKey, + + dialer: &websocket.Dialer{ + Proxy: http.ProxyFromEnvironment, + HandshakeTimeout: settings.Timeout(), + }, + connectionsMap: make(map[string][]*proxyConnection), + settings: settings, + + publishers: make(map[sfu.StreamId]*proxyConnection), + + rpcClients: rpcClients, + } + mcu.publishersCond.L = &mcu.mu + + if err := mcu.loadContinentsMap(config); err != nil { + return nil, err + } + + skipverify, _ := config.GetBool("mcu", "skipverify") + if skipverify { + logger.Println("WARNING: MCU verification is disabled!") + mcu.dialer.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: skipverify, + } + } + + switch urlType { + case proxyUrlTypeStatic: + mcu.config, err = NewConfigStatic(logger, config, mcu, dnsMonitor) + case proxyUrlTypeEtcd: + mcu.config, err = NewConfigEtcd(logger, config, etcdClient, mcu) + default: + err = fmt.Errorf("unsupported proxy URL type %s", urlType) + } + if err != nil { + return nil, err + } + + return mcu, nil +} + +func (m *proxySFU) GetBandwidthLimits() (api.Bandwidth, api.Bandwidth) { + return m.settings.MaxStreamBitrate(), m.settings.MaxScreenBitrate() +} + +func (m *proxySFU) loadContinentsMap(cfg *goconf.ConfigFile) error { + options, err := config.GetStringOptions(cfg, "continent-overrides", false) + if err != nil { + return err + } + + if len(options) == 0 { + m.setContinentsMap(nil) + return nil + } + + continentsMap := make(ContinentsMap) + for option, value := range options { + option := geoip.Continent(strings.ToUpper(strings.TrimSpace(option))) + if !geoip.IsValidContinent(option) { + m.logger.Printf("Ignore unknown continent %s", option) + continue + } + + var values []geoip.Continent + for v := range internal.SplitEntries(value, ",") { + v := geoip.Continent(strings.ToUpper(v)) + if !geoip.IsValidContinent(v) { + m.logger.Printf("Ignore unknown continent %s for override %s", v, option) + continue + } + values = append(values, v) + } + if len(values) == 0 { + m.logger.Printf("No valid values found for continent override %s, ignoring", option) + continue + } + + continentsMap[option] = values + m.logger.Printf("Mapping users on continent %s to %s", option, values) + } + + m.setContinentsMap(continentsMap) + return nil +} + +func (m *proxySFU) Start(ctx context.Context) error { + return m.config.Start() +} + +func (m *proxySFU) Stop() { + m.connectionsMu.RLock() + defer m.connectionsMu.RUnlock() + + ctx, cancel := context.WithTimeout(context.Background(), closeTimeout) + defer cancel() + for _, c := range m.connections { + c.stop(ctx) + } + + m.config.Stop() +} + +func (m *proxySFU) CreateToken(subject string) (string, error) { + claims := &proxy.TokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: m.tokenId, + Subject: subject, + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tokenString, err := token.SignedString(m.tokenKey) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func (m *proxySFU) getConnections() []*proxyConnection { + m.connectionsMu.RLock() + defer m.connectionsMu.RUnlock() + + return m.connections +} + +func (m *proxySFU) ConnectionsCount() int { + m.connectionsMu.RLock() + defer m.connectionsMu.RUnlock() + + return len(m.connections) +} + +func (m *proxySFU) hasConnections() bool { + m.connectionsMu.RLock() + defer m.connectionsMu.RUnlock() + + return slices.ContainsFunc(m.connections, func(conn *proxyConnection) bool { + return conn.IsConnected() + }) +} + +func (m *proxySFU) WaitForDisconnected(ctx context.Context) error { + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + + for m.hasConnections() { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } + return nil +} + +func (m *proxySFU) WaitForConnections(ctx context.Context) error { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + + for !m.hasConnections() { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } + return nil +} + +func (m *proxySFU) WaitForConnectionsEstablished(ctx context.Context, waitMap map[string]bool) error { + m.connectionsMu.RLock() + defer m.connectionsMu.RUnlock() + + for len(waitMap) > 0 { + if err := ctx.Err(); err != nil { + return err + } + + for u := range waitMap { + for _, c := range m.connections { + if c.rawUrl == u && c.IsConnected() && c.SessionId() != "" { + delete(waitMap, u) + break + } + } + } + + m.connectionsMu.RUnlock() + time.Sleep(time.Millisecond) + m.connectionsMu.RLock() + } + + return nil +} + +func (m *proxySFU) AddConnection(ignoreErrors bool, url string, ips ...net.IP) error { + m.connectionsMu.Lock() + defer m.connectionsMu.Unlock() + + var conns []*proxyConnection + if len(ips) == 0 { + conn, err := newProxyConnection(m, url, nil, "") + if err != nil { + if ignoreErrors { + m.logger.Printf("Could not create proxy connection to %s: %s", url, err) + return nil + } + + return err + } + + conns = append(conns, conn) + } else { + for _, ip := range ips { + conn, err := newProxyConnection(m, url, ip, "") + if err != nil { + if ignoreErrors { + m.logger.Printf("Could not create proxy connection to %s (%s): %s", url, ip, err) + continue + } + + return err + } + + conns = append(conns, conn) + } + } + + for _, conn := range conns { + m.logger.Printf("Adding new connection to %s", conn) + conn.start() + + m.connections = append(m.connections, conn) + if existing, found := m.connectionsMap[url]; found { + m.connectionsMap[url] = append(existing, conn) + } else { + m.connectionsMap[url] = []*proxyConnection{conn} + } + } + + m.nextSort.Store(0) + return nil +} + +func (m *proxySFU) iterateConnections(url string, ips []net.IP) iter.Seq[*proxyConnection] { + return func(yield func(*proxyConnection) bool) { + m.connectionsMu.Lock() + defer m.connectionsMu.Unlock() + + conns, found := m.connectionsMap[url] + if !found { + return + } + + if len(ips) == 0 { + for _, conn := range conns { + if !yield(conn) { + return + } + } + } else { + for _, conn := range conns { + if slices.ContainsFunc(ips, func(i net.IP) bool { + return i.Equal(conn.ip) + }) { + if !yield(conn) { + return + } + } + } + } + } +} + +func (m *proxySFU) RemoveConnection(url string, ips ...net.IP) { + for conn := range m.iterateConnections(url, ips) { + m.logger.Printf("Removing connection to %s", conn) + conn.closeIfEmpty() + } +} + +func (m *proxySFU) KeepConnection(url string, ips ...net.IP) { + for conn := range m.iterateConnections(url, ips) { + conn.stopCloseIfEmpty() + conn.clearTemporary() + } +} + +func (m *proxySFU) Reload(config *goconf.ConfigFile) { + m.settings.Reload(config) + + if m.settings.Timeout() != m.dialer.HandshakeTimeout { + m.dialer.HandshakeTimeout = m.settings.Timeout() + } + + if err := m.loadContinentsMap(config); err != nil { + m.logger.Printf("Error loading continents map: %s", err) + } + + if err := m.config.Reload(config); err != nil { + m.logger.Printf("could not reload proxy configuration: %s", err) + } +} + +func (m *proxySFU) removeConnection(c *proxyConnection) { + m.connectionsMu.Lock() + defer m.connectionsMu.Unlock() + + if conns, found := m.connectionsMap[c.rawUrl]; found { + idx := slices.Index(conns, c) + if idx == -1 { + return + } + + conns = slices.Delete(conns, idx, idx+1) + if len(conns) == 0 { + delete(m.connectionsMap, c.rawUrl) + } else { + m.connectionsMap[c.rawUrl] = conns + } + + m.connections = nil + for _, conns := range m.connectionsMap { + m.connections = append(m.connections, conns...) + } + + m.nextSort.Store(0) + } +} + +func (m *proxySFU) SetOnConnected(f func()) { + // Not supported. +} + +func (m *proxySFU) SetOnDisconnected(f func()) { + // Not supported. +} + +type mcuProxyStats struct { + Publishers int64 `json:"publishers"` + Clients int64 `json:"clients"` + Details []*proxyConnectionStats `json:"details"` +} + +func (m *proxySFU) GetStats() any { + result := &mcuProxyStats{} + + m.connectionsMu.RLock() + defer m.connectionsMu.RUnlock() + + for _, conn := range m.connections { + stats := conn.GetStats() + result.Publishers += stats.Publishers + result.Clients += stats.Clients + result.Details = append(result.Details, stats) + } + return result +} + +func (m *proxySFU) GetServerInfoSfu() *talk.BackendServerInfoSfu { + m.connectionsMu.RLock() + defer m.connectionsMu.RUnlock() + + sfu := &talk.BackendServerInfoSfu{ + Mode: talk.SfuModeProxy, + } + for _, c := range m.connections { + proxy := talk.BackendServerInfoSfuProxy{ + Url: c.rawUrl, + + Temporary: c.IsTemporary(), + } + if len(c.ip) > 0 { + proxy.IP = c.ip.String() + } + if c.IsConnected() { + proxy.Connected = true + proxy.Shutdown = internal.MakePtr(c.IsShutdownScheduled()) + if since := c.connectedSince.Load(); since != 0 { + t := time.UnixMicro(since) + proxy.Uptime = &t + } + proxy.Version = c.Version() + proxy.Features = c.Features() + proxy.Country = c.Country() + proxy.Load = internal.MakePtr(c.Load()) + if bw := c.Bandwidth(); bw != nil { + proxy.Bandwidth = &talk.BackendServerInfoSfuProxyBandwidth{ + Incoming: bw.Incoming, + Outgoing: bw.Outgoing, + Received: bw.Received, + Sent: bw.Sent, + } + } + } + sfu.Proxies = append(sfu.Proxies, proxy) + } + slices.SortFunc(sfu.Proxies, func(a, b talk.BackendServerInfoSfuProxy) int { + c := strings.Compare(a.Url, b.Url) + if c == 0 { + c = strings.Compare(a.IP, b.IP) + } + return c + }) + return sfu +} + +func (m *proxySFU) getContinentsMap() ContinentsMap { + continentsMap := m.continentsMap.Load() + if continentsMap == nil { + return nil + } + return continentsMap.(ContinentsMap) +} + +func (m *proxySFU) setContinentsMap(continentsMap ContinentsMap) { + if continentsMap == nil { + continentsMap = make(ContinentsMap) + } + m.continentsMap.Store(continentsMap) +} + +type mcuProxyConnectionsList []*proxyConnection + +func ContinentsOverlap(a, b []geoip.Continent) bool { + if len(a) == 0 || len(b) == 0 { + return false + } + + for _, checkA := range a { + if slices.Contains(b, checkA) { + return true + } + } + return false +} + +func sortConnectionsForCountry(connections []*proxyConnection, country geoip.Country, continentMap ContinentsMap) []*proxyConnection { + // Move connections in the same country to the start of the list. + sorted := make(mcuProxyConnectionsList, 0, len(connections)) + unprocessed := make(mcuProxyConnectionsList, 0, len(connections)) + for _, conn := range connections { + if country == conn.Country() { + sorted = append(sorted, conn) + } else { + unprocessed = append(unprocessed, conn) + } + } + if continents, found := geoip.ContinentMap[country]; found && len(unprocessed) > 1 { + remaining := make(mcuProxyConnectionsList, 0, len(unprocessed)) + // Map continents to other continents (e.g. use Europe for Africa). + for _, continent := range continents { + if toAdd, found := continentMap[continent]; found { + continents = append(continents, toAdd...) + } + } + + // Next up are connections on the same or mapped continent. + for _, conn := range unprocessed { + connCountry := conn.Country() + if geoip.IsValidCountry(connCountry) { + connContinents := geoip.ContinentMap[connCountry] + if ContinentsOverlap(continents, connContinents) { + sorted = append(sorted, conn) + } else { + remaining = append(remaining, conn) + } + } else { + remaining = append(remaining, conn) + } + } + unprocessed = remaining + } + // Add all other connections by load. + sorted = append(sorted, unprocessed...) + return sorted +} + +func (m *proxySFU) getSortedConnections(initiator sfu.Initiator) []*proxyConnection { + m.connectionsMu.RLock() + connections := m.connections + m.connectionsMu.RUnlock() + if len(connections) < 2 { + return connections + } + + // Connections are re-sorted every requests or + // every . + now := time.Now().UnixNano() + if m.connRequests.Add(1)%connectionSortRequests == 0 || m.nextSort.Load() <= now { + m.nextSort.Store(now + int64(connectionSortInterval)) + + sorted := slices.Clone(connections) + slices.SortFunc(sorted, func(a, b *proxyConnection) int { + return int(a.Load() - b.Load()) + }) + + m.connectionsMu.Lock() + m.connections = sorted + m.connectionsMu.Unlock() + connections = sorted + } + + if initiator != nil { + if country := initiator.Country(); geoip.IsValidCountry(country) { + connections = sortConnectionsForCountry(connections, country, m.getContinentsMap()) + } + } + return connections +} + +func (m *proxySFU) removePublisher(publisher *proxyPublisher) { + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.publishers, sfu.GetStreamId(publisher.id, publisher.StreamType())) +} + +func (m *proxySFU) createPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings, initiator sfu.Initiator, connections []*proxyConnection, isAllowed func(c *proxyConnection) bool) sfu.Publisher { + var maxBitrate api.Bandwidth + if streamType == sfu.StreamTypeScreen { + maxBitrate = m.settings.MaxScreenBitrate() + } else { + maxBitrate = m.settings.MaxStreamBitrate() + } + + publisherSettings := settings + if publisherSettings.Bitrate <= 0 { + publisherSettings.Bitrate = maxBitrate + } else { + publisherSettings.Bitrate = min(publisherSettings.Bitrate, maxBitrate) + } + + for _, conn := range connections { + if !isAllowed(conn) || conn.IsShutdownScheduled() || conn.IsTemporary() { + continue + } + + subctx, cancel := context.WithTimeout(ctx, m.settings.Timeout()) + defer cancel() + + publisher, err := conn.newPublisher(subctx, listener, id, sid, streamType, publisherSettings) + if err != nil { + m.logger.Printf("Could not create %s publisher for %s on %s: %s", streamType, id, conn, err) + continue + } + + m.mu.Lock() + defer m.mu.Unlock() + m.publishers[sfu.GetStreamId(id, streamType)] = conn + m.publishersCond.Broadcast() + return publisher + } + + return nil +} + +func (m *proxySFU) NewPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings, initiator sfu.Initiator) (sfu.Publisher, error) { + connections := m.getSortedConnections(initiator) + publisher := m.createPublisher(ctx, listener, id, sid, streamType, settings, initiator, connections, func(c *proxyConnection) bool { + bw := c.Bandwidth() + return bw == nil || bw.AllowIncoming() + }) + if publisher == nil { + // No proxy has available bandwidth, select one with the lowest currently used bandwidth. + connections2 := make([]*proxyConnection, 0, len(connections)) + for _, c := range connections { + if c.Bandwidth() != nil { + connections2 = append(connections2, c) + } + } + slices.SortFunc(connections2, func(a *proxyConnection, b *proxyConnection) int { + var incoming_a *float64 + if bw := a.Bandwidth(); bw != nil { + incoming_a = bw.Incoming + } + + var incoming_b *float64 + if bw := b.Bandwidth(); bw != nil { + incoming_b = bw.Incoming + } + + switch { + case incoming_a == nil && incoming_b == nil: + return 0 + case incoming_a == nil && incoming_b != nil: + return -1 + case incoming_a != nil && incoming_b == nil: + return -1 + case *incoming_a < *incoming_b: + return -1 + case *incoming_a > *incoming_b: + return 1 + default: + return 0 + } + }) + publisher = m.createPublisher(ctx, listener, id, sid, streamType, settings, initiator, connections2, func(c *proxyConnection) bool { + return true + }) + } + + if publisher == nil { + statsProxyNobackendAvailableTotal.WithLabelValues(string(streamType)).Inc() + return nil, errors.New("no MCU connection available") + } + + return publisher, nil +} + +func (m *proxySFU) getPublisherConnection(publisher api.PublicSessionId, streamType sfu.StreamType) *proxyConnection { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.publishers[sfu.GetStreamId(publisher, streamType)] +} + +func (m *proxySFU) waitForPublisherConnection(ctx context.Context, publisher api.PublicSessionId, streamType sfu.StreamType) *proxyConnection { + m.mu.Lock() + defer m.mu.Unlock() + + conn := m.publishers[sfu.GetStreamId(publisher, streamType)] + if conn != nil { + // Publisher was created while waiting for lock. + return conn + } + + stop := context.AfterFunc(ctx, func() { + m.publishersCond.Broadcast() + }) + defer stop() + + sfuinternal.StatsWaitingForPublisherTotal.WithLabelValues(string(streamType)).Inc() + for conn == nil { + if err := ctx.Err(); err != nil { + return nil + } + + m.publishersCond.Wait() + conn = m.publishers[sfu.GetStreamId(publisher, streamType)] + } + return conn +} + +type proxyPublisherInfo struct { + id api.PublicSessionId + conn *proxyConnection + token string + err error +} + +func (m *proxySFU) createSubscriber(ctx context.Context, listener sfu.Listener, info *proxyPublisherInfo, publisher api.PublicSessionId, streamType sfu.StreamType, connections []*proxyConnection, isAllowed func(c *proxyConnection) bool) sfu.Subscriber { + for _, conn := range connections { + if !isAllowed(conn) || conn.IsShutdownScheduled() || conn.IsTemporary() { + continue + } + + subctx, cancel := context.WithTimeout(ctx, m.settings.Timeout()) + defer cancel() + + var subscriber sfu.Subscriber + var err error + if conn == info.conn { + subscriber, err = conn.newSubscriber(subctx, listener, info.id, publisher, streamType) + } else { + subscriber, err = conn.newRemoteSubscriber(subctx, listener, info.id, publisher, streamType, info.conn, info.token) + } + if err != nil { + m.logger.Printf("Could not create subscriber for %s publisher %s on %s: %s", streamType, publisher, conn, err) + continue + } + + return subscriber + } + + return nil +} + +func (m *proxySFU) NewSubscriber(ctx context.Context, listener sfu.Listener, publisher api.PublicSessionId, streamType sfu.StreamType, initiator sfu.Initiator) (sfu.Subscriber, error) { + var publisherInfo *proxyPublisherInfo + if conn := m.getPublisherConnection(publisher, streamType); conn != nil { + // Fast common path: publisher is available locally. + conn.publishersLock.Lock() + id, found := conn.publisherIds[sfu.GetStreamId(publisher, streamType)] + conn.publishersLock.Unlock() + if !found { + return nil, fmt.Errorf("unknown publisher %s", publisher) + } + + publisherInfo = &proxyPublisherInfo{ + id: id, + conn: conn, + } + } else { + m.logger.Printf("No %s publisher %s found yet, deferring", streamType, publisher) + ch := make(chan *proxyPublisherInfo, 1) + getctx, cancel := context.WithCancel(ctx) + defer cancel() + + var wg sync.WaitGroup + + // Wait for publisher to be created locally. + wg.Go(func() { + if conn := m.waitForPublisherConnection(getctx, publisher, streamType); conn != nil { + cancel() // Cancel pending RPC calls. + + conn.publishersLock.Lock() + id, found := conn.publisherIds[sfu.GetStreamId(publisher, streamType)] + conn.publishersLock.Unlock() + if !found { + ch <- &proxyPublisherInfo{ + err: fmt.Errorf("unknown id for local %s publisher %s", streamType, publisher), + } + return + } + + ch <- &proxyPublisherInfo{ + id: id, + conn: conn, + } + } + }) + + // Wait for publisher to be created on one of the other servers in the cluster. + if clients := m.rpcClients.GetClients(); len(clients) > 0 { + for _, client := range clients { + wg.Go(func() { + id, url, ip, connectToken, publisherToken, err := client.GetPublisherId(getctx, publisher, streamType) + if errors.Is(err, context.Canceled) { + return + } else if err != nil { + m.logger.Printf("Error getting %s publisher id %s from %s: %s", streamType, publisher, client.Target(), err) + return + } else if id == "" { + // Publisher not found on other server + return + } + + cancel() // Cancel pending RPC calls. + m.logger.Printf("Found publisher id %s through %s on proxy %s", id, client.Target(), url) + + m.connectionsMu.RLock() + connections := m.connections + m.connectionsMu.RUnlock() + var publisherConn *proxyConnection + for _, conn := range connections { + if conn.rawUrl != url || !ip.Equal(conn.ip) { + continue + } + + // Simple case, signaling server has a connection to the same endpoint + publisherConn = conn + break + } + + if publisherConn == nil { + publisherConn, err = newProxyConnection(m, url, ip, connectToken) + if err != nil { + m.logger.Printf("Could not create temporary connection to %s for %s publisher %s: %s", url, streamType, publisher, err) + return + } + + publisherConn.setTemporary() + publisherConn.start() + if err := publisherConn.waitUntilConnected(ctx); err != nil { + m.logger.Printf("Could not establish new connection to %s: %s", publisherConn, err) + publisherConn.closeIfEmpty() + return + } + + m.connectionsMu.Lock() + m.connections = append(m.connections, publisherConn) + conns, found := m.connectionsMap[url] + if found { + conns = append(conns, publisherConn) + } else { + conns = []*proxyConnection{publisherConn} + } + m.connectionsMap[url] = conns + m.connectionsMu.Unlock() + } + + ch <- &proxyPublisherInfo{ + id: id, + conn: publisherConn, + token: publisherToken, + } + }) + } + } + + wg.Wait() + select { + case ch <- &proxyPublisherInfo{ + err: fmt.Errorf("no %s publisher %s found", streamType, publisher), + }: + default: + } + + select { + case info := <-ch: + publisherInfo = info + case <-ctx.Done(): + return nil, fmt.Errorf("no %s publisher %s found", streamType, publisher) + } + } + + if publisherInfo.err != nil { + return nil, publisherInfo.err + } + + bw := publisherInfo.conn.Bandwidth() + allowOutgoing := bw == nil || bw.AllowOutgoing() + if !allowOutgoing || !publisherInfo.conn.IsSameCountry(initiator) { + connections := m.getSortedConnections(initiator) + if !allowOutgoing || len(connections) > 0 && !connections[0].IsSameCountry(publisherInfo.conn) { + // Connect to remote publisher through "closer" gateway. + subscriber := m.createSubscriber(ctx, listener, publisherInfo, publisher, streamType, connections, func(c *proxyConnection) bool { + bw := c.Bandwidth() + return bw == nil || bw.AllowOutgoing() + }) + if subscriber == nil { + connections2 := make([]*proxyConnection, 0, len(connections)) + for _, c := range connections { + if c.Bandwidth() != nil { + connections2 = append(connections2, c) + } + } + slices.SortFunc(connections2, func(a *proxyConnection, b *proxyConnection) int { + var outgoing_a *float64 + if bw := a.Bandwidth(); bw != nil { + outgoing_a = bw.Outgoing + } + + var outgoing_b *float64 + if bw := b.Bandwidth(); bw != nil { + outgoing_b = bw.Outgoing + } + + switch { + case outgoing_a == nil && outgoing_b == nil: + return 0 + case outgoing_a == nil && outgoing_b != nil: + return -1 + case outgoing_a != nil && outgoing_b == nil: + return -1 + case *outgoing_a < *outgoing_b: + return -1 + case *outgoing_a > *outgoing_b: + return 1 + default: + return 0 + } + }) + subscriber = m.createSubscriber(ctx, listener, publisherInfo, publisher, streamType, connections2, func(c *proxyConnection) bool { + return true + }) + } + if subscriber != nil { + return subscriber, nil + } + } + } + + subctx, cancel := context.WithTimeout(ctx, m.settings.Timeout()) + defer cancel() + + subscriber, err := publisherInfo.conn.newSubscriber(subctx, listener, publisherInfo.id, publisher, streamType) + if err != nil { + if publisherInfo.conn.IsTemporary() { + publisherInfo.conn.closeIfEmpty() + } + m.logger.Printf("Could not create subscriber for %s publisher %s on %s: %s", streamType, publisher, publisherInfo.conn, err) + return nil, err + } + + return subscriber, nil +} diff --git a/sfu/proxy/proxy_test.go b/sfu/proxy/proxy_test.go new file mode 100644 index 0000000..9bab3e9 --- /dev/null +++ b/sfu/proxy/proxy_test.go @@ -0,0 +1,1747 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2020 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package proxy + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "net" + "net/url" + "path" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + dnstest "github.com/strukturag/nextcloud-spreed-signaling/v2/dns/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + etcdtest "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc" + grpctest "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + metricstest "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/proxy" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/mock" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/proxy/testserver" +) + +const ( + testTimeout = 10 * time.Second +) + +func TestMcuProxyStats(t *testing.T) { + t.Parallel() + metricstest.CollectAndLint(t, proxyMcuStats...) +} + +func newProxyConnectionWithCountry(country geoip.Country) *proxyConnection { + conn := &proxyConnection{} + conn.country.Store(country) + return conn +} + +func Test_sortConnectionsForCountry(t *testing.T) { + t.Parallel() + conn_de := newProxyConnectionWithCountry("DE") + conn_at := newProxyConnectionWithCountry("AT") + conn_jp := newProxyConnectionWithCountry("JP") + conn_us := newProxyConnectionWithCountry("US") + + testcases := map[geoip.Country][][]*proxyConnection{ + // Direct country match + "DE": { + {conn_at, conn_jp, conn_de}, + {conn_de, conn_at, conn_jp}, + }, + // Direct country match + "AT": { + {conn_at, conn_jp, conn_de}, + {conn_at, conn_de, conn_jp}, + }, + // Continent match + "CH": { + {conn_de, conn_jp, conn_at}, + {conn_de, conn_at, conn_jp}, + }, + // Direct country match + "JP": { + {conn_de, conn_jp, conn_at}, + {conn_jp, conn_de, conn_at}, + }, + // Continent match + "CN": { + {conn_de, conn_jp, conn_at}, + {conn_jp, conn_de, conn_at}, + }, + // Continent match + "RU": { + {conn_us, conn_de, conn_jp, conn_at}, + {conn_de, conn_at, conn_us, conn_jp}, + }, + // No match + "AU": { + {conn_us, conn_de, conn_jp, conn_at}, + {conn_us, conn_de, conn_jp, conn_at}, + }, + } + + for country, test := range testcases { + t.Run(string(country), func(t *testing.T) { + t.Parallel() + sorted := sortConnectionsForCountry(test[0], country, nil) + for idx, conn := range sorted { + assert.Equal(t, test[1][idx], conn, "Index %d for %s: expected %s, got %s", idx, country, test[1][idx].Country(), conn.Country()) + } + }) + } +} + +func Test_sortConnectionsForCountryWithOverride(t *testing.T) { + t.Parallel() + conn_de := newProxyConnectionWithCountry("DE") + conn_at := newProxyConnectionWithCountry("AT") + conn_jp := newProxyConnectionWithCountry("JP") + conn_us := newProxyConnectionWithCountry("US") + + testcases := map[geoip.Country][][]*proxyConnection{ + // Direct country match + "DE": { + {conn_at, conn_jp, conn_de}, + {conn_de, conn_at, conn_jp}, + }, + // Direct country match + "AT": { + {conn_at, conn_jp, conn_de}, + {conn_at, conn_de, conn_jp}, + }, + // Continent match + "CH": { + {conn_de, conn_jp, conn_at}, + {conn_de, conn_at, conn_jp}, + }, + // Direct country match + "JP": { + {conn_de, conn_jp, conn_at}, + {conn_jp, conn_de, conn_at}, + }, + // Continent match + "CN": { + {conn_de, conn_jp, conn_at}, + {conn_jp, conn_de, conn_at}, + }, + // Continent match + "RU": { + {conn_us, conn_de, conn_jp, conn_at}, + {conn_de, conn_at, conn_us, conn_jp}, + }, + // No match + "AR": { + {conn_us, conn_de, conn_jp, conn_at}, + {conn_us, conn_de, conn_jp, conn_at}, + }, + // No match but override (OC -> AS / NA) + "AU": { + {conn_us, conn_jp}, + {conn_us, conn_jp}, + }, + // No match but override (AF -> EU) + "ZA": { + {conn_de, conn_at}, + {conn_de, conn_at}, + }, + } + + continentMap := ContinentsMap{ + // Use European connections for Africa. + "AF": {"EU"}, + // Use Asian and North American connections for Oceania. + "OC": {"AS", "NA"}, + } + for country, test := range testcases { + t.Run(string(country), func(t *testing.T) { + t.Parallel() + sorted := sortConnectionsForCountry(test[0], country, continentMap) + for idx, conn := range sorted { + assert.Equal(t, test[1][idx], conn, "Index %d for %s: expected %s, got %s", idx, country, test[1][idx].Country(), conn.Country()) + } + }) + } +} + +func newMcuProxyForTestWithOptions(t *testing.T, options testserver.ProxyTestOptions, idx int, lookup *dnstest.MockLookup) (*proxySFU, *goconf.ConfigFile) { + t.Helper() + require := require.New(t) + if options.Etcd == nil { + options.Etcd = etcdtest.NewServerForTest(t) + } + grpcClients, dnsMonitor := grpctest.NewClientsWithEtcdForTest(t, options.Etcd, lookup) + + tokenKey, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + dir := t.TempDir() + privkeyFile := path.Join(dir, "privkey.pem") + pubkeyFile := path.Join(dir, "pubkey.pem") + require.NoError(internal.WritePrivateKey(tokenKey, privkeyFile)) + require.NoError(internal.WritePublicKey(&tokenKey.PublicKey, pubkeyFile)) + + cfg := goconf.NewConfigFile() + cfg.AddOption("mcu", "urltype", "static") + if strings.Contains(t.Name(), "DnsDiscovery") { + cfg.AddOption("mcu", "dnsdiscovery", "true") + } + cfg.AddOption("mcu", "proxytimeout", strconv.Itoa(int(testTimeout.Seconds()))) + var urls []string + waitingMap := make(map[string]bool) + if len(options.Servers) == 0 { + options.Servers = []testserver.ProxyTestServer{ + testserver.NewProxyServerForTest(t, "DE"), + } + } + tokenId := fmt.Sprintf("test-token-%d", idx) + for _, s := range options.Servers { + s.SetServers(options.Servers) + s.SetToken(tokenId, &tokenKey.PublicKey) + urls = append(urls, s.URL()) + waitingMap[s.URL()] = true + } + cfg.AddOption("mcu", "url", strings.Join(urls, " ")) + cfg.AddOption("mcu", "token_id", tokenId) + cfg.AddOption("mcu", "token_key", privkeyFile) + + etcdConfig := goconf.NewConfigFile() + etcdConfig.AddOption("etcd", "endpoints", options.Etcd.URL().String()) + etcdConfig.AddOption("etcd", "loglevel", "error") + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + etcdClient, err := etcd.NewClient(logger, etcdConfig, "") + require.NoError(err) + t.Cleanup(func() { + assert.NoError(t, etcdClient.Close()) + }) + + mcu, err := NewProxySFU(ctx, cfg, etcdClient, grpcClients, dnsMonitor) + require.NoError(err) + t.Cleanup(func() { + mcu.Stop() + }) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(mcu.Start(ctx)) + + proxy := mcu.(*proxySFU) + + require.NoError(proxy.WaitForConnections(ctx)) + + for len(waitingMap) > 0 { + require.NoError(ctx.Err()) + + for u := range waitingMap { + proxy.connectionsMu.RLock() + connections := proxy.connections + proxy.connectionsMu.RUnlock() + for _, c := range connections { + if c.rawUrl == u && c.IsConnected() && c.SessionId() != "" { + delete(waitingMap, u) + break + } + } + } + + time.Sleep(time.Millisecond) + } + + return proxy, cfg +} + +func newMcuProxyForTestWithServers(t *testing.T, servers []testserver.ProxyTestServer, idx int, lookup *dnstest.MockLookup) *proxySFU { + t.Helper() + + proxy, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Servers: servers, + }, idx, lookup) + return proxy +} + +func newMcuProxyForTest(t *testing.T, idx int, lookup *dnstest.MockLookup) *proxySFU { + t.Helper() + server := testserver.NewProxyServerForTest(t, "DE") + + return newMcuProxyForTestWithServers(t, []testserver.ProxyTestServer{server}, idx, lookup) +} + +func Test_ProxyAddRemoveConnections(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + server1 := testserver.NewProxyServerForTest(t, "DE") + mcu, config := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Servers: []testserver.ProxyTestServer{ + server1, + }, + }, 0, nil) + + server2 := testserver.NewProxyServerForTest(t, "DE") + server1.Servers = append(server1.Servers, server2) + server2.Servers = server1.Servers + server2.Tokens = server1.Tokens + urls1 := []string{ + server1.URL(), + server2.URL(), + } + config.AddOption("mcu", "url", strings.Join(urls1, " ")) + mcu.Reload(config) + + mcu.connectionsMu.RLock() + assert.Len(mcu.connections, 2) + mcu.connectionsMu.RUnlock() + + // Wait until connection is established. + waitCtx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + for waitCtx.Err() == nil { + mcu.connectionsMu.RLock() + notAllConnected := slices.ContainsFunc(mcu.connections, func(conn *proxyConnection) bool { + return !conn.IsConnected() + }) + mcu.connectionsMu.RUnlock() + if notAllConnected { + time.Sleep(time.Millisecond) + continue + } + + break + } + assert.NoError(waitCtx.Err(), "error while waiting for connection to be established") + + urls2 := []string{ + server2.URL(), + } + config.AddOption("mcu", "url", strings.Join(urls2, " ")) + mcu.Reload(config) + + // Removing the connections takes a short while (asynchronously, closed when unused). + waitCtx, cancel = context.WithTimeout(ctx, time.Second) + defer cancel() + + for waitCtx.Err() == nil { + mcu.connectionsMu.RLock() + if len(mcu.connections) != 1 { + mcu.connectionsMu.RUnlock() + time.Sleep(time.Millisecond) + continue + } + + assert.Len(mcu.connections, 1) + assert.Equal(server2.URL(), mcu.connections[0].rawUrl) + mcu.connectionsMu.RUnlock() + break + } + assert.NoError(waitCtx.Err(), "error while waiting for connection to be removed") +} + +func Test_ProxyAddRemoveConnectionsDnsDiscovery(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + lookup := dnstest.NewMockLookup() + + server1 := testserver.NewProxyServerForTest(t, "DE") + server1.Start() + h, port, err := net.SplitHostPort(server1.Listener().Addr().String()) + require.NoError(err) + ip1 := net.ParseIP(h) + require.NotNil(ip1, "failed for %s", h) + + require.Contains(server1.URL(), ip1.String()) + server1.SetURL(strings.ReplaceAll(server1.URL(), ip1.String(), "proxydomain.invalid")) + u1, err := url.Parse(server1.URL()) + require.NoError(err) + lookup.Set(u1.Hostname(), []net.IP{ + ip1, + }) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + mcu, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Servers: []testserver.ProxyTestServer{ + server1, + }, + }, 0, lookup) + + if connections := mcu.getConnections(); assert.Len(connections, 1) && assert.NotNil(connections[0].ip) { + assert.True(ip1.Equal(connections[0].ip), "ip addresses differ: expected %s, got %s", ip1.String(), connections[0].ip.String()) + } + + dnsMonitor := mcu.config.(*configStatic).dnsMonitor + require.NotNil(dnsMonitor) + + server2 := testserver.NewProxyServerForTest(t, "DE") + l, err := net.Listen("tcp", "127.0.0.2:"+port) + require.NoError(err) + assert.NoError(server2.Listener().Close()) + server2.SetListener(l) + server2.Start() + + h, _, err = net.SplitHostPort(server2.Listener().Addr().String()) + require.NoError(err) + ip2 := net.ParseIP(h) + require.NotNil(ip2, "failed for %s", h) + require.Contains(server2.URL(), ip2.String()) + server2.SetURL(strings.ReplaceAll(server2.URL(), ip2.String(), "proxydomain.invalid")) + + server1.Servers = append(server1.Servers, server2) + server2.Servers = server1.Servers + server2.Tokens = server1.Tokens + + lookup.Set(u1.Hostname(), []net.IP{ + ip1, + ip2, + }) + dnsMonitor.CheckHostnames() + + // Wait until connection is established. + waitCtx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + for waitCtx.Err() == nil { + mcu.connectionsMu.RLock() + if len(mcu.connections) != 2 { + mcu.connectionsMu.RUnlock() + time.Sleep(time.Millisecond) + continue + } + + notAllConnected := slices.ContainsFunc(mcu.connections, func(conn *proxyConnection) bool { + return !conn.IsConnected() + }) + mcu.connectionsMu.RUnlock() + if notAllConnected { + time.Sleep(time.Millisecond) + continue + } + + break + } + assert.NoError(waitCtx.Err(), "error while waiting for connection to be established") + + lookup.Set(u1.Hostname(), []net.IP{ + ip2, + }) + dnsMonitor.CheckHostnames() + + // Removing the connections takes a short while (asynchronously, closed when unused). + waitCtx, cancel = context.WithTimeout(ctx, time.Second) + defer cancel() + + for waitCtx.Err() == nil { + mcu.connectionsMu.RLock() + if len(mcu.connections) != 1 { + mcu.connectionsMu.RUnlock() + time.Sleep(time.Millisecond) + continue + } + + assert.Len(mcu.connections, 1) + assert.Equal(server1.URL(), mcu.connections[0].rawUrl) + if assert.NotNil(mcu.connections[0].ip) { + assert.True(ip2.Equal(mcu.connections[0].ip), "ip addresses differ: expected %s, got %s", ip2.String(), mcu.connections[0].ip.String()) + } + mcu.connectionsMu.RUnlock() + break + } + assert.NoError(waitCtx.Err(), "error while waiting for connection to be removed") +} + +func Test_ProxyPublisherSubscriber(t *testing.T) { + t.Parallel() + mcu := newMcuProxyForTest(t, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("DE") + + sub, err := mcu.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + require.NoError(t, err) + + defer sub.Close(context.Background()) +} + +func Test_ProxyPublisherCodecs(t *testing.T) { + t.Parallel() + mcu := newMcuProxyForTest(t, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + AudioCodec: "opus,g722", + VideoCodec: "vp9,vp8,av1", + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) +} + +func Test_ProxyWaitForPublisher(t *testing.T) { + t.Parallel() + mcu := newMcuProxyForTest(t, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("DE") + done := make(chan struct{}) + go func() { + defer close(done) + sub, err := mcu.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + if !assert.NoError(t, err) { + return + } + + defer sub.Close(context.Background()) + }() + + // Give subscriber goroutine some time to start + time.Sleep(100 * time.Millisecond) + + pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + select { + case <-done: + case <-ctx.Done(): + assert.NoError(t, ctx.Err()) + } + defer pub.Close(context.Background()) +} + +func Test_ProxyPublisherBandwidth(t *testing.T) { + t.Parallel() + server1 := testserver.NewProxyServerForTest(t, "DE") + server2 := testserver.NewProxyServerForTest(t, "DE") + mcu := newMcuProxyForTestWithServers(t, []testserver.ProxyTestServer{ + server1, + server2, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pub1Id := api.PublicSessionId("the-publisher-1") + pub1Sid := "1234567890" + pub1Listener := mock.NewListener(pub1Id + "-public") + pub1Initiator := mock.NewInitiator("DE") + pub1, err := mcu.NewPublisher(ctx, pub1Listener, pub1Id, pub1Sid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pub1Initiator) + require.NoError(t, err) + + defer pub1.Close(context.Background()) + + if pub1.(*proxyPublisher).conn.rawUrl == server1.URL() { + server1.UpdateBandwidth(100, 0) + } else { + server2.UpdateBandwidth(100, 0) + } + + // Wait until proxy has been updated + for assert.NoError(t, ctx.Err()) { + mcu.connectionsMu.RLock() + connections := mcu.connections + mcu.connectionsMu.RUnlock() + missing := true + for _, c := range connections { + if c.Bandwidth() != nil { + missing = false + break + } + } + if !missing { + break + } + time.Sleep(time.Millisecond) + } + + pub2Id := api.PublicSessionId("the-publisher-2") + pub2id := "1234567890" + pub2Listener := mock.NewListener(pub2Id + "-public") + pub2Initiator := mock.NewInitiator("DE") + pub2, err := mcu.NewPublisher(ctx, pub2Listener, pub2Id, pub2id, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pub2Initiator) + require.NoError(t, err) + + defer pub2.Close(context.Background()) + + assert.NotEqual(t, pub1.(*proxyPublisher).conn.rawUrl, pub2.(*proxyPublisher).conn.rawUrl) +} + +func Test_ProxyPublisherBandwidthOverload(t *testing.T) { + t.Parallel() + server1 := testserver.NewProxyServerForTest(t, "DE") + server2 := testserver.NewProxyServerForTest(t, "DE") + mcu := newMcuProxyForTestWithServers(t, []testserver.ProxyTestServer{ + server1, + server2, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pub1Id := api.PublicSessionId("the-publisher-1") + pub1Sid := "1234567890" + pub1Listener := mock.NewListener(pub1Id + "-public") + pub1Initiator := mock.NewInitiator("DE") + pub1, err := mcu.NewPublisher(ctx, pub1Listener, pub1Id, pub1Sid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pub1Initiator) + require.NoError(t, err) + + defer pub1.Close(context.Background()) + + // If all servers are bandwidth loaded, select the one with the least usage. + if pub1.(*proxyPublisher).conn.rawUrl == server1.URL() { + server1.UpdateBandwidth(100, 0) + server2.UpdateBandwidth(102, 0) + } else { + server1.UpdateBandwidth(102, 0) + server2.UpdateBandwidth(100, 0) + } + + // Wait until proxy has been updated + for assert.NoError(t, ctx.Err()) { + mcu.connectionsMu.RLock() + connections := mcu.connections + mcu.connectionsMu.RUnlock() + missing := false + for _, c := range connections { + if c.Bandwidth() == nil { + missing = true + break + } + } + if !missing { + break + } + time.Sleep(time.Millisecond) + } + + pub2Id := api.PublicSessionId("the-publisher-2") + pub2id := "1234567890" + pub2Listener := mock.NewListener(pub2Id + "-public") + pub2Initiator := mock.NewInitiator("DE") + pub2, err := mcu.NewPublisher(ctx, pub2Listener, pub2Id, pub2id, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pub2Initiator) + require.NoError(t, err) + + defer pub2.Close(context.Background()) + + assert.Equal(t, pub1.(*proxyPublisher).conn.rawUrl, pub2.(*proxyPublisher).conn.rawUrl) +} + +func Test_ProxyPublisherLoad(t *testing.T) { + t.Parallel() + server1 := testserver.NewProxyServerForTest(t, "DE") + server2 := testserver.NewProxyServerForTest(t, "DE") + mcu := newMcuProxyForTestWithServers(t, []testserver.ProxyTestServer{ + server1, + server2, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pub1Id := api.PublicSessionId("the-publisher-1") + pub1Sid := "1234567890" + pub1Listener := mock.NewListener(pub1Id + "-public") + pub1Initiator := mock.NewInitiator("DE") + pub1, err := mcu.NewPublisher(ctx, pub1Listener, pub1Id, pub1Sid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pub1Initiator) + require.NoError(t, err) + + defer pub1.Close(context.Background()) + + // Make sure connections are re-sorted. + mcu.nextSort.Store(0) + time.Sleep(100 * time.Millisecond) + + pub2Id := api.PublicSessionId("the-publisher-2") + pub2id := "1234567890" + pub2Listener := mock.NewListener(pub2Id + "-public") + pub2Initiator := mock.NewInitiator("DE") + pub2, err := mcu.NewPublisher(ctx, pub2Listener, pub2Id, pub2id, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pub2Initiator) + require.NoError(t, err) + + defer pub2.Close(context.Background()) + + assert.NotEqual(t, pub1.(*proxyPublisher).conn.rawUrl, pub2.(*proxyPublisher).conn.rawUrl) +} + +func Test_ProxyPublisherCountry(t *testing.T) { + t.Parallel() + serverDE := testserver.NewProxyServerForTest(t, "DE") + serverUS := testserver.NewProxyServerForTest(t, "US") + mcu := newMcuProxyForTestWithServers(t, []testserver.ProxyTestServer{ + serverDE, + serverUS, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubDEId := api.PublicSessionId("the-publisher-de") + pubDESid := "1234567890" + pubDEListener := mock.NewListener(pubDEId + "-public") + pubDEInitiator := mock.NewInitiator("DE") + pubDE, err := mcu.NewPublisher(ctx, pubDEListener, pubDEId, pubDESid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubDEInitiator) + require.NoError(t, err) + + defer pubDE.Close(context.Background()) + + assert.Equal(t, serverDE.URL(), pubDE.(*proxyPublisher).conn.rawUrl) + + pubUSId := api.PublicSessionId("the-publisher-us") + pubUSSid := "1234567890" + pubUSListener := mock.NewListener(pubUSId + "-public") + pubUSInitiator := mock.NewInitiator("US") + pubUS, err := mcu.NewPublisher(ctx, pubUSListener, pubUSId, pubUSSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubUSInitiator) + require.NoError(t, err) + + defer pubUS.Close(context.Background()) + + assert.Equal(t, serverUS.URL(), pubUS.(*proxyPublisher).conn.rawUrl) +} + +func Test_ProxyPublisherContinent(t *testing.T) { + t.Parallel() + serverDE := testserver.NewProxyServerForTest(t, "DE") + serverUS := testserver.NewProxyServerForTest(t, "US") + mcu := newMcuProxyForTestWithServers(t, []testserver.ProxyTestServer{ + serverDE, + serverUS, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubDEId := api.PublicSessionId("the-publisher-de") + pubDESid := "1234567890" + pubDEListener := mock.NewListener(pubDEId + "-public") + pubDEInitiator := mock.NewInitiator("DE") + pubDE, err := mcu.NewPublisher(ctx, pubDEListener, pubDEId, pubDESid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubDEInitiator) + require.NoError(t, err) + + defer pubDE.Close(context.Background()) + + assert.Equal(t, serverDE.URL(), pubDE.(*proxyPublisher).conn.rawUrl) + + pubFRId := api.PublicSessionId("the-publisher-fr") + pubFRSid := "1234567890" + pubFRListener := mock.NewListener(pubFRId + "-public") + pubFRInitiator := mock.NewInitiator("FR") + pubFR, err := mcu.NewPublisher(ctx, pubFRListener, pubFRId, pubFRSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubFRInitiator) + require.NoError(t, err) + + defer pubFR.Close(context.Background()) + + assert.Equal(t, serverDE.URL(), pubFR.(*proxyPublisher).conn.rawUrl) +} + +func Test_ProxySubscriberCountry(t *testing.T) { + t.Parallel() + serverDE := testserver.NewProxyServerForTest(t, "DE") + serverUS := testserver.NewProxyServerForTest(t, "US") + mcu := newMcuProxyForTestWithServers(t, []testserver.ProxyTestServer{ + serverDE, + serverUS, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) + + assert.Equal(t, serverDE.URL(), pub.(*proxyPublisher).conn.rawUrl) + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("US") + sub, err := mcu.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + require.NoError(t, err) + + defer sub.Close(context.Background()) + + assert.Equal(t, serverUS.URL(), sub.(*proxySubscriber).conn.rawUrl) +} + +func Test_ProxySubscriberContinent(t *testing.T) { + t.Parallel() + serverDE := testserver.NewProxyServerForTest(t, "DE") + serverUS := testserver.NewProxyServerForTest(t, "US") + mcu := newMcuProxyForTestWithServers(t, []testserver.ProxyTestServer{ + serverDE, + serverUS, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) + + assert.Equal(t, serverDE.URL(), pub.(*proxyPublisher).conn.rawUrl) + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("FR") + sub, err := mcu.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + require.NoError(t, err) + + defer sub.Close(context.Background()) + + assert.Equal(t, serverDE.URL(), sub.(*proxySubscriber).conn.rawUrl) +} + +func Test_ProxySubscriberBandwidth(t *testing.T) { + t.Parallel() + serverDE := testserver.NewProxyServerForTest(t, "DE") + serverUS := testserver.NewProxyServerForTest(t, "US") + mcu := newMcuProxyForTestWithServers(t, []testserver.ProxyTestServer{ + serverDE, + serverUS, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) + + assert.Equal(t, serverDE.URL(), pub.(*proxyPublisher).conn.rawUrl) + + serverDE.UpdateBandwidth(0, 100) + + // Wait until proxy has been updated + for assert.NoError(t, ctx.Err()) { + mcu.connectionsMu.RLock() + connections := mcu.connections + mcu.connectionsMu.RUnlock() + missing := true + for _, c := range connections { + if c.Bandwidth() != nil { + missing = false + break + } + } + if !missing { + break + } + time.Sleep(time.Millisecond) + } + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("US") + sub, err := mcu.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + require.NoError(t, err) + + defer sub.Close(context.Background()) + + assert.Equal(t, serverUS.URL(), sub.(*proxySubscriber).conn.rawUrl) +} + +func Test_ProxySubscriberBandwidthOverload(t *testing.T) { + t.Parallel() + serverDE := testserver.NewProxyServerForTest(t, "DE") + serverUS := testserver.NewProxyServerForTest(t, "US") + mcu := newMcuProxyForTestWithServers(t, []testserver.ProxyTestServer{ + serverDE, + serverUS, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) + + assert.Equal(t, serverDE.URL(), pub.(*proxyPublisher).conn.rawUrl) + + serverDE.UpdateBandwidth(0, 100) + serverUS.UpdateBandwidth(0, 102) + + // Wait until proxy has been updated + for assert.NoError(t, ctx.Err()) { + mcu.connectionsMu.RLock() + connections := mcu.connections + mcu.connectionsMu.RUnlock() + missing := false + for _, c := range connections { + if c.Bandwidth() == nil { + missing = true + break + } + } + if !missing { + break + } + time.Sleep(time.Millisecond) + } + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("US") + sub, err := mcu.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + require.NoError(t, err) + + defer sub.Close(context.Background()) + + assert.Equal(t, serverDE.URL(), sub.(*proxySubscriber).conn.rawUrl) +} + +func Test_ProxyPublisherTimeout(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + server := testserver.NewProxyServerForTest(t, "DE") + mcu, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Servers: []testserver.ProxyTestServer{server}, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + settings := mcu.settings.(*proxySettings) + settings.SetTimeout(testserver.TimeoutTestTimeout) + + // Creating the publisher will timeout locally. + pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + if pub != nil { + defer pub.Close(context.Background()) + } + assert.ErrorContains(err, "no MCU connection available") + + // Wait for publisher to be created on the proxy side. + require.NoError(server.WaitForWakeup(ctx), "publisher not created") + + // The local side will remove the (unused) publisher from the proxy. + require.NoError(server.WaitForWakeup(ctx), "unused publisher not deleted") +} + +func Test_ProxySubscriberTimeout(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + server := testserver.NewProxyServerForTest(t, "DE") + mcu, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Servers: []testserver.ProxyTestServer{server}, + }, 0, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + pub, err := mcu.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(err) + defer pub.Close(context.Background()) + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("DE") + + settings := mcu.settings.(*proxySettings) + settings.SetTimeout(testserver.TimeoutTestTimeout) + + // Creating the subscriber will timeout locally. + sub, err := mcu.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + if sub != nil { + defer sub.Close(context.Background()) + } + assert.ErrorIs(err, context.DeadlineExceeded) + + // Wait for subscriber to be created on the proxy side. + require.NoError(server.WaitForWakeup(ctx), "subscriber not created") + + // The local side will remove the (unused) subscriber from the proxy. + require.NoError(server.WaitForWakeup(ctx), "unused subscriber not deleted") +} + +func Test_ProxyReconnectAfter(t *testing.T) { + t.Parallel() + reasons := []string{ + "session_resumed", + "session_expired", + "session_closed", + "unknown_reason", + } + for _, reason := range reasons { + t.Run(reason, func(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + server := testserver.NewProxyServerForTest(t, "DE") + mcu, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Servers: []testserver.ProxyTestServer{server}, + }, 0, nil) + + connections := mcu.getSortedConnections(nil) + require.Len(connections, 1) + sessionId := connections[0].SessionId() + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + client := server.GetSingleClient() + require.NotNil(client) + + client.SendMessage(&proxy.ServerMessage{ + Type: "bye", + Bye: &proxy.ByeServerMessage{ + Reason: reason, + }, + }) + + // The "bye" will close the connection and reset the session id. + assert.NoError(mcu.WaitForDisconnected(ctx)) + + // The client will automatically reconnect. + time.Sleep(10 * time.Millisecond) + assert.NoError(mcu.WaitForConnections(ctx)) + + if connections := mcu.getSortedConnections(nil); assert.Len(connections, 1) { + assert.NotEqual(sessionId, connections[0].SessionId()) + } + }) + } +} + +func Test_ProxyReconnectAfterShutdown(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + server := testserver.NewProxyServerForTest(t, "DE") + mcu, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Servers: []testserver.ProxyTestServer{server}, + }, 0, nil) + + connections := mcu.getSortedConnections(nil) + require.Len(connections, 1) + sessionId := connections[0].SessionId() + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + client := server.GetSingleClient() + require.NotNil(client) + + client.SendMessage(&proxy.ServerMessage{ + Type: "event", + Event: &proxy.EventServerMessage{ + Type: "shutdown-scheduled", + }, + }) + + // Force reconnect. + client.Close() + assert.NoError(mcu.WaitForDisconnected(ctx)) + + // The client will automatically reconnect and resume the session. + time.Sleep(10 * time.Millisecond) + assert.NoError(mcu.WaitForConnections(ctx)) + + if connections := mcu.getSortedConnections(nil); assert.Len(connections, 1) { + assert.Equal(sessionId, connections[0].SessionId()) + } +} + +func Test_ProxyResume(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + server := testserver.NewProxyServerForTest(t, "DE") + mcu, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Servers: []testserver.ProxyTestServer{server}, + }, 0, nil) + + connections := mcu.getSortedConnections(nil) + require.Len(connections, 1) + sessionId := connections[0].SessionId() + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + client := server.GetSingleClient() + require.NotNil(client) + + // Force reconnect. + client.Close() + assert.NoError(mcu.WaitForDisconnected(ctx)) + + // The client will automatically reconnect. + time.Sleep(10 * time.Millisecond) + assert.NoError(mcu.WaitForConnections(ctx)) + + if connections := mcu.getSortedConnections(nil); assert.Len(connections, 1) { + assert.Equal(sessionId, connections[0].SessionId()) + } +} + +func Test_ProxyResumeFail(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + server := testserver.NewProxyServerForTest(t, "DE") + mcu, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Servers: []testserver.ProxyTestServer{server}, + }, 0, nil) + + connections := mcu.getSortedConnections(nil) + require.Len(connections, 1) + sessionId := connections[0].SessionId() + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + client := server.GetSingleClient() + require.NotNil(client) + server.ClearClients() + + // Force reconnect. + client.Close() + assert.NoError(mcu.WaitForDisconnected(ctx)) + + // The client will automatically reconnect. + time.Sleep(10 * time.Millisecond) + assert.NoError(mcu.WaitForConnections(ctx)) + + if connections := mcu.getSortedConnections(nil); assert.Len(connections, 1) { + assert.NotEqual(sessionId, connections[0].SessionId()) + } +} + +type publisherHub struct { + grpctest.MockHub + + mu sync.Mutex + // +checklocks:mu + publishers map[api.PublicSessionId]*proxyPublisher + publishersCond sync.Cond +} + +func newPublisherHub() *publisherHub { + hub := &publisherHub{ + publishers: make(map[api.PublicSessionId]*proxyPublisher), + } + hub.publishersCond.L = &hub.mu + return hub +} + +func (h *publisherHub) addPublisher(publisher *proxyPublisher) { + h.mu.Lock() + defer h.mu.Unlock() + + h.publishers[publisher.PublisherId()] = publisher + h.publishersCond.Broadcast() +} + +func (h *publisherHub) GetPublisherIdForSessionId(ctx context.Context, sessionId api.PublicSessionId, streamType sfu.StreamType) (*grpc.GetPublisherIdReply, error) { + h.mu.Lock() + defer h.mu.Unlock() + + stop := context.AfterFunc(ctx, func() { + h.publishersCond.Broadcast() + }) + defer stop() + + pub, found := h.publishers[sessionId] + for !found { + if err := ctx.Err(); err != nil { + return nil, err + } + + h.publishersCond.Wait() + pub, found = h.publishers[sessionId] + } + + connToken, err := pub.conn.proxy.CreateToken("") + if err != nil { + return nil, err + } + pubToken, err := pub.conn.proxy.CreateToken(string(pub.Id())) + if err != nil { + return nil, err + } + + reply := &grpc.GetPublisherIdReply{ + PublisherId: pub.Id(), + ProxyUrl: pub.conn.rawUrl, + ConnectToken: connToken, + PublisherToken: pubToken, + } + if ip := pub.conn.ip; len(ip) > 0 { + reply.Ip = ip.String() + } + return reply, nil +} + +func Test_ProxyRemotePublisher(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + embedEtcd := etcdtest.NewServerForTest(t) + + grpcServer1, addr1 := grpctest.NewServerForTest(t) + grpcServer2, addr2 := grpctest.NewServerForTest(t) + + hub1 := newPublisherHub() + hub2 := newPublisherHub() + grpcServer1.SetHub(hub1) + grpcServer2.SetHub(hub2) + + embedEtcd.SetValue("/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) + embedEtcd.SetValue("/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) + + server1 := testserver.NewProxyServerForTest(t, "DE") + server2 := testserver.NewProxyServerForTest(t, "DE") + + mcu1, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server1, + server2, + }, + }, 1, nil) + mcu2, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server1, + server2, + }, + }, 2, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + pub, err := mcu1.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) + + if proxyPub, ok := pub.(*proxyPublisher); assert.True(ok) { + hub1.addPublisher(proxyPub) + } + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("DE") + sub, err := mcu2.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + require.NoError(t, err) + + defer sub.Close(context.Background()) +} + +func Test_ProxyMultipleRemotePublisher(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + embedEtcd := etcdtest.NewServerForTest(t) + + grpcServer1, addr1 := grpctest.NewServerForTest(t) + grpcServer2, addr2 := grpctest.NewServerForTest(t) + grpcServer3, addr3 := grpctest.NewServerForTest(t) + + hub1 := newPublisherHub() + hub2 := newPublisherHub() + hub3 := newPublisherHub() + grpcServer1.SetHub(hub1) + grpcServer2.SetHub(hub2) + grpcServer3.SetHub(hub3) + + embedEtcd.SetValue("/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) + embedEtcd.SetValue("/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) + embedEtcd.SetValue("/grpctargets/three", []byte("{\"address\":\""+addr3+"\"}")) + + server1 := testserver.NewProxyServerForTest(t, "DE") + server2 := testserver.NewProxyServerForTest(t, "US") + server3 := testserver.NewProxyServerForTest(t, "US") + + mcu1, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server1, + server2, + server3, + }, + }, 1, nil) + mcu2, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server1, + server2, + server3, + }, + }, 2, nil) + mcu3, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server1, + server2, + server3, + }, + }, 3, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + pub, err := mcu1.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) + + if proxyPub, ok := pub.(*proxyPublisher); assert.True(ok) { + hub1.addPublisher(proxyPub) + } + + sub1Listener := mock.NewListener("subscriber-public-1") + sub1Initiator := mock.NewInitiator("US") + sub1, err := mcu2.NewSubscriber(ctx, sub1Listener, pubId, sfu.StreamTypeVideo, sub1Initiator) + require.NoError(t, err) + + defer sub1.Close(context.Background()) + + sub2Listener := mock.NewListener("subscriber-public-2") + sub2Initiator := mock.NewInitiator("US") + sub2, err := mcu3.NewSubscriber(ctx, sub2Listener, pubId, sfu.StreamTypeVideo, sub2Initiator) + require.NoError(t, err) + + defer sub2.Close(context.Background()) +} + +func Test_ProxyRemotePublisherWait(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + embedEtcd := etcdtest.NewServerForTest(t) + + grpcServer1, addr1 := grpctest.NewServerForTest(t) + grpcServer2, addr2 := grpctest.NewServerForTest(t) + + hub1 := newPublisherHub() + hub2 := newPublisherHub() + grpcServer1.SetHub(hub1) + grpcServer2.SetHub(hub2) + + embedEtcd.SetValue("/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) + embedEtcd.SetValue("/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) + + server1 := testserver.NewProxyServerForTest(t, "DE") + server2 := testserver.NewProxyServerForTest(t, "DE") + + mcu1, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server1, + server2, + }, + }, 1, nil) + mcu2, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server1, + server2, + }, + }, 2, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("DE") + + done := make(chan struct{}) + go func() { + defer close(done) + sub, err := mcu2.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + if !assert.NoError(err) { + return + } + + defer sub.Close(context.Background()) + }() + + // Give subscriber goroutine some time to start + time.Sleep(100 * time.Millisecond) + + pub, err := mcu1.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) + + if proxyPub, ok := pub.(*proxyPublisher); assert.True(ok) { + hub1.addPublisher(proxyPub) + } + + select { + case <-done: + case <-ctx.Done(): + assert.NoError(ctx.Err()) + } +} + +func Test_ProxyRemotePublisherTemporary(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + embedEtcd := etcdtest.NewServerForTest(t) + + server, addr1 := grpctest.NewServerForTest(t) + hub := newPublisherHub() + server.SetHub(hub) + + target := grpc.TargetInformationEtcd{ + Address: addr1, + } + encoded, err := json.Marshal(target) + require.NoError(err) + embedEtcd.SetValue("/grpctargets/server", encoded) + + server1 := testserver.NewProxyServerForTest(t, "DE") + server2 := testserver.NewProxyServerForTest(t, "DE") + + mcu1, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server1, + }, + }, 1, nil) + mcu2, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server2, + }, + }, 2, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + pub, err := mcu1.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(err) + + defer pub.Close(context.Background()) + + if proxyPub, ok := pub.(*proxyPublisher); assert.True(ok) { + hub.addPublisher(proxyPub) + } + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("DE") + sub, err := mcu2.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + require.NoError(err) + + defer sub.Close(context.Background()) + + if connSub, ok := sub.(*proxySubscriber); assert.True(ok) { + assert.Equal(server1.URL(), connSub.conn.rawUrl) + assert.Empty(connSub.conn.ip) + } + + // The temporary connection has been added + assert.Equal(2, mcu2.ConnectionsCount()) + + sub.Close(context.Background()) + + // Wait for temporary connection to be removed. +loop: + for { + select { + case <-ctx.Done(): + assert.NoError(ctx.Err()) + default: + if mcu2.ConnectionsCount() == 1 { + break loop + } + } + } +} + +func Test_ProxyConnectToken(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + embedEtcd := etcdtest.NewServerForTest(t) + + grpcServer1, addr1 := grpctest.NewServerForTest(t) + grpcServer2, addr2 := grpctest.NewServerForTest(t) + + hub1 := newPublisherHub() + hub2 := newPublisherHub() + grpcServer1.SetHub(hub1) + grpcServer2.SetHub(hub2) + + embedEtcd.SetValue("/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) + embedEtcd.SetValue("/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) + + server1 := testserver.NewProxyServerForTest(t, "DE") + server2 := testserver.NewProxyServerForTest(t, "DE") + + // Signaling server instances are in a cluster but don't share their proxies, + // i.e. they are only known to their local proxy, not the one of the other + // signaling server - so the connection token must be passed between them. + mcu1, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server1, + }, + }, 1, nil) + mcu2, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server2, + }, + }, 2, nil) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + pub, err := mcu1.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) + + if proxyPub, ok := pub.(*proxyPublisher); assert.True(ok) { + hub1.addPublisher(proxyPub) + } + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("DE") + sub, err := mcu2.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + require.NoError(t, err) + + defer sub.Close(context.Background()) +} + +func Test_ProxyPublisherToken(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + embedEtcd := etcdtest.NewServerForTest(t) + + grpcServer1, addr1 := grpctest.NewServerForTest(t) + grpcServer2, addr2 := grpctest.NewServerForTest(t) + + hub1 := newPublisherHub() + hub2 := newPublisherHub() + grpcServer1.SetHub(hub1) + grpcServer2.SetHub(hub2) + + embedEtcd.SetValue("/grpctargets/one", []byte("{\"address\":\""+addr1+"\"}")) + embedEtcd.SetValue("/grpctargets/two", []byte("{\"address\":\""+addr2+"\"}")) + + server1 := testserver.NewProxyServerForTest(t, "DE") + server2 := testserver.NewProxyServerForTest(t, "US") + + // Signaling server instances are in a cluster but don't share their proxies, + // i.e. they are only known to their local proxy, not the one of the other + // signaling server - so the connection token must be passed between them. + // Also the subscriber is connecting from a different country, so a remote + // stream will be created that needs a valid token from the remote proxy. + mcu1, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server1, + }, + }, 1, nil) + mcu2, _ := newMcuProxyForTestWithOptions(t, testserver.ProxyTestOptions{ + Etcd: embedEtcd, + Servers: []testserver.ProxyTestServer{ + server2, + }, + }, 2, nil) + // Support remote subscribers for the tests. + server1.Servers = append(server1.Servers, server2) + server2.Servers = append(server2.Servers, server1) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + pubId := api.PublicSessionId("the-publisher") + pubSid := "1234567890" + pubListener := mock.NewListener(pubId + "-public") + pubInitiator := mock.NewInitiator("DE") + + pub, err := mcu1.NewPublisher(ctx, pubListener, pubId, pubSid, sfu.StreamTypeVideo, sfu.NewPublisherSettings{ + MediaTypes: sfu.MediaTypeVideo | sfu.MediaTypeAudio, + }, pubInitiator) + require.NoError(t, err) + + defer pub.Close(context.Background()) + + if proxyPub, ok := pub.(*proxyPublisher); assert.True(ok) { + hub1.addPublisher(proxyPub) + } + + subListener := mock.NewListener("subscriber-public") + subInitiator := mock.NewInitiator("US") + sub, err := mcu2.NewSubscriber(ctx, subListener, pubId, sfu.StreamTypeVideo, subInitiator) + require.NoError(t, err) + + defer sub.Close(context.Background()) +} diff --git a/sfu/proxy/stats_prometheus.go b/sfu/proxy/stats_prometheus.go new file mode 100644 index 0000000..1984f1c --- /dev/null +++ b/sfu/proxy/stats_prometheus.go @@ -0,0 +1,81 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2021 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package proxy + +import ( + "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/internal" +) + +var ( + statsConnectedProxyBackendsCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "backend_connections", + Help: "Current number of connections to signaling proxy backends", + }, []string{"country"}) + statsProxyBackendLoadCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "backend_load", + Help: "Current load of signaling proxy backends", + }, []string{"url"}) + statsProxyUsageCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "backend_usage", + Help: "The current usage of signaling proxy backends in percent", + }, []string{"url", "direction"}) + statsProxyBandwidthCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "backend_bandwidth", + Help: "The current bandwidth of signaling proxy backends in bytes per second", + }, []string{"url", "direction"}) + statsProxyNobackendAvailableTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "mcu", + Name: "no_backend_available_total", + Help: "Total number of publishing requests where no backend was available", + }, []string{"type"}) + + proxyMcuStats = []prometheus.Collector{ + statsConnectedProxyBackendsCurrent, + statsProxyBackendLoadCurrent, + statsProxyUsageCurrent, + statsProxyBandwidthCurrent, + statsProxyNobackendAvailableTotal, + } +) + +func RegisterStats() { + internal.RegisterCommonStats() + metrics.RegisterAll(proxyMcuStats...) +} + +func UnregisterStats() { + internal.UnregisterCommonStats() + metrics.UnregisterAll(proxyMcuStats...) +} diff --git a/sfu/proxy/test/proxy.go b/sfu/proxy/test/proxy.go new file mode 100644 index 0000000..c3d44ec --- /dev/null +++ b/sfu/proxy/test/proxy.go @@ -0,0 +1,126 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "fmt" + "path" + "strconv" + "strings" + "testing" + "time" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dnstest "github.com/strukturag/nextcloud-spreed-signaling/v2/dns/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + etcdtest "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd/test" + grpctest "github.com/strukturag/nextcloud-spreed-signaling/v2/grpc/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/proxy" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu/proxy/testserver" +) + +const ( + testTimeout = 10 * time.Second +) + +type ConnectionWaiter interface { + WaitForConnections(ctx context.Context) error + WaitForConnectionsEstablished(ctx context.Context, waitMap map[string]bool) error +} + +func NewMcuProxyForTestWithOptions(t *testing.T, options testserver.ProxyTestOptions, idx int, lookup *dnstest.MockLookup) (sfu.SFU, *goconf.ConfigFile) { + t.Helper() + require := require.New(t) + require.NotEmpty(options.Servers) + if options.Etcd == nil { + options.Etcd = etcdtest.NewServerForTest(t) + } + grpcClients, dnsMonitor := grpctest.NewClientsWithEtcdForTest(t, options.Etcd, lookup) + + tokenKey, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(err) + dir := t.TempDir() + privkeyFile := path.Join(dir, "privkey.pem") + pubkeyFile := path.Join(dir, "pubkey.pem") + require.NoError(internal.WritePrivateKey(tokenKey, privkeyFile)) + require.NoError(internal.WritePublicKey(&tokenKey.PublicKey, pubkeyFile)) + + cfg := goconf.NewConfigFile() + cfg.AddOption("mcu", "urltype", "static") + if strings.Contains(t.Name(), "DnsDiscovery") { + cfg.AddOption("mcu", "dnsdiscovery", "true") + } + cfg.AddOption("mcu", "proxytimeout", strconv.Itoa(int(testTimeout.Seconds()))) + var urls []string + waitingMap := make(map[string]bool) + tokenId := fmt.Sprintf("test-token-%d", idx) + for _, s := range options.Servers { + s.SetServers(options.Servers) + s.SetToken(tokenId, &tokenKey.PublicKey) + urls = append(urls, s.URL()) + waitingMap[s.URL()] = true + } + cfg.AddOption("mcu", "url", strings.Join(urls, " ")) + cfg.AddOption("mcu", "token_id", tokenId) + cfg.AddOption("mcu", "token_key", privkeyFile) + + etcdConfig := goconf.NewConfigFile() + etcdConfig.AddOption("etcd", "endpoints", options.Etcd.URL().String()) + etcdConfig.AddOption("etcd", "loglevel", "error") + + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) + etcdClient, err := etcd.NewClient(logger, etcdConfig, "") + require.NoError(err) + t.Cleanup(func() { + assert.NoError(t, etcdClient.Close()) + }) + + mcu, err := proxy.NewProxySFU(ctx, cfg, etcdClient, grpcClients, dnsMonitor) + require.NoError(err) + t.Cleanup(func() { + mcu.Stop() + }) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + require.NoError(mcu.Start(ctx)) + + waiter, ok := mcu.(ConnectionWaiter) + require.True(ok, "can't wait for connections") + + require.NoError(waiter.WaitForConnections(ctx)) + require.NoError(waiter.WaitForConnectionsEstablished(ctx, waitingMap)) + + return mcu, cfg +} diff --git a/sfu/proxy/testserver/server.go b/sfu/proxy/testserver/server.go new file mode 100644 index 0000000..c0c25e4 --- /dev/null +++ b/sfu/proxy/testserver/server.go @@ -0,0 +1,753 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2026 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package testserver + +import ( + "context" + "crypto/rsa" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + etcdtest "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/proxy" +) + +const ( + TimeoutTestTimeout = 100 * time.Millisecond +) + +type ProxyTestServer interface { + URL() string + SetServers(servers []ProxyTestServer) + SetToken(tokenId string, key *rsa.PublicKey) + + getToken(tokenId string) (*rsa.PublicKey, bool) + getPublisher(id api.PublicSessionId) *testProxyServerPublisher +} + +type ProxyTestOptions struct { + Etcd *etcdtest.EtcdServer + Servers []ProxyTestServer +} + +type proxyServerClientHandler func(msg *proxy.ClientMessage) (*proxy.ServerMessage, error) + +type testProxyServerPublisher struct { + id api.PublicSessionId +} + +type testProxyServerSubscriber struct { + id string + sid string + pub *testProxyServerPublisher + + remoteUrl string +} + +type TestProxyClient interface { + Close() + SendMessage(msg *proxy.ServerMessage) +} + +type testProxyServerClient struct { + t *testing.T + + server *TestProxyServerHandler + // +checklocks:mu + ws *websocket.Conn + processMessage proxyServerClientHandler + + mu sync.Mutex + sessionId api.PublicSessionId +} + +func (c *testProxyServerClient) processHello(msg *proxy.ClientMessage) (*proxy.ServerMessage, error) { + if msg.Type != "hello" { + return nil, fmt.Errorf("expected hello, got %+v", msg) + } + + if msg.Hello.ResumeId != "" { + client := c.server.getClient(msg.Hello.ResumeId) + if client == nil { + response := &proxy.ServerMessage{ + Id: msg.Id, + Type: "error", + Error: &api.Error{ + Code: "no_such_session", + }, + } + return response, nil + } + + c.sessionId = msg.Hello.ResumeId + c.server.setClient(c.sessionId, c) + response := &proxy.ServerMessage{ + Id: msg.Id, + Type: "hello", + Hello: &proxy.HelloServerMessage{ + Version: "1.0", + SessionId: c.sessionId, + Server: &api.WelcomeServerMessage{ + Version: "1.0", + Country: c.server.country, + }, + }, + } + c.processMessage = c.processRegularMessage + return response, nil + } + + token, err := jwt.ParseWithClaims(msg.Hello.Token, &proxy.TokenClaims{}, func(token *jwt.Token) (any, error) { + claims, ok := token.Claims.(*proxy.TokenClaims) + if !assert.True(c.t, ok, "unsupported claims type: %+v", token.Claims) { + return nil, errors.New("unsupported claims type") + } + + key, found := c.server.Tokens[claims.Issuer] + if !assert.True(c.t, found) { + return nil, errors.New("no key found for issuer") + } + + return key, nil + }) + if assert.NoError(c.t, err) { + if assert.True(c.t, token.Valid) { + _, ok := token.Claims.(*proxy.TokenClaims) + assert.True(c.t, ok) + } + } + + response := &proxy.ServerMessage{ + Id: msg.Id, + Type: "hello", + Hello: &proxy.HelloServerMessage{ + Version: "1.0", + SessionId: c.sessionId, + Server: &api.WelcomeServerMessage{ + Version: "1.0", + Country: c.server.country, + }, + }, + } + c.processMessage = c.processRegularMessage + return response, nil +} + +func (c *testProxyServerClient) processRegularMessage(msg *proxy.ClientMessage) (*proxy.ServerMessage, error) { + var handler proxyServerClientHandler + switch msg.Type { + case "command": + handler = c.processCommandMessage + } + + if handler == nil { + response := msg.NewWrappedErrorServerMessage(fmt.Errorf("type \"%s\" is not implemented", msg.Type)) + return response, nil + } + + return handler(msg) +} + +func (c *testProxyServerClient) processCommandMessage(msg *proxy.ClientMessage) (*proxy.ServerMessage, error) { + var response *proxy.ServerMessage + switch msg.Command.Type { + case "create-publisher": + if strings.Contains(c.t.Name(), "ProxyPublisherTimeout") { + time.Sleep(2 * TimeoutTestTimeout) + defer c.server.Wakeup() + } + pub := c.server.createPublisher() + + if assert.NotNil(c.t, msg.Command.PublisherSettings) { + if assert.NotEqualValues(c.t, 0, msg.Command.PublisherSettings.Bitrate) { + assert.Equal(c.t, msg.Command.Bitrate, msg.Command.PublisherSettings.Bitrate) // nolint + } + assert.Equal(c.t, msg.Command.MediaTypes, msg.Command.PublisherSettings.MediaTypes) // nolint + if strings.Contains(c.t.Name(), "Codecs") { + assert.Equal(c.t, "opus,g722", msg.Command.PublisherSettings.AudioCodec) + assert.Equal(c.t, "vp9,vp8,av1", msg.Command.PublisherSettings.VideoCodec) + } else { + assert.Empty(c.t, msg.Command.PublisherSettings.AudioCodec) + assert.Empty(c.t, msg.Command.PublisherSettings.VideoCodec) + } + } + + response = &proxy.ServerMessage{ + Id: msg.Id, + Type: "command", + Command: &proxy.CommandServerMessage{ + Id: string(pub.id), + Bitrate: msg.Command.Bitrate, // nolint + }, + } + c.server.updateLoad(1) + case "delete-publisher": + if strings.Contains(c.t.Name(), "ProxyPublisherTimeout") { + defer c.server.Wakeup() + } + + if pub, found := c.server.deletePublisher(api.PublicSessionId(msg.Command.ClientId)); !found { + response = msg.NewWrappedErrorServerMessage(fmt.Errorf("publisher %s not found", msg.Command.ClientId)) + } else { + response = &proxy.ServerMessage{ + Id: msg.Id, + Type: "command", + Command: &proxy.CommandServerMessage{ + Id: string(pub.id), + }, + } + c.server.updateLoad(-1) + } + case "create-subscriber": + var pub *testProxyServerPublisher + if msg.Command.RemoteUrl != "" { + for _, server := range c.server.Servers { + if server.URL() != msg.Command.RemoteUrl { + continue + } + + token, err := jwt.ParseWithClaims(msg.Command.RemoteToken, &proxy.TokenClaims{}, func(token *jwt.Token) (any, error) { + claims, ok := token.Claims.(*proxy.TokenClaims) + if !assert.True(c.t, ok, "unsupported claims type: %+v", token.Claims) { + return nil, errors.New("unsupported claims type") + } + + key, found := server.getToken(claims.Issuer) + if !assert.True(c.t, found) { + return nil, errors.New("no key found for issuer") + } + + return key, nil + }) + if assert.NoError(c.t, err) { + if claims, ok := token.Claims.(*proxy.TokenClaims); assert.True(c.t, token.Valid) && assert.True(c.t, ok) { + assert.EqualValues(c.t, msg.Command.PublisherId, claims.Subject) + } + } + + pub = server.getPublisher(msg.Command.PublisherId) + break + } + } else { + pub = c.server.getPublisher(msg.Command.PublisherId) + } + + if pub == nil { + response = msg.NewWrappedErrorServerMessage(fmt.Errorf("publisher %s not found", msg.Command.PublisherId)) + } else { + if strings.Contains(c.t.Name(), "ProxySubscriberTimeout") { + time.Sleep(2 * TimeoutTestTimeout) + defer c.server.Wakeup() + } + sub := c.server.createSubscriber(pub) + response = &proxy.ServerMessage{ + Id: msg.Id, + Type: "command", + Command: &proxy.CommandServerMessage{ + Id: sub.id, + Sid: sub.sid, + }, + } + c.server.updateLoad(1) + } + case "delete-subscriber": + if strings.Contains(c.t.Name(), "ProxySubscriberTimeout") { + defer c.server.Wakeup() + } + if sub, found := c.server.deleteSubscriber(msg.Command.ClientId); !found { + response = msg.NewWrappedErrorServerMessage(fmt.Errorf("subscriber %s not found", msg.Command.ClientId)) + } else { + if msg.Command.RemoteUrl != sub.remoteUrl { + response = msg.NewWrappedErrorServerMessage(fmt.Errorf("remote subscriber %s not found", msg.Command.ClientId)) + return response, nil + } + + response = &proxy.ServerMessage{ + Id: msg.Id, + Type: "command", + Command: &proxy.CommandServerMessage{ + Id: sub.id, + }, + } + c.server.updateLoad(-1) + } + } + if response == nil { + response = msg.NewWrappedErrorServerMessage(fmt.Errorf("command \"%s\" is not implemented", msg.Command.Type)) + } + + return response, nil +} + +func (c *testProxyServerClient) Close() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.ws != nil { + c.ws.Close() + c.ws = nil + } +} + +func (c *testProxyServerClient) handleSendMessageError(fmt string, msg *proxy.ServerMessage, err error) { + c.t.Helper() + + if !errors.Is(err, websocket.ErrCloseSent) || msg.Type != "event" || msg.Event.Type != "update-load" { + assert.Fail(c.t, "error while sending message", fmt, msg, err) + } +} + +func (c *testProxyServerClient) SendMessage(msg *proxy.ServerMessage) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.ws == nil { + return + } + + data, err := json.Marshal(msg) + if err != nil { + c.handleSendMessageError("error marshalling %+v: %s", msg, err) + return + } + + w, err := c.ws.NextWriter(websocket.TextMessage) + if err != nil { + c.handleSendMessageError("error creating writer for %+v: %s", msg, err) + return + } + + if _, err := w.Write(data); err != nil { + c.handleSendMessageError("error sending %+v: %s", msg, err) + return + } + + if err := w.Close(); err != nil { + c.handleSendMessageError("error during close of sending %+v: %s", msg, err) + } + + if msg.CloseAfterSend(nil) { + c.ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) // nolint + c.ws.Close() + } +} + +func (c *testProxyServerClient) run() { + defer func() { + c.mu.Lock() + defer c.mu.Unlock() + + c.server.expireSession(30*time.Second, c) + c.ws = nil + }() + c.processMessage = c.processHello + assert := assert.New(c.t) + for { + c.mu.Lock() + ws := c.ws + c.mu.Unlock() + if ws == nil { + break + } + + msgType, reader, err := ws.NextReader() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure) { + assert.NoError(err) + } + return + } + + body, err := io.ReadAll(reader) + if !assert.NoError(err) { + continue + } + + if !assert.Equal(websocket.TextMessage, msgType, "unexpected message type for %s", string(body)) { + continue + } + + var msg proxy.ClientMessage + if err := json.Unmarshal(body, &msg); !assert.NoError(err, "could not decode message %s", string(body)) { + continue + } + + if err := msg.CheckValid(); !assert.NoError(err, "invalid message %s", string(body)) { + continue + } + + response, err := c.processMessage(&msg) + if !assert.NoError(err) { + continue + } + + c.SendMessage(response) + if response.Type == "hello" { + c.server.sendLoad(c) + } + } +} + +type TestProxyServerHandler struct { + t *testing.T + + url string + server *httptest.Server + Servers []ProxyTestServer + Tokens map[string]*rsa.PublicKey + upgrader *websocket.Upgrader + country geoip.Country + + mu sync.Mutex + load atomic.Uint64 + incoming atomic.Pointer[float64] + outgoing atomic.Pointer[float64] + // +checklocks:mu + clients map[api.PublicSessionId]*testProxyServerClient + // +checklocks:mu + publishers map[api.PublicSessionId]*testProxyServerPublisher + // +checklocks:mu + subscribers map[string]*testProxyServerSubscriber + + wakeupChan chan struct{} +} + +func (h *TestProxyServerHandler) Start() { + h.server.Start() + h.url = h.server.URL +} + +func (h *TestProxyServerHandler) URL() string { + return h.url +} + +func (h *TestProxyServerHandler) SetURL(url string) { + h.url = url +} + +func (h *TestProxyServerHandler) Listener() net.Listener { + return h.server.Listener +} + +func (h *TestProxyServerHandler) SetListener(listener net.Listener) { + h.server.Listener = listener +} + +func (h *TestProxyServerHandler) SetServers(servers []ProxyTestServer) { + h.Servers = servers +} + +func (h *TestProxyServerHandler) SetToken(tokenId string, key *rsa.PublicKey) { + h.Tokens[tokenId] = key +} + +func (h *TestProxyServerHandler) getToken(tokenId string) (key *rsa.PublicKey, found bool) { + key, found = h.Tokens[tokenId] + return +} + +func (h *TestProxyServerHandler) createPublisher() *testProxyServerPublisher { + h.mu.Lock() + defer h.mu.Unlock() + pub := &testProxyServerPublisher{ + id: api.PublicSessionId(internal.RandomString(32)), + } + + for { + if _, found := h.publishers[pub.id]; !found { + break + } + + pub.id = api.PublicSessionId(internal.RandomString(32)) + } + h.publishers[pub.id] = pub + return pub +} + +func (h *TestProxyServerHandler) getPublisher(id api.PublicSessionId) *testProxyServerPublisher { + h.mu.Lock() + defer h.mu.Unlock() + + return h.publishers[id] +} + +func (h *TestProxyServerHandler) deletePublisher(id api.PublicSessionId) (*testProxyServerPublisher, bool) { + h.mu.Lock() + defer h.mu.Unlock() + + pub, found := h.publishers[id] + if !found { + return nil, false + } + + delete(h.publishers, id) + return pub, true +} + +func (h *TestProxyServerHandler) createSubscriber(pub *testProxyServerPublisher) *testProxyServerSubscriber { + h.mu.Lock() + defer h.mu.Unlock() + + sub := &testProxyServerSubscriber{ + id: internal.RandomString(32), + sid: internal.RandomString(8), + pub: pub, + } + + for { + if _, found := h.subscribers[sub.id]; !found { + break + } + + sub.id = internal.RandomString(32) + } + h.subscribers[sub.id] = sub + return sub +} + +func (h *TestProxyServerHandler) deleteSubscriber(id string) (*testProxyServerSubscriber, bool) { + h.mu.Lock() + defer h.mu.Unlock() + + sub, found := h.subscribers[id] + if !found { + return nil, false + } + + delete(h.subscribers, id) + return sub, true +} + +func (h *TestProxyServerHandler) UpdateBandwidth(incoming float64, outgoing float64) { + h.incoming.Store(&incoming) + h.outgoing.Store(&outgoing) + + h.mu.Lock() + defer h.mu.Unlock() + + msg := h.getLoadMessage(h.load.Load()) + for _, c := range h.clients { + c.SendMessage(msg) + } +} + +func (h *TestProxyServerHandler) Clear(incoming bool, outgoing bool) { + if incoming { + h.incoming.Store(nil) + } + if outgoing { + h.outgoing.Store(nil) + } + + h.mu.Lock() + defer h.mu.Unlock() + + msg := h.getLoadMessage(h.load.Load()) + for _, c := range h.clients { + c.SendMessage(msg) + } +} + +func (h *TestProxyServerHandler) getLoadMessage(load uint64) *proxy.ServerMessage { + msg := &proxy.ServerMessage{ + Type: "event", + Event: &proxy.EventServerMessage{ + Type: "update-load", + Load: load, + }, + } + + incoming := h.incoming.Load() + outgoing := h.outgoing.Load() + if incoming != nil || outgoing != nil { + msg.Event.Bandwidth = &proxy.EventServerBandwidth{ + Incoming: incoming, + Outgoing: outgoing, + } + } + return msg +} + +func (h *TestProxyServerHandler) updateLoad(delta int64) { + if delta == 0 { + return + } + + var load uint64 + if delta > 0 { + load = h.load.Add(uint64(delta)) + } else { + load = h.load.Add(^uint64(delta - 1)) + } + + h.mu.Lock() + defer h.mu.Unlock() + + msg := h.getLoadMessage(load) + for _, c := range h.clients { + c.SendMessage(msg) + } +} + +func (h *TestProxyServerHandler) sendLoad(c *testProxyServerClient) { + msg := h.getLoadMessage(h.load.Load()) + c.SendMessage(msg) +} + +func (h *TestProxyServerHandler) expireSession(timeout time.Duration, client *testProxyServerClient) { + timer := time.AfterFunc(timeout, func() { + h.removeClient(client) + }) + + h.t.Cleanup(func() { + timer.Stop() + }) +} + +func (h *TestProxyServerHandler) removeClient(client *testProxyServerClient) { + h.mu.Lock() + defer h.mu.Unlock() + + delete(h.clients, client.sessionId) +} + +func (h *TestProxyServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ws, err := h.upgrader.Upgrade(w, r, nil) + if !assert.NoError(h.t, err) { + return + } + + client := &testProxyServerClient{ + t: h.t, + server: h, + ws: ws, + sessionId: api.PublicSessionId(internal.RandomString(32)), + } + + h.setClient(client.sessionId, client) + + go client.run() +} + +func (h *TestProxyServerHandler) getClient(sessionId api.PublicSessionId) *testProxyServerClient { + h.mu.Lock() + defer h.mu.Unlock() + + return h.clients[sessionId] +} + +func (h *TestProxyServerHandler) setClient(sessionId api.PublicSessionId, client *testProxyServerClient) { + h.mu.Lock() + defer h.mu.Unlock() + + if prev, found := h.clients[sessionId]; found { + prev.SendMessage(&proxy.ServerMessage{ + Type: "bye", + Bye: &proxy.ByeServerMessage{ + Reason: "session_resumed", + }, + }) + prev.Close() + } + + h.clients[sessionId] = client +} + +func (h *TestProxyServerHandler) Wakeup() { + h.wakeupChan <- struct{}{} +} + +func (h *TestProxyServerHandler) WaitForWakeup(ctx context.Context) error { + select { + case <-h.wakeupChan: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (h *TestProxyServerHandler) GetSingleClient() TestProxyClient { + h.mu.Lock() + defer h.mu.Unlock() + + for _, c := range h.clients { + return c + } + + return nil +} + +func (h *TestProxyServerHandler) ClearClients() { + h.mu.Lock() + defer h.mu.Unlock() + + clear(h.clients) +} + +func NewProxyServerForTest(t *testing.T, country geoip.Country) *TestProxyServerHandler { + t.Helper() + + upgrader := websocket.Upgrader{} + proxyHandler := &TestProxyServerHandler{ + t: t, + Tokens: make(map[string]*rsa.PublicKey), + upgrader: &upgrader, + country: country, + clients: make(map[api.PublicSessionId]*testProxyServerClient), + publishers: make(map[api.PublicSessionId]*testProxyServerPublisher), + subscribers: make(map[string]*testProxyServerSubscriber), + wakeupChan: make(chan struct{}), + } + server := httptest.NewUnstartedServer(proxyHandler) + if !strings.Contains(t.Name(), "DnsDiscovery") { + server.Start() + } + proxyHandler.server = server + proxyHandler.url = server.URL + t.Cleanup(func() { + server.Close() + proxyHandler.mu.Lock() + defer proxyHandler.mu.Unlock() + for _, c := range proxyHandler.clients { + c.Close() + } + }) + + return proxyHandler +} diff --git a/sfu/test/sfu.go b/sfu/test/sfu.go new file mode 100644 index 0000000..6b64edc --- /dev/null +++ b/sfu/test/sfu.go @@ -0,0 +1,329 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2019 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "context" + "errors" + "fmt" + "maps" + "net" + "sync" + "sync/atomic" + "testing" + + "github.com/dlintw/goconf" + "github.com/stretchr/testify/assert" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/mock" + "github.com/strukturag/nextcloud-spreed-signaling/v2/sfu" + "github.com/strukturag/nextcloud-spreed-signaling/v2/talk" +) + +var ( + TestMaxBitrateScreen = api.BandwidthFromBits(12345678) + TestMaxBitrateVideo = api.BandwidthFromBits(23456789) +) + +type SFU struct { + t *testing.T + mu sync.Mutex + // +checklocks:mu + publishers map[api.PublicSessionId]*SFUPublisher + // +checklocks:mu + subscribers map[string]*SFUSubscriber + + maxStreamBitrate api.AtomicBandwidth + maxScreenBitrate api.AtomicBandwidth +} + +func NewSFU(t *testing.T) *SFU { + return &SFU{ + t: t, + + publishers: make(map[api.PublicSessionId]*SFUPublisher), + subscribers: make(map[string]*SFUSubscriber), + } +} + +func (m *SFU) GetBandwidthLimits() (api.Bandwidth, api.Bandwidth) { + return m.maxStreamBitrate.Load(), m.maxScreenBitrate.Load() +} + +func (m *SFU) SetBandwidthLimits(maxStreamBitrate api.Bandwidth, maxScreenBitrate api.Bandwidth) { + m.maxStreamBitrate.Store(maxStreamBitrate) + m.maxScreenBitrate.Store(maxScreenBitrate) +} + +func (m *SFU) Start(ctx context.Context) error { + return nil +} + +func (m *SFU) Stop() { +} + +func (m *SFU) Reload(config *goconf.ConfigFile) { +} + +func (m *SFU) SetOnConnected(f func()) { +} + +func (m *SFU) SetOnDisconnected(f func()) { +} + +func (m *SFU) GetStats() any { + return nil +} + +func (m *SFU) GetServerInfoSfu() *talk.BackendServerInfoSfu { + return nil +} + +func (m *SFU) NewPublisher(ctx context.Context, listener sfu.Listener, id api.PublicSessionId, sid string, streamType sfu.StreamType, settings sfu.NewPublisherSettings, initiator sfu.Initiator) (sfu.Publisher, error) { + var maxBitrate api.Bandwidth + if streamType == sfu.StreamTypeScreen { + maxBitrate = TestMaxBitrateScreen + } else { + maxBitrate = TestMaxBitrateVideo + } + publisherSettings := settings + bitrate := publisherSettings.Bitrate + if bitrate <= 0 || bitrate > maxBitrate { + publisherSettings.Bitrate = maxBitrate + } + pub := &SFUPublisher{ + SFUClient: SFUClient{ + t: m.t, + id: string(id), + sid: sid, + streamType: streamType, + }, + + settings: publisherSettings, + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.publishers[id] = pub + return pub, nil +} + +func (m *SFU) GetPublishers() map[api.PublicSessionId]*SFUPublisher { + m.mu.Lock() + defer m.mu.Unlock() + + result := maps.Clone(m.publishers) + return result +} + +func (m *SFU) GetPublisher(id api.PublicSessionId) *SFUPublisher { + m.mu.Lock() + defer m.mu.Unlock() + + return m.publishers[id] +} + +func (m *SFU) GetSubscriber(id api.PublicSessionId, streamType sfu.StreamType) *SFUSubscriber { + m.mu.Lock() + defer m.mu.Unlock() + + key := fmt.Sprintf("%s|%s", id, streamType) + return m.subscribers[key] +} + +func (m *SFU) NewSubscriber(ctx context.Context, listener sfu.Listener, publisher api.PublicSessionId, streamType sfu.StreamType, initiator sfu.Initiator) (sfu.Subscriber, error) { + m.mu.Lock() + defer m.mu.Unlock() + + pub := m.publishers[publisher] + if pub == nil { + return nil, errors.New("waiting for publisher not implemented yet") + } + + id := internal.RandomString(8) + sub := &SFUSubscriber{ + SFUClient: SFUClient{ + t: m.t, + id: id, + streamType: streamType, + }, + + publisher: pub, + } + key := fmt.Sprintf("%s|%s", publisher, streamType) + assert.Empty(m.t, m.subscribers[key], "duplicate subscriber") + m.subscribers[key] = sub + return sub, nil +} + +type SFUClient struct { + t *testing.T + closed atomic.Bool + + id string + sid string + streamType sfu.StreamType +} + +func (c *SFUClient) Id() string { + return c.id +} + +func (c *SFUClient) Sid() string { + return c.sid +} + +func (c *SFUClient) StreamType() sfu.StreamType { + return c.streamType +} + +func (c *SFUClient) MaxBitrate() api.Bandwidth { + return 0 +} + +func (c *SFUClient) Close(ctx context.Context) { + if c.closed.CompareAndSwap(false, true) { + logger := logtest.NewLoggerForTest(c.t) + logger.Printf("Close SFU client %s", c.id) + } +} + +func (c *SFUClient) IsClosed() bool { + return c.closed.Load() +} + +type SFUPublisher struct { + SFUClient + + settings sfu.NewPublisherSettings + + sdp string +} + +func (p *SFUPublisher) Settings() sfu.NewPublisherSettings { + return p.settings +} + +func (p *SFUPublisher) PublisherId() api.PublicSessionId { + return api.PublicSessionId(p.id) +} + +func (p *SFUPublisher) HasMedia(mt sfu.MediaType) bool { + return (p.settings.MediaTypes & mt) == mt +} + +func (p *SFUPublisher) SetMedia(mt sfu.MediaType) { + p.settings.MediaTypes = mt +} + +func (p *SFUPublisher) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { + go func() { + if p.IsClosed() { + callback(errors.New("already closed"), nil) + return + } + + switch data.Type { + case "offer": + sdp := data.Payload["sdp"] + if sdp, ok := sdp.(string); ok { + p.sdp = sdp + switch sdp { + case mock.MockSdpOfferAudioOnly: + callback(nil, api.StringMap{ + "type": "answer", + "sdp": mock.MockSdpAnswerAudioOnly, + }) + return + case mock.MockSdpOfferAudioAndVideo: + callback(nil, api.StringMap{ + "type": "answer", + "sdp": mock.MockSdpAnswerAudioAndVideo, + }) + return + } + } + callback(fmt.Errorf("offer payload %+v is not implemented", data.Payload), nil) + default: + callback(fmt.Errorf("message type %s is not implemented", data.Type), nil) + } + }() +} + +func (p *SFUPublisher) GetStreams(ctx context.Context) ([]sfu.PublisherStream, error) { + return nil, errors.New("not implemented") +} + +func (p *SFUPublisher) PublishRemote(ctx context.Context, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) error { + return errors.New("remote publishing not supported") +} + +func (p *SFUPublisher) UnpublishRemote(ctx context.Context, remoteId api.PublicSessionId, hostname string, port int, rtcpPort int) error { + return errors.New("remote publishing not supported") +} + +func (p *SFUPublisher) GetConnectionURL() (string, net.IP) { + return "https://proxy.domain.invalid", net.ParseIP("10.20.30.40") +} + +type SFUSubscriber struct { + SFUClient + + publisher *SFUPublisher +} + +func (s *SFUSubscriber) Publisher() api.PublicSessionId { + return s.publisher.PublisherId() +} + +func (s *SFUSubscriber) SendMessage(ctx context.Context, message *api.MessageClientMessage, data *api.MessageClientMessageData, callback func(error, api.StringMap)) { + go func() { + if s.IsClosed() { + callback(errors.New("already closed"), nil) + return + } + + switch data.Type { + case "requestoffer": + fallthrough + case "sendoffer": + sdp := s.publisher.sdp + if sdp == "" { + callback(errors.New("publisher not sending (no SDP)"), nil) + return + } + + callback(nil, api.StringMap{ + "type": "offer", + "sdp": sdp, + }) + case "answer": + callback(nil, nil) + default: + callback(fmt.Errorf("message type %s is not implemented", data.Type), nil) + } + }() +} diff --git a/single_notifier.go b/single_notifier.go deleted file mode 100644 index 921542a..0000000 --- a/single_notifier.go +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "sync" -) - -type SingleWaiter struct { - root bool - ch chan struct{} - once sync.Once -} - -func newSingleWaiter() *SingleWaiter { - return &SingleWaiter{ - root: true, - ch: make(chan struct{}), - } -} - -func (w *SingleWaiter) subWaiter() *SingleWaiter { - return &SingleWaiter{ - ch: w.ch, - } -} - -func (w *SingleWaiter) Wait(ctx context.Context) error { - select { - case <-w.ch: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func (w *SingleWaiter) cancel() { - if !w.root { - return - } - - w.once.Do(func() { - close(w.ch) - }) -} - -type SingleNotifier struct { - sync.Mutex - - waiter *SingleWaiter - waiters map[*SingleWaiter]bool -} - -func (n *SingleNotifier) NewWaiter() *SingleWaiter { - n.Lock() - defer n.Unlock() - - if n.waiter == nil { - n.waiter = newSingleWaiter() - } - - if n.waiters == nil { - n.waiters = make(map[*SingleWaiter]bool) - } - - w := n.waiter.subWaiter() - n.waiters[w] = true - return w -} - -func (n *SingleNotifier) Reset() { - n.Lock() - defer n.Unlock() - - if n.waiter != nil { - n.waiter.cancel() - n.waiter = nil - } - n.waiters = nil -} - -func (n *SingleNotifier) Release(w *SingleWaiter) { - n.Lock() - defer n.Unlock() - - if _, found := n.waiters[w]; found { - delete(n.waiters, w) - if len(n.waiters) == 0 { - n.waiters = nil - if n.waiter != nil { - n.waiter.cancel() - n.waiter = nil - } - } - } -} - -func (n *SingleNotifier) Notify() { - n.Lock() - defer n.Unlock() - - if n.waiter != nil { - n.waiter.cancel() - } - n.waiters = nil -} diff --git a/single_notifier_test.go b/single_notifier_test.go deleted file mode 100644 index 7b95beb..0000000 --- a/single_notifier_test.go +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2022 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestSingleNotifierNoWaiter(t *testing.T) { - var notifier SingleNotifier - - // Notifications can be sent even if no waiter exists. - notifier.Notify() -} - -func TestSingleNotifierSimple(t *testing.T) { - var notifier SingleNotifier - - var wg sync.WaitGroup - wg.Add(1) - - waiter := notifier.NewWaiter() - defer notifier.Release(waiter) - - go func() { - defer wg.Done() - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - assert.NoError(t, waiter.Wait(ctx)) - }() - - notifier.Notify() - wg.Wait() -} - -func TestSingleNotifierMultiNotify(t *testing.T) { - var notifier SingleNotifier - - waiter := notifier.NewWaiter() - defer notifier.Release(waiter) - - notifier.Notify() - // The second notification will be ignored while the first is still pending. - notifier.Notify() -} - -func TestSingleNotifierWaitClosed(t *testing.T) { - var notifier SingleNotifier - - waiter := notifier.NewWaiter() - notifier.Release(waiter) - - assert.NoError(t, waiter.Wait(context.Background())) -} - -func TestSingleNotifierWaitClosedMulti(t *testing.T) { - var notifier SingleNotifier - - waiter1 := notifier.NewWaiter() - waiter2 := notifier.NewWaiter() - notifier.Release(waiter1) - notifier.Release(waiter2) - - assert.NoError(t, waiter1.Wait(context.Background())) - assert.NoError(t, waiter2.Wait(context.Background())) -} - -func TestSingleNotifierResetWillNotify(t *testing.T) { - var notifier SingleNotifier - - var wg sync.WaitGroup - wg.Add(1) - - waiter := notifier.NewWaiter() - defer notifier.Release(waiter) - - go func() { - defer wg.Done() - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - assert.NoError(t, waiter.Wait(ctx)) - }() - - notifier.Reset() - wg.Wait() -} - -func TestSingleNotifierDuplicate(t *testing.T) { - t.Parallel() - var notifier SingleNotifier - var wgStart sync.WaitGroup - var wgEnd sync.WaitGroup - - for i := 0; i < 2; i++ { - wgStart.Add(1) - wgEnd.Add(1) - - go func() { - defer wgEnd.Done() - waiter := notifier.NewWaiter() - defer notifier.Release(waiter) - - // Goroutine has created the waiter and is ready. - wgStart.Done() - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - assert.NoError(t, waiter.Wait(ctx)) - }() - } - - wgStart.Wait() - - time.Sleep(100 * time.Millisecond) - notifier.Notify() - wgEnd.Wait() -} diff --git a/api_backend.go b/talk/api.go similarity index 60% rename from api_backend.go rename to talk/api.go index 2fd0bc9..1bc0032 100644 --- a/api_backend.go +++ b/talk/api.go @@ -19,21 +19,23 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( "crypto/hmac" - "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "net/http" - "net/url" "regexp" - "strings" "time" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" ) const ( @@ -49,14 +51,6 @@ const ( ConfigKeySessionPingLimit = "session-ping-limit" ) -func newRandomString(length int) string { - b := make([]byte, length/2) - if _, err := rand.Read(b); err != nil { - panic(err) - } - return hex.EncodeToString(b) -} - func CalculateBackendChecksum(random string, body []byte, secret []byte) string { mac := hmac.New(sha256.New, secret) mac.Write([]byte(random)) // nolint @@ -66,7 +60,7 @@ func CalculateBackendChecksum(random string, body []byte, secret []byte) string func AddBackendChecksum(r *http.Request, body []byte, secret []byte) { // Add checksum so the backend can validate the request. - rnd := newRandomString(64) + rnd := internal.RandomString(64) checksum := CalculateBackendChecksum(rnd, body, secret) r.Header.Set(HeaderBackendSignalingRandom, rnd) r.Header.Set(HeaderBackendSignalingChecksum, checksum) @@ -86,7 +80,8 @@ func ValidateBackendChecksumValue(checksum string, random string, body []byte, s // Requests from Nextcloud to the signaling server. type BackendServerRoomRequest struct { - room *Room + RoomId string `json:"-"` + Backend *Backend `json:"-"` Type string `json:"type"` @@ -123,8 +118,8 @@ type BackendRoomInviteRequest struct { } type BackendRoomDisinviteRequest struct { - UserIds []string `json:"userids,omitempty"` - SessionIds []string `json:"sessionids,omitempty"` + UserIds []string `json:"userids,omitempty"` + SessionIds []api.RoomSessionId `json:"sessionids,omitempty"` // TODO(jojo): We should get rid of "AllUserIds" and find a better way to // notify existing users the room has changed and they need to update it. AllUserIds []string `json:"alluserids,omitempty"` @@ -142,23 +137,26 @@ type BackendRoomDeleteRequest struct { type BackendRoomInCallRequest struct { // TODO(jojo): Change "InCall" to "int" when #914 has landed in NC Talk. - InCall json.RawMessage `json:"incall,omitempty"` - All bool `json:"all,omitempty"` - Changed []map[string]interface{} `json:"changed,omitempty"` - Users []map[string]interface{} `json:"users,omitempty"` + InCall json.RawMessage `json:"incall,omitempty"` + All bool `json:"all,omitempty"` + Changed []api.StringMap `json:"changed,omitempty"` + Users []api.StringMap `json:"users,omitempty"` } type BackendRoomParticipantsRequest struct { - Changed []map[string]interface{} `json:"changed,omitempty"` - Users []map[string]interface{} `json:"users,omitempty"` + Changed []api.StringMap `json:"changed,omitempty"` + Users []api.StringMap `json:"users,omitempty"` } type BackendRoomMessageRequest struct { Data json.RawMessage `json:"data,omitempty"` } -type BackendRoomSwitchToSessionsList []string -type BackendRoomSwitchToSessionsMap map[string]json.RawMessage +type BackendRoomSwitchToSessionsList []api.RoomSessionId +type BackendRoomSwitchToSessionsMap map[api.RoomSessionId]json.RawMessage + +type BackendRoomSwitchToPublicSessionsList []api.PublicSessionId +type BackendRoomSwitchToPublicSessionsMap map[api.PublicSessionId]json.RawMessage type BackendRoomSwitchToMessageRequest struct { // Target room id @@ -172,8 +170,8 @@ type BackendRoomSwitchToMessageRequest struct { Sessions json.RawMessage `json:"sessions,omitempty"` // Internal properties - SessionsList BackendRoomSwitchToSessionsList `json:"sessionslist,omitempty"` - SessionsMap BackendRoomSwitchToSessionsMap `json:"sessionsmap,omitempty"` + SessionsList BackendRoomSwitchToPublicSessionsList `json:"sessionslist,omitempty"` + SessionsMap BackendRoomSwitchToPublicSessionsMap `json:"sessionsmap,omitempty"` } type BackendRoomDialoutRequest struct { @@ -191,18 +189,26 @@ func isValidNumber(s string) bool { return checkE164Number.MatchString(s) } -func (r *BackendRoomDialoutRequest) ValidateNumber() *Error { +func (r *BackendRoomDialoutRequest) ValidateNumber() *api.Error { if r.Number == "" { - return NewError("number_missing", "No number provided") + return api.NewError("number_missing", "No number provided") } if !isValidNumber(r.Number) { - return NewError("invalid_number", "Expected E.164 number.") + return api.NewError("invalid_number", "Expected E.164 number.") } return nil } +func (r *BackendRoomDialoutRequest) String() string { + data, err := json.Marshal(r) + if err != nil { + return fmt.Sprintf("Could not serialize %#v: %s", r, err) + } + return string(data) +} + type TransientAction string const ( @@ -213,7 +219,7 @@ const ( type BackendRoomTransientRequest struct { Action TransientAction `json:"action"` Key string `json:"key"` - Value interface{} `json:"value,omitempty"` + Value any `json:"value,omitempty"` TTL time.Duration `json:"ttl,omitempty"` } @@ -231,7 +237,7 @@ type BackendRoomDialoutError struct { type BackendRoomDialoutResponse struct { CallId string `json:"callid,omitempty"` - Error *Error `json:"error,omitempty"` + Error *api.Error `json:"error,omitempty"` } // Requests from the signaling server to the Nextcloud backend. @@ -272,7 +278,7 @@ type BackendClientResponse struct { Type string `json:"type"` - Error *Error `json:"error,omitempty"` + Error *api.Error `json:"error,omitempty"` Auth *BackendClientAuthResponse `json:"auth,omitempty"` @@ -290,11 +296,11 @@ type BackendClientAuthResponse struct { } type BackendClientRoomRequest struct { - Version string `json:"version"` - RoomId string `json:"roomid"` - Action string `json:"action,omitempty"` - UserId string `json:"userid"` - SessionId string `json:"sessionid"` + Version string `json:"version"` + RoomId string `json:"roomid"` + Action string `json:"action,omitempty"` + UserId string `json:"userid"` + SessionId api.RoomSessionId `json:"sessionid"` // For Nextcloud Talk with SIP support and for federated sessions. ActorId string `json:"actorid,omitempty"` @@ -302,12 +308,17 @@ type BackendClientRoomRequest struct { InCall int `json:"incall,omitempty"` } -func (r *BackendClientRoomRequest) UpdateFromSession(s Session) { - if s.ClientType() == HelloClientTypeFederation { +type SessionWithUserData interface { + ClientType() api.ClientType + ParsedUserData() (api.StringMap, error) +} + +func (r *BackendClientRoomRequest) UpdateFromSession(s SessionWithUserData) { + if s.ClientType() == api.HelloClientTypeFederation { // Need to send additional data for requests of federated users. if u, err := s.ParsedUserData(); err == nil && len(u) > 0 { - if actorType, found := getStringMapEntry[string](u, "actorType"); found { - if actorId, found := getStringMapEntry[string](u, "actorId"); found { + if actorType, found := api.GetStringMapEntry[string](u, "actorType"); found { + if actorId, found := api.GetStringMapEntry[string](u, "actorId"); found { r.ActorId = actorId r.ActorType = actorType } @@ -316,7 +327,7 @@ func (r *BackendClientRoomRequest) UpdateFromSession(s Session) { } } -func NewBackendClientRoomRequest(roomid string, userid string, sessionid string) *BackendClientRequest { +func NewBackendClientRoomRequest(roomid string, userid string, sessionid api.RoomSessionId) *BackendClientRequest { return &BackendClientRequest{ Type: "room", Room: &BackendClientRoomRequest{ @@ -338,7 +349,7 @@ type BackendClientRoomResponse struct { // See "RoomSessionData" for a possible content. Session json.RawMessage `json:"session,omitempty"` - Permissions *[]Permission `json:"permissions,omitempty"` + Permissions *[]api.Permission `json:"permissions,omitempty"` } type RoomSessionData struct { @@ -346,8 +357,8 @@ type RoomSessionData struct { } type BackendPingEntry struct { - UserId string `json:"userid,omitempty"` - SessionId string `json:"sessionid"` + UserId string `json:"userid,omitempty"` + SessionId api.RoomSessionId `json:"sessionid"` } type BackendClientPingRequest struct { @@ -373,12 +384,12 @@ type BackendClientRingResponse struct { } type BackendClientSessionRequest struct { - Version string `json:"version"` - RoomId string `json:"roomid"` - Action string `json:"action"` - SessionId string `json:"sessionid"` - UserId string `json:"userid,omitempty"` - User json.RawMessage `json:"user,omitempty"` + Version string `json:"version"` + RoomId string `json:"roomid"` + Action string `json:"action"` + SessionId api.PublicSessionId `json:"sessionid"` + UserId string `json:"userid,omitempty"` + User json.RawMessage `json:"user,omitempty"` } type BackendClientSessionResponse struct { @@ -386,7 +397,7 @@ type BackendClientSessionResponse struct { RoomId string `json:"roomid"` } -func NewBackendClientSessionRequest(roomid string, action string, sessionid string, msg *AddSessionInternalClientMessage) *BackendClientRequest { +func NewBackendClientSessionRequest(roomid string, action string, sessionid api.PublicSessionId, msg *api.AddSessionInternalClientMessage) *BackendClientRequest { request := &BackendClientRequest{ Type: "session", Session: &BackendClientSessionRequest{ @@ -403,24 +414,6 @@ func NewBackendClientSessionRequest(roomid string, action string, sessionid stri return request } -type OcsMeta struct { - Status string `json:"status"` - StatusCode int `json:"statuscode"` - Message string `json:"message"` -} - -type OcsBody struct { - Meta OcsMeta `json:"meta"` - Data json.RawMessage `json:"data"` -} - -type OcsResponse struct { - json.Marshaler - json.Unmarshaler - - Ocs *OcsBody `json:"ocs"` -} - // See https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 type TurnCredentials struct { Username string `json:"username"` @@ -429,38 +422,107 @@ type TurnCredentials struct { URIs []string `json:"uris"` } -// Information on a backend in the etcd cluster. - -type BackendInformationEtcd struct { - parsedUrl *url.URL - - Url string `json:"url"` - Secret string `json:"secret"` - - MaxStreamBitrate int `json:"maxstreambitrate,omitempty"` - MaxScreenBitrate int `json:"maxscreenbitrate,omitempty"` - - SessionLimit uint64 `json:"sessionlimit,omitempty"` +type BackendServerInfoVideoRoom struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Author string `json:"author,omitempty"` } -func (p *BackendInformationEtcd) CheckValid() error { - if p.Url == "" { - return fmt.Errorf("url missing") - } - if p.Secret == "" { - return fmt.Errorf("secret missing") - } +type BackendServerInfoSfuJanus struct { + Url string `json:"url"` - parsedUrl, err := url.Parse(p.Url) - if err != nil { - return fmt.Errorf("invalid url: %w", err) - } + Connected bool `json:"connected"` - if strings.Contains(parsedUrl.Host, ":") && hasStandardPort(parsedUrl) { - parsedUrl.Host = parsedUrl.Hostname() - p.Url = parsedUrl.String() - } + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Author string `json:"author,omitempty"` - p.parsedUrl = parsedUrl - return nil + DataChannels *bool `json:"datachannels,omitempty"` + FullTrickle *bool `json:"fulltrickle,omitempty"` + LocalIP string `json:"localip,omitempty"` + IPv6 *bool `json:"ipv6,omitempty"` + + VideoRoom *BackendServerInfoVideoRoom `json:"videoroom,omitempty"` +} + +type BackendServerInfoSfuProxyBandwidth struct { + // Incoming is the bandwidth utilization for publishers in percent. + Incoming *float64 `json:"incoming,omitempty"` + // Outgoing is the bandwidth utilization for subscribers in percent. + Outgoing *float64 `json:"outgoing,omitempty"` + + // Received is the incoming bandwidth. + Received api.Bandwidth `json:"received,omitempty"` + // Sent is the outgoing bandwidth. + Sent api.Bandwidth `json:"sent,omitempty"` +} + +type BackendServerInfoSfuProxy struct { + Url string `json:"url"` + IP string `json:"ip,omitempty"` + + Connected bool `json:"connected"` + Temporary bool `json:"temporary"` + Shutdown *bool `json:"shutdown,omitempty"` + Uptime *time.Time `json:"uptime,omitempty"` + + Version string `json:"version,omitempty"` + Features []string `json:"features,omitempty"` + + Country geoip.Country `json:"country,omitempty"` + Load *uint64 `json:"load,omitempty"` + Bandwidth *BackendServerInfoSfuProxyBandwidth `json:"bandwidth,omitempty"` +} + +type SfuMode string + +const ( + SfuModeJanus SfuMode = "janus" + SfuModeProxy SfuMode = "proxy" +) + +type BackendServerInfoSfu struct { + Mode SfuMode `json:"mode"` + + Janus *BackendServerInfoSfuJanus `json:"janus,omitempty"` + Proxies []BackendServerInfoSfuProxy `json:"proxies,omitempty"` +} + +type BackendServerInfoDialout struct { + SessionId api.PublicSessionId `json:"sessionid"` + Connected bool `json:"connected"` + Address string `json:"address,omitempty"` + UserAgent string `json:"useragent,omitempty"` + Version string `json:"version,omitempty"` + Features []string `json:"features,omitempty"` +} + +type BackendServerInfoNats struct { + Urls []string `json:"urls"` + Connected bool `json:"connected"` + + ServerUrl string `json:"serverurl,omitempty"` + ServerID string `json:"serverid,omitempty"` + ServerVersion string `json:"version,omitempty"` + ClusterName string `json:"clustername,omitempty"` +} + +type BackendServerInfoGrpc struct { + Target string `json:"target"` + IP string `json:"ip,omitempty"` + Connected bool `json:"connected"` + + Version string `json:"version,omitempty"` +} + +type BackendServerInfo struct { + Version string `json:"version"` + Features []string `json:"features"` + + Sfu *BackendServerInfoSfu `json:"sfu,omitempty"` + Dialout []BackendServerInfoDialout `json:"dialout,omitempty"` + + Nats *BackendServerInfoNats `json:"nats,omitempty"` + Grpc []BackendServerInfoGrpc `json:"grpc,omitempty"` + Etcd *etcd.BackendServerInfoEtcd `json:"etcd,omitempty"` } diff --git a/talk/api_easyjson.go b/talk/api_easyjson.go new file mode 100644 index 0000000..d4703a3 --- /dev/null +++ b/talk/api_easyjson.go @@ -0,0 +1,4891 @@ +// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. + +package talk + +import ( + json "encoding/json" + easyjson "github.com/mailru/easyjson" + jlexer "github.com/mailru/easyjson/jlexer" + jwriter "github.com/mailru/easyjson/jwriter" + api "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + etcd "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + geoip "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" + time "time" +) + +// suppress unused package warning +var ( + _ *json.RawMessage + _ *jlexer.Lexer + _ *jwriter.Writer + _ easyjson.Marshaler +) + +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(in *jlexer.Lexer, out *TurnCredentials) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "username": + if in.IsNull() { + in.Skip() + } else { + out.Username = string(in.String()) + } + case "password": + if in.IsNull() { + in.Skip() + } else { + out.Password = string(in.String()) + } + case "ttl": + if in.IsNull() { + in.Skip() + } else { + out.TTL = int64(in.Int64()) + } + case "uris": + if in.IsNull() { + in.Skip() + out.URIs = nil + } else { + in.Delim('[') + if out.URIs == nil { + if !in.IsDelim(']') { + out.URIs = make([]string, 0, 4) + } else { + out.URIs = []string{} + } + } else { + out.URIs = (out.URIs)[:0] + } + for !in.IsDelim(']') { + var v1 string + if in.IsNull() { + in.Skip() + } else { + v1 = string(in.String()) + } + out.URIs = append(out.URIs, v1) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(out *jwriter.Writer, in TurnCredentials) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"username\":" + out.RawString(prefix[1:]) + out.String(string(in.Username)) + } + { + const prefix string = ",\"password\":" + out.RawString(prefix) + out.String(string(in.Password)) + } + { + const prefix string = ",\"ttl\":" + out.RawString(prefix) + out.Int64(int64(in.TTL)) + } + { + const prefix string = ",\"uris\":" + out.RawString(prefix) + if in.URIs == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v2, v3 := range in.URIs { + if v2 > 0 { + out.RawByte(',') + } + out.String(string(v3)) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v TurnCredentials) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v TurnCredentials) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *TurnCredentials) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *TurnCredentials) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(in *jlexer.Lexer, out *RoomSessionData) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "userid": + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(out *jwriter.Writer, in RoomSessionData) { + out.RawByte('{') + first := true + _ = first + if in.UserId != "" { + const prefix string = ",\"userid\":" + first = false + out.RawString(prefix[1:]) + out.String(string(in.UserId)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v RoomSessionData) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v RoomSessionData) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *RoomSessionData) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *RoomSessionData) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(in *jlexer.Lexer, out *BackendServerRoomResponse) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "dialout": + if in.IsNull() { + in.Skip() + out.Dialout = nil + } else { + if out.Dialout == nil { + out.Dialout = new(BackendRoomDialoutResponse) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Dialout).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(out *jwriter.Writer, in BackendServerRoomResponse) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"type\":" + out.RawString(prefix[1:]) + out.String(string(in.Type)) + } + if in.Dialout != nil { + const prefix string = ",\"dialout\":" + out.RawString(prefix) + (*in.Dialout).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerRoomResponse) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerRoomResponse) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerRoomResponse) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerRoomResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk3(in *jlexer.Lexer, out *BackendServerRoomRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "invite": + if in.IsNull() { + in.Skip() + out.Invite = nil + } else { + if out.Invite == nil { + out.Invite = new(BackendRoomInviteRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Invite).UnmarshalEasyJSON(in) + } + } + case "disinvite": + if in.IsNull() { + in.Skip() + out.Disinvite = nil + } else { + if out.Disinvite == nil { + out.Disinvite = new(BackendRoomDisinviteRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Disinvite).UnmarshalEasyJSON(in) + } + } + case "update": + if in.IsNull() { + in.Skip() + out.Update = nil + } else { + if out.Update == nil { + out.Update = new(BackendRoomUpdateRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Update).UnmarshalEasyJSON(in) + } + } + case "delete": + if in.IsNull() { + in.Skip() + out.Delete = nil + } else { + if out.Delete == nil { + out.Delete = new(BackendRoomDeleteRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Delete).UnmarshalEasyJSON(in) + } + } + case "incall": + if in.IsNull() { + in.Skip() + out.InCall = nil + } else { + if out.InCall == nil { + out.InCall = new(BackendRoomInCallRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.InCall).UnmarshalEasyJSON(in) + } + } + case "participants": + if in.IsNull() { + in.Skip() + out.Participants = nil + } else { + if out.Participants == nil { + out.Participants = new(BackendRoomParticipantsRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Participants).UnmarshalEasyJSON(in) + } + } + case "message": + if in.IsNull() { + in.Skip() + out.Message = nil + } else { + if out.Message == nil { + out.Message = new(BackendRoomMessageRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Message).UnmarshalEasyJSON(in) + } + } + case "switchto": + if in.IsNull() { + in.Skip() + out.SwitchTo = nil + } else { + if out.SwitchTo == nil { + out.SwitchTo = new(BackendRoomSwitchToMessageRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.SwitchTo).UnmarshalEasyJSON(in) + } + } + case "dialout": + if in.IsNull() { + in.Skip() + out.Dialout = nil + } else { + if out.Dialout == nil { + out.Dialout = new(BackendRoomDialoutRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Dialout).UnmarshalEasyJSON(in) + } + } + case "transient": + if in.IsNull() { + in.Skip() + out.Transient = nil + } else { + if out.Transient == nil { + out.Transient = new(BackendRoomTransientRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Transient).UnmarshalEasyJSON(in) + } + } + case "received": + if in.IsNull() { + in.Skip() + } else { + out.ReceivedTime = int64(in.Int64()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk3(out *jwriter.Writer, in BackendServerRoomRequest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"type\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.Type)) + } + if in.Invite != nil { + const prefix string = ",\"invite\":" + out.RawString(prefix) + (*in.Invite).MarshalEasyJSON(out) + } + if in.Disinvite != nil { + const prefix string = ",\"disinvite\":" + out.RawString(prefix) + (*in.Disinvite).MarshalEasyJSON(out) + } + if in.Update != nil { + const prefix string = ",\"update\":" + out.RawString(prefix) + (*in.Update).MarshalEasyJSON(out) + } + if in.Delete != nil { + const prefix string = ",\"delete\":" + out.RawString(prefix) + (*in.Delete).MarshalEasyJSON(out) + } + if in.InCall != nil { + const prefix string = ",\"incall\":" + out.RawString(prefix) + (*in.InCall).MarshalEasyJSON(out) + } + if in.Participants != nil { + const prefix string = ",\"participants\":" + out.RawString(prefix) + (*in.Participants).MarshalEasyJSON(out) + } + if in.Message != nil { + const prefix string = ",\"message\":" + out.RawString(prefix) + (*in.Message).MarshalEasyJSON(out) + } + if in.SwitchTo != nil { + const prefix string = ",\"switchto\":" + out.RawString(prefix) + (*in.SwitchTo).MarshalEasyJSON(out) + } + if in.Dialout != nil { + const prefix string = ",\"dialout\":" + out.RawString(prefix) + (*in.Dialout).MarshalEasyJSON(out) + } + if in.Transient != nil { + const prefix string = ",\"transient\":" + out.RawString(prefix) + (*in.Transient).MarshalEasyJSON(out) + } + if in.ReceivedTime != 0 { + const prefix string = ",\"received\":" + out.RawString(prefix) + out.Int64(int64(in.ReceivedTime)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerRoomRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk3(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerRoomRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk3(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerRoomRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk3(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerRoomRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk3(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk4(in *jlexer.Lexer, out *BackendServerInfoVideoRoom) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "name": + if in.IsNull() { + in.Skip() + } else { + out.Name = string(in.String()) + } + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "author": + if in.IsNull() { + in.Skip() + } else { + out.Author = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk4(out *jwriter.Writer, in BackendServerInfoVideoRoom) { + out.RawByte('{') + first := true + _ = first + if in.Name != "" { + const prefix string = ",\"name\":" + first = false + out.RawString(prefix[1:]) + out.String(string(in.Name)) + } + if in.Version != "" { + const prefix string = ",\"version\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.Version)) + } + if in.Author != "" { + const prefix string = ",\"author\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.Author)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerInfoVideoRoom) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk4(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerInfoVideoRoom) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk4(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerInfoVideoRoom) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk4(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerInfoVideoRoom) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk4(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk5(in *jlexer.Lexer, out *BackendServerInfoSfuProxyBandwidth) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "incoming": + if in.IsNull() { + in.Skip() + out.Incoming = nil + } else { + if out.Incoming == nil { + out.Incoming = new(float64) + } + if in.IsNull() { + in.Skip() + } else { + *out.Incoming = float64(in.Float64()) + } + } + case "outgoing": + if in.IsNull() { + in.Skip() + out.Outgoing = nil + } else { + if out.Outgoing == nil { + out.Outgoing = new(float64) + } + if in.IsNull() { + in.Skip() + } else { + *out.Outgoing = float64(in.Float64()) + } + } + case "received": + if in.IsNull() { + in.Skip() + } else { + out.Received = api.Bandwidth(in.Uint64()) + } + case "sent": + if in.IsNull() { + in.Skip() + } else { + out.Sent = api.Bandwidth(in.Uint64()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk5(out *jwriter.Writer, in BackendServerInfoSfuProxyBandwidth) { + out.RawByte('{') + first := true + _ = first + if in.Incoming != nil { + const prefix string = ",\"incoming\":" + first = false + out.RawString(prefix[1:]) + out.Float64(float64(*in.Incoming)) + } + if in.Outgoing != nil { + const prefix string = ",\"outgoing\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Float64(float64(*in.Outgoing)) + } + if in.Received != 0 { + const prefix string = ",\"received\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Uint64(uint64(in.Received)) + } + if in.Sent != 0 { + const prefix string = ",\"sent\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Uint64(uint64(in.Sent)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerInfoSfuProxyBandwidth) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk5(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerInfoSfuProxyBandwidth) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk5(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerInfoSfuProxyBandwidth) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk5(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerInfoSfuProxyBandwidth) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk5(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk6(in *jlexer.Lexer, out *BackendServerInfoSfuProxy) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "url": + if in.IsNull() { + in.Skip() + } else { + out.Url = string(in.String()) + } + case "ip": + if in.IsNull() { + in.Skip() + } else { + out.IP = string(in.String()) + } + case "connected": + if in.IsNull() { + in.Skip() + } else { + out.Connected = bool(in.Bool()) + } + case "temporary": + if in.IsNull() { + in.Skip() + } else { + out.Temporary = bool(in.Bool()) + } + case "shutdown": + if in.IsNull() { + in.Skip() + out.Shutdown = nil + } else { + if out.Shutdown == nil { + out.Shutdown = new(bool) + } + if in.IsNull() { + in.Skip() + } else { + *out.Shutdown = bool(in.Bool()) + } + } + case "uptime": + if in.IsNull() { + in.Skip() + out.Uptime = nil + } else { + if out.Uptime == nil { + out.Uptime = new(time.Time) + } + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.Uptime).UnmarshalJSON(data)) + } + } + } + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "features": + if in.IsNull() { + in.Skip() + out.Features = nil + } else { + in.Delim('[') + if out.Features == nil { + if !in.IsDelim(']') { + out.Features = make([]string, 0, 4) + } else { + out.Features = []string{} + } + } else { + out.Features = (out.Features)[:0] + } + for !in.IsDelim(']') { + var v4 string + if in.IsNull() { + in.Skip() + } else { + v4 = string(in.String()) + } + out.Features = append(out.Features, v4) + in.WantComma() + } + in.Delim(']') + } + case "country": + if in.IsNull() { + in.Skip() + } else { + out.Country = geoip.Country(in.String()) + } + case "load": + if in.IsNull() { + in.Skip() + out.Load = nil + } else { + if out.Load == nil { + out.Load = new(uint64) + } + if in.IsNull() { + in.Skip() + } else { + *out.Load = uint64(in.Uint64()) + } + } + case "bandwidth": + if in.IsNull() { + in.Skip() + out.Bandwidth = nil + } else { + if out.Bandwidth == nil { + out.Bandwidth = new(BackendServerInfoSfuProxyBandwidth) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Bandwidth).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk6(out *jwriter.Writer, in BackendServerInfoSfuProxy) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"url\":" + out.RawString(prefix[1:]) + out.String(string(in.Url)) + } + if in.IP != "" { + const prefix string = ",\"ip\":" + out.RawString(prefix) + out.String(string(in.IP)) + } + { + const prefix string = ",\"connected\":" + out.RawString(prefix) + out.Bool(bool(in.Connected)) + } + { + const prefix string = ",\"temporary\":" + out.RawString(prefix) + out.Bool(bool(in.Temporary)) + } + if in.Shutdown != nil { + const prefix string = ",\"shutdown\":" + out.RawString(prefix) + out.Bool(bool(*in.Shutdown)) + } + if in.Uptime != nil { + const prefix string = ",\"uptime\":" + out.RawString(prefix) + out.Raw((*in.Uptime).MarshalJSON()) + } + if in.Version != "" { + const prefix string = ",\"version\":" + out.RawString(prefix) + out.String(string(in.Version)) + } + if len(in.Features) != 0 { + const prefix string = ",\"features\":" + out.RawString(prefix) + { + out.RawByte('[') + for v5, v6 := range in.Features { + if v5 > 0 { + out.RawByte(',') + } + out.String(string(v6)) + } + out.RawByte(']') + } + } + if in.Country != "" { + const prefix string = ",\"country\":" + out.RawString(prefix) + out.String(string(in.Country)) + } + if in.Load != nil { + const prefix string = ",\"load\":" + out.RawString(prefix) + out.Uint64(uint64(*in.Load)) + } + if in.Bandwidth != nil { + const prefix string = ",\"bandwidth\":" + out.RawString(prefix) + (*in.Bandwidth).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerInfoSfuProxy) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk6(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerInfoSfuProxy) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk6(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerInfoSfuProxy) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk6(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerInfoSfuProxy) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk6(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk7(in *jlexer.Lexer, out *BackendServerInfoSfuJanus) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "url": + if in.IsNull() { + in.Skip() + } else { + out.Url = string(in.String()) + } + case "connected": + if in.IsNull() { + in.Skip() + } else { + out.Connected = bool(in.Bool()) + } + case "name": + if in.IsNull() { + in.Skip() + } else { + out.Name = string(in.String()) + } + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "author": + if in.IsNull() { + in.Skip() + } else { + out.Author = string(in.String()) + } + case "datachannels": + if in.IsNull() { + in.Skip() + out.DataChannels = nil + } else { + if out.DataChannels == nil { + out.DataChannels = new(bool) + } + if in.IsNull() { + in.Skip() + } else { + *out.DataChannels = bool(in.Bool()) + } + } + case "fulltrickle": + if in.IsNull() { + in.Skip() + out.FullTrickle = nil + } else { + if out.FullTrickle == nil { + out.FullTrickle = new(bool) + } + if in.IsNull() { + in.Skip() + } else { + *out.FullTrickle = bool(in.Bool()) + } + } + case "localip": + if in.IsNull() { + in.Skip() + } else { + out.LocalIP = string(in.String()) + } + case "ipv6": + if in.IsNull() { + in.Skip() + out.IPv6 = nil + } else { + if out.IPv6 == nil { + out.IPv6 = new(bool) + } + if in.IsNull() { + in.Skip() + } else { + *out.IPv6 = bool(in.Bool()) + } + } + case "videoroom": + if in.IsNull() { + in.Skip() + out.VideoRoom = nil + } else { + if out.VideoRoom == nil { + out.VideoRoom = new(BackendServerInfoVideoRoom) + } + if in.IsNull() { + in.Skip() + } else { + (*out.VideoRoom).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk7(out *jwriter.Writer, in BackendServerInfoSfuJanus) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"url\":" + out.RawString(prefix[1:]) + out.String(string(in.Url)) + } + { + const prefix string = ",\"connected\":" + out.RawString(prefix) + out.Bool(bool(in.Connected)) + } + if in.Name != "" { + const prefix string = ",\"name\":" + out.RawString(prefix) + out.String(string(in.Name)) + } + if in.Version != "" { + const prefix string = ",\"version\":" + out.RawString(prefix) + out.String(string(in.Version)) + } + if in.Author != "" { + const prefix string = ",\"author\":" + out.RawString(prefix) + out.String(string(in.Author)) + } + if in.DataChannels != nil { + const prefix string = ",\"datachannels\":" + out.RawString(prefix) + out.Bool(bool(*in.DataChannels)) + } + if in.FullTrickle != nil { + const prefix string = ",\"fulltrickle\":" + out.RawString(prefix) + out.Bool(bool(*in.FullTrickle)) + } + if in.LocalIP != "" { + const prefix string = ",\"localip\":" + out.RawString(prefix) + out.String(string(in.LocalIP)) + } + if in.IPv6 != nil { + const prefix string = ",\"ipv6\":" + out.RawString(prefix) + out.Bool(bool(*in.IPv6)) + } + if in.VideoRoom != nil { + const prefix string = ",\"videoroom\":" + out.RawString(prefix) + (*in.VideoRoom).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerInfoSfuJanus) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk7(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerInfoSfuJanus) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk7(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerInfoSfuJanus) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk7(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerInfoSfuJanus) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk7(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk8(in *jlexer.Lexer, out *BackendServerInfoSfu) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "mode": + if in.IsNull() { + in.Skip() + } else { + out.Mode = SfuMode(in.String()) + } + case "janus": + if in.IsNull() { + in.Skip() + out.Janus = nil + } else { + if out.Janus == nil { + out.Janus = new(BackendServerInfoSfuJanus) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Janus).UnmarshalEasyJSON(in) + } + } + case "proxies": + if in.IsNull() { + in.Skip() + out.Proxies = nil + } else { + in.Delim('[') + if out.Proxies == nil { + if !in.IsDelim(']') { + out.Proxies = make([]BackendServerInfoSfuProxy, 0, 0) + } else { + out.Proxies = []BackendServerInfoSfuProxy{} + } + } else { + out.Proxies = (out.Proxies)[:0] + } + for !in.IsDelim(']') { + var v7 BackendServerInfoSfuProxy + if in.IsNull() { + in.Skip() + } else { + (v7).UnmarshalEasyJSON(in) + } + out.Proxies = append(out.Proxies, v7) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk8(out *jwriter.Writer, in BackendServerInfoSfu) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"mode\":" + out.RawString(prefix[1:]) + out.String(string(in.Mode)) + } + if in.Janus != nil { + const prefix string = ",\"janus\":" + out.RawString(prefix) + (*in.Janus).MarshalEasyJSON(out) + } + if len(in.Proxies) != 0 { + const prefix string = ",\"proxies\":" + out.RawString(prefix) + { + out.RawByte('[') + for v8, v9 := range in.Proxies { + if v8 > 0 { + out.RawByte(',') + } + (v9).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerInfoSfu) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk8(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerInfoSfu) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk8(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerInfoSfu) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk8(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerInfoSfu) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk8(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk9(in *jlexer.Lexer, out *BackendServerInfoNats) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "urls": + if in.IsNull() { + in.Skip() + out.Urls = nil + } else { + in.Delim('[') + if out.Urls == nil { + if !in.IsDelim(']') { + out.Urls = make([]string, 0, 4) + } else { + out.Urls = []string{} + } + } else { + out.Urls = (out.Urls)[:0] + } + for !in.IsDelim(']') { + var v10 string + if in.IsNull() { + in.Skip() + } else { + v10 = string(in.String()) + } + out.Urls = append(out.Urls, v10) + in.WantComma() + } + in.Delim(']') + } + case "connected": + if in.IsNull() { + in.Skip() + } else { + out.Connected = bool(in.Bool()) + } + case "serverurl": + if in.IsNull() { + in.Skip() + } else { + out.ServerUrl = string(in.String()) + } + case "serverid": + if in.IsNull() { + in.Skip() + } else { + out.ServerID = string(in.String()) + } + case "version": + if in.IsNull() { + in.Skip() + } else { + out.ServerVersion = string(in.String()) + } + case "clustername": + if in.IsNull() { + in.Skip() + } else { + out.ClusterName = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk9(out *jwriter.Writer, in BackendServerInfoNats) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"urls\":" + out.RawString(prefix[1:]) + if in.Urls == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v11, v12 := range in.Urls { + if v11 > 0 { + out.RawByte(',') + } + out.String(string(v12)) + } + out.RawByte(']') + } + } + { + const prefix string = ",\"connected\":" + out.RawString(prefix) + out.Bool(bool(in.Connected)) + } + if in.ServerUrl != "" { + const prefix string = ",\"serverurl\":" + out.RawString(prefix) + out.String(string(in.ServerUrl)) + } + if in.ServerID != "" { + const prefix string = ",\"serverid\":" + out.RawString(prefix) + out.String(string(in.ServerID)) + } + if in.ServerVersion != "" { + const prefix string = ",\"version\":" + out.RawString(prefix) + out.String(string(in.ServerVersion)) + } + if in.ClusterName != "" { + const prefix string = ",\"clustername\":" + out.RawString(prefix) + out.String(string(in.ClusterName)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerInfoNats) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk9(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerInfoNats) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk9(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerInfoNats) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk9(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerInfoNats) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk9(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk10(in *jlexer.Lexer, out *BackendServerInfoGrpc) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "target": + if in.IsNull() { + in.Skip() + } else { + out.Target = string(in.String()) + } + case "ip": + if in.IsNull() { + in.Skip() + } else { + out.IP = string(in.String()) + } + case "connected": + if in.IsNull() { + in.Skip() + } else { + out.Connected = bool(in.Bool()) + } + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk10(out *jwriter.Writer, in BackendServerInfoGrpc) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"target\":" + out.RawString(prefix[1:]) + out.String(string(in.Target)) + } + if in.IP != "" { + const prefix string = ",\"ip\":" + out.RawString(prefix) + out.String(string(in.IP)) + } + { + const prefix string = ",\"connected\":" + out.RawString(prefix) + out.Bool(bool(in.Connected)) + } + if in.Version != "" { + const prefix string = ",\"version\":" + out.RawString(prefix) + out.String(string(in.Version)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerInfoGrpc) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk10(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerInfoGrpc) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk10(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerInfoGrpc) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk10(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerInfoGrpc) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk10(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk11(in *jlexer.Lexer, out *BackendServerInfoDialout) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "sessionid": + if in.IsNull() { + in.Skip() + } else { + out.SessionId = api.PublicSessionId(in.String()) + } + case "connected": + if in.IsNull() { + in.Skip() + } else { + out.Connected = bool(in.Bool()) + } + case "address": + if in.IsNull() { + in.Skip() + } else { + out.Address = string(in.String()) + } + case "useragent": + if in.IsNull() { + in.Skip() + } else { + out.UserAgent = string(in.String()) + } + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "features": + if in.IsNull() { + in.Skip() + out.Features = nil + } else { + in.Delim('[') + if out.Features == nil { + if !in.IsDelim(']') { + out.Features = make([]string, 0, 4) + } else { + out.Features = []string{} + } + } else { + out.Features = (out.Features)[:0] + } + for !in.IsDelim(']') { + var v13 string + if in.IsNull() { + in.Skip() + } else { + v13 = string(in.String()) + } + out.Features = append(out.Features, v13) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk11(out *jwriter.Writer, in BackendServerInfoDialout) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"sessionid\":" + out.RawString(prefix[1:]) + out.String(string(in.SessionId)) + } + { + const prefix string = ",\"connected\":" + out.RawString(prefix) + out.Bool(bool(in.Connected)) + } + if in.Address != "" { + const prefix string = ",\"address\":" + out.RawString(prefix) + out.String(string(in.Address)) + } + if in.UserAgent != "" { + const prefix string = ",\"useragent\":" + out.RawString(prefix) + out.String(string(in.UserAgent)) + } + if in.Version != "" { + const prefix string = ",\"version\":" + out.RawString(prefix) + out.String(string(in.Version)) + } + if len(in.Features) != 0 { + const prefix string = ",\"features\":" + out.RawString(prefix) + { + out.RawByte('[') + for v14, v15 := range in.Features { + if v14 > 0 { + out.RawByte(',') + } + out.String(string(v15)) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerInfoDialout) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk11(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerInfoDialout) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk11(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerInfoDialout) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk11(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerInfoDialout) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk11(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk12(in *jlexer.Lexer, out *BackendServerInfo) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "features": + if in.IsNull() { + in.Skip() + out.Features = nil + } else { + in.Delim('[') + if out.Features == nil { + if !in.IsDelim(']') { + out.Features = make([]string, 0, 4) + } else { + out.Features = []string{} + } + } else { + out.Features = (out.Features)[:0] + } + for !in.IsDelim(']') { + var v16 string + if in.IsNull() { + in.Skip() + } else { + v16 = string(in.String()) + } + out.Features = append(out.Features, v16) + in.WantComma() + } + in.Delim(']') + } + case "sfu": + if in.IsNull() { + in.Skip() + out.Sfu = nil + } else { + if out.Sfu == nil { + out.Sfu = new(BackendServerInfoSfu) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Sfu).UnmarshalEasyJSON(in) + } + } + case "dialout": + if in.IsNull() { + in.Skip() + out.Dialout = nil + } else { + in.Delim('[') + if out.Dialout == nil { + if !in.IsDelim(']') { + out.Dialout = make([]BackendServerInfoDialout, 0, 0) + } else { + out.Dialout = []BackendServerInfoDialout{} + } + } else { + out.Dialout = (out.Dialout)[:0] + } + for !in.IsDelim(']') { + var v17 BackendServerInfoDialout + if in.IsNull() { + in.Skip() + } else { + (v17).UnmarshalEasyJSON(in) + } + out.Dialout = append(out.Dialout, v17) + in.WantComma() + } + in.Delim(']') + } + case "nats": + if in.IsNull() { + in.Skip() + out.Nats = nil + } else { + if out.Nats == nil { + out.Nats = new(BackendServerInfoNats) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Nats).UnmarshalEasyJSON(in) + } + } + case "grpc": + if in.IsNull() { + in.Skip() + out.Grpc = nil + } else { + in.Delim('[') + if out.Grpc == nil { + if !in.IsDelim(']') { + out.Grpc = make([]BackendServerInfoGrpc, 0, 1) + } else { + out.Grpc = []BackendServerInfoGrpc{} + } + } else { + out.Grpc = (out.Grpc)[:0] + } + for !in.IsDelim(']') { + var v18 BackendServerInfoGrpc + if in.IsNull() { + in.Skip() + } else { + (v18).UnmarshalEasyJSON(in) + } + out.Grpc = append(out.Grpc, v18) + in.WantComma() + } + in.Delim(']') + } + case "etcd": + if in.IsNull() { + in.Skip() + out.Etcd = nil + } else { + if out.Etcd == nil { + out.Etcd = new(etcd.BackendServerInfoEtcd) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Etcd).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk12(out *jwriter.Writer, in BackendServerInfo) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"version\":" + out.RawString(prefix[1:]) + out.String(string(in.Version)) + } + { + const prefix string = ",\"features\":" + out.RawString(prefix) + if in.Features == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v19, v20 := range in.Features { + if v19 > 0 { + out.RawByte(',') + } + out.String(string(v20)) + } + out.RawByte(']') + } + } + if in.Sfu != nil { + const prefix string = ",\"sfu\":" + out.RawString(prefix) + (*in.Sfu).MarshalEasyJSON(out) + } + if len(in.Dialout) != 0 { + const prefix string = ",\"dialout\":" + out.RawString(prefix) + { + out.RawByte('[') + for v21, v22 := range in.Dialout { + if v21 > 0 { + out.RawByte(',') + } + (v22).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + if in.Nats != nil { + const prefix string = ",\"nats\":" + out.RawString(prefix) + (*in.Nats).MarshalEasyJSON(out) + } + if len(in.Grpc) != 0 { + const prefix string = ",\"grpc\":" + out.RawString(prefix) + { + out.RawByte('[') + for v23, v24 := range in.Grpc { + if v23 > 0 { + out.RawByte(',') + } + (v24).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + if in.Etcd != nil { + const prefix string = ",\"etcd\":" + out.RawString(prefix) + (*in.Etcd).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendServerInfo) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk12(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendServerInfo) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk12(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendServerInfo) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk12(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendServerInfo) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk12(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk13(in *jlexer.Lexer, out *BackendRoomUpdateRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "userids": + if in.IsNull() { + in.Skip() + out.UserIds = nil + } else { + in.Delim('[') + if out.UserIds == nil { + if !in.IsDelim(']') { + out.UserIds = make([]string, 0, 4) + } else { + out.UserIds = []string{} + } + } else { + out.UserIds = (out.UserIds)[:0] + } + for !in.IsDelim(']') { + var v25 string + if in.IsNull() { + in.Skip() + } else { + v25 = string(in.String()) + } + out.UserIds = append(out.UserIds, v25) + in.WantComma() + } + in.Delim(']') + } + case "properties": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Properties).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk13(out *jwriter.Writer, in BackendRoomUpdateRequest) { + out.RawByte('{') + first := true + _ = first + if len(in.UserIds) != 0 { + const prefix string = ",\"userids\":" + first = false + out.RawString(prefix[1:]) + { + out.RawByte('[') + for v26, v27 := range in.UserIds { + if v26 > 0 { + out.RawByte(',') + } + out.String(string(v27)) + } + out.RawByte(']') + } + } + if len(in.Properties) != 0 { + const prefix string = ",\"properties\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Raw((in.Properties).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomUpdateRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk13(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomUpdateRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk13(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomUpdateRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk13(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomUpdateRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk13(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk14(in *jlexer.Lexer, out *BackendRoomTransientRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "action": + if in.IsNull() { + in.Skip() + } else { + out.Action = TransientAction(in.String()) + } + case "key": + if in.IsNull() { + in.Skip() + } else { + out.Key = string(in.String()) + } + case "value": + if m, ok := out.Value.(easyjson.Unmarshaler); ok { + m.UnmarshalEasyJSON(in) + } else if m, ok := out.Value.(json.Unmarshaler); ok { + _ = m.UnmarshalJSON(in.Raw()) + } else { + out.Value = in.Interface() + } + case "ttl": + if in.IsNull() { + in.Skip() + } else { + out.TTL = time.Duration(in.Int64()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk14(out *jwriter.Writer, in BackendRoomTransientRequest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"action\":" + out.RawString(prefix[1:]) + out.String(string(in.Action)) + } + { + const prefix string = ",\"key\":" + out.RawString(prefix) + out.String(string(in.Key)) + } + if in.Value != nil { + const prefix string = ",\"value\":" + out.RawString(prefix) + if m, ok := in.Value.(easyjson.Marshaler); ok { + m.MarshalEasyJSON(out) + } else if m, ok := in.Value.(json.Marshaler); ok { + out.Raw(m.MarshalJSON()) + } else { + out.Raw(json.Marshal(in.Value)) + } + } + if in.TTL != 0 { + const prefix string = ",\"ttl\":" + out.RawString(prefix) + out.Int64(int64(in.TTL)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomTransientRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk14(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomTransientRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk14(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomTransientRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk14(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomTransientRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk14(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk15(in *jlexer.Lexer, out *BackendRoomSwitchToMessageRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "roomid": + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } + case "sessions": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Sessions).UnmarshalJSON(data)) + } + } + case "sessionslist": + if in.IsNull() { + in.Skip() + out.SessionsList = nil + } else { + in.Delim('[') + if out.SessionsList == nil { + if !in.IsDelim(']') { + out.SessionsList = make(BackendRoomSwitchToPublicSessionsList, 0, 4) + } else { + out.SessionsList = BackendRoomSwitchToPublicSessionsList{} + } + } else { + out.SessionsList = (out.SessionsList)[:0] + } + for !in.IsDelim(']') { + var v28 api.PublicSessionId + if in.IsNull() { + in.Skip() + } else { + v28 = api.PublicSessionId(in.String()) + } + out.SessionsList = append(out.SessionsList, v28) + in.WantComma() + } + in.Delim(']') + } + case "sessionsmap": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.SessionsMap = make(BackendRoomSwitchToPublicSessionsMap) + } else { + out.SessionsMap = nil + } + for !in.IsDelim('}') { + key := api.PublicSessionId(in.String()) + in.WantColon() + var v29 json.RawMessage + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((v29).UnmarshalJSON(data)) + } + } + (out.SessionsMap)[key] = v29 + in.WantComma() + } + in.Delim('}') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk15(out *jwriter.Writer, in BackendRoomSwitchToMessageRequest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"roomid\":" + out.RawString(prefix[1:]) + out.String(string(in.RoomId)) + } + if len(in.Sessions) != 0 { + const prefix string = ",\"sessions\":" + out.RawString(prefix) + out.Raw((in.Sessions).MarshalJSON()) + } + if len(in.SessionsList) != 0 { + const prefix string = ",\"sessionslist\":" + out.RawString(prefix) + { + out.RawByte('[') + for v30, v31 := range in.SessionsList { + if v30 > 0 { + out.RawByte(',') + } + out.String(string(v31)) + } + out.RawByte(']') + } + } + if len(in.SessionsMap) != 0 { + const prefix string = ",\"sessionsmap\":" + out.RawString(prefix) + { + out.RawByte('{') + v32First := true + for v32Name, v32Value := range in.SessionsMap { + if v32First { + v32First = false + } else { + out.RawByte(',') + } + out.String(string(v32Name)) + out.RawByte(':') + out.Raw((v32Value).MarshalJSON()) + } + out.RawByte('}') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomSwitchToMessageRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk15(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomSwitchToMessageRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk15(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomSwitchToMessageRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk15(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomSwitchToMessageRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk15(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk16(in *jlexer.Lexer, out *BackendRoomParticipantsRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "changed": + if in.IsNull() { + in.Skip() + out.Changed = nil + } else { + in.Delim('[') + if out.Changed == nil { + if !in.IsDelim(']') { + out.Changed = make([]api.StringMap, 0, 8) + } else { + out.Changed = []api.StringMap{} + } + } else { + out.Changed = (out.Changed)[:0] + } + for !in.IsDelim(']') { + var v33 api.StringMap + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + v33 = make(api.StringMap) + } else { + v33 = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v34 interface{} + if m, ok := v34.(easyjson.Unmarshaler); ok { + m.UnmarshalEasyJSON(in) + } else if m, ok := v34.(json.Unmarshaler); ok { + _ = m.UnmarshalJSON(in.Raw()) + } else { + v34 = in.Interface() + } + (v33)[key] = v34 + in.WantComma() + } + in.Delim('}') + } + out.Changed = append(out.Changed, v33) + in.WantComma() + } + in.Delim(']') + } + case "users": + if in.IsNull() { + in.Skip() + out.Users = nil + } else { + in.Delim('[') + if out.Users == nil { + if !in.IsDelim(']') { + out.Users = make([]api.StringMap, 0, 8) + } else { + out.Users = []api.StringMap{} + } + } else { + out.Users = (out.Users)[:0] + } + for !in.IsDelim(']') { + var v35 api.StringMap + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + v35 = make(api.StringMap) + } else { + v35 = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v36 interface{} + if m, ok := v36.(easyjson.Unmarshaler); ok { + m.UnmarshalEasyJSON(in) + } else if m, ok := v36.(json.Unmarshaler); ok { + _ = m.UnmarshalJSON(in.Raw()) + } else { + v36 = in.Interface() + } + (v35)[key] = v36 + in.WantComma() + } + in.Delim('}') + } + out.Users = append(out.Users, v35) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk16(out *jwriter.Writer, in BackendRoomParticipantsRequest) { + out.RawByte('{') + first := true + _ = first + if len(in.Changed) != 0 { + const prefix string = ",\"changed\":" + first = false + out.RawString(prefix[1:]) + { + out.RawByte('[') + for v37, v38 := range in.Changed { + if v37 > 0 { + out.RawByte(',') + } + if v38 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { + out.RawString(`null`) + } else { + out.RawByte('{') + v39First := true + for v39Name, v39Value := range v38 { + if v39First { + v39First = false + } else { + out.RawByte(',') + } + out.String(string(v39Name)) + out.RawByte(':') + if m, ok := v39Value.(easyjson.Marshaler); ok { + m.MarshalEasyJSON(out) + } else if m, ok := v39Value.(json.Marshaler); ok { + out.Raw(m.MarshalJSON()) + } else { + out.Raw(json.Marshal(v39Value)) + } + } + out.RawByte('}') + } + } + out.RawByte(']') + } + } + if len(in.Users) != 0 { + const prefix string = ",\"users\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + { + out.RawByte('[') + for v40, v41 := range in.Users { + if v40 > 0 { + out.RawByte(',') + } + if v41 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { + out.RawString(`null`) + } else { + out.RawByte('{') + v42First := true + for v42Name, v42Value := range v41 { + if v42First { + v42First = false + } else { + out.RawByte(',') + } + out.String(string(v42Name)) + out.RawByte(':') + if m, ok := v42Value.(easyjson.Marshaler); ok { + m.MarshalEasyJSON(out) + } else if m, ok := v42Value.(json.Marshaler); ok { + out.Raw(m.MarshalJSON()) + } else { + out.Raw(json.Marshal(v42Value)) + } + } + out.RawByte('}') + } + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomParticipantsRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk16(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomParticipantsRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk16(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomParticipantsRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk16(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomParticipantsRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk16(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk17(in *jlexer.Lexer, out *BackendRoomMessageRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "data": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Data).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk17(out *jwriter.Writer, in BackendRoomMessageRequest) { + out.RawByte('{') + first := true + _ = first + if len(in.Data) != 0 { + const prefix string = ",\"data\":" + first = false + out.RawString(prefix[1:]) + out.Raw((in.Data).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomMessageRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk17(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomMessageRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk17(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomMessageRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk17(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomMessageRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk17(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk18(in *jlexer.Lexer, out *BackendRoomInviteRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "userids": + if in.IsNull() { + in.Skip() + out.UserIds = nil + } else { + in.Delim('[') + if out.UserIds == nil { + if !in.IsDelim(']') { + out.UserIds = make([]string, 0, 4) + } else { + out.UserIds = []string{} + } + } else { + out.UserIds = (out.UserIds)[:0] + } + for !in.IsDelim(']') { + var v43 string + if in.IsNull() { + in.Skip() + } else { + v43 = string(in.String()) + } + out.UserIds = append(out.UserIds, v43) + in.WantComma() + } + in.Delim(']') + } + case "alluserids": + if in.IsNull() { + in.Skip() + out.AllUserIds = nil + } else { + in.Delim('[') + if out.AllUserIds == nil { + if !in.IsDelim(']') { + out.AllUserIds = make([]string, 0, 4) + } else { + out.AllUserIds = []string{} + } + } else { + out.AllUserIds = (out.AllUserIds)[:0] + } + for !in.IsDelim(']') { + var v44 string + if in.IsNull() { + in.Skip() + } else { + v44 = string(in.String()) + } + out.AllUserIds = append(out.AllUserIds, v44) + in.WantComma() + } + in.Delim(']') + } + case "properties": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Properties).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk18(out *jwriter.Writer, in BackendRoomInviteRequest) { + out.RawByte('{') + first := true + _ = first + if len(in.UserIds) != 0 { + const prefix string = ",\"userids\":" + first = false + out.RawString(prefix[1:]) + { + out.RawByte('[') + for v45, v46 := range in.UserIds { + if v45 > 0 { + out.RawByte(',') + } + out.String(string(v46)) + } + out.RawByte(']') + } + } + if len(in.AllUserIds) != 0 { + const prefix string = ",\"alluserids\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + { + out.RawByte('[') + for v47, v48 := range in.AllUserIds { + if v47 > 0 { + out.RawByte(',') + } + out.String(string(v48)) + } + out.RawByte(']') + } + } + if len(in.Properties) != 0 { + const prefix string = ",\"properties\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Raw((in.Properties).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomInviteRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk18(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomInviteRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk18(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomInviteRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk18(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomInviteRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk18(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk19(in *jlexer.Lexer, out *BackendRoomInCallRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "incall": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.InCall).UnmarshalJSON(data)) + } + } + case "all": + if in.IsNull() { + in.Skip() + } else { + out.All = bool(in.Bool()) + } + case "changed": + if in.IsNull() { + in.Skip() + out.Changed = nil + } else { + in.Delim('[') + if out.Changed == nil { + if !in.IsDelim(']') { + out.Changed = make([]api.StringMap, 0, 8) + } else { + out.Changed = []api.StringMap{} + } + } else { + out.Changed = (out.Changed)[:0] + } + for !in.IsDelim(']') { + var v49 api.StringMap + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + v49 = make(api.StringMap) + } else { + v49 = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v50 interface{} + if m, ok := v50.(easyjson.Unmarshaler); ok { + m.UnmarshalEasyJSON(in) + } else if m, ok := v50.(json.Unmarshaler); ok { + _ = m.UnmarshalJSON(in.Raw()) + } else { + v50 = in.Interface() + } + (v49)[key] = v50 + in.WantComma() + } + in.Delim('}') + } + out.Changed = append(out.Changed, v49) + in.WantComma() + } + in.Delim(']') + } + case "users": + if in.IsNull() { + in.Skip() + out.Users = nil + } else { + in.Delim('[') + if out.Users == nil { + if !in.IsDelim(']') { + out.Users = make([]api.StringMap, 0, 8) + } else { + out.Users = []api.StringMap{} + } + } else { + out.Users = (out.Users)[:0] + } + for !in.IsDelim(']') { + var v51 api.StringMap + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + v51 = make(api.StringMap) + } else { + v51 = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v52 interface{} + if m, ok := v52.(easyjson.Unmarshaler); ok { + m.UnmarshalEasyJSON(in) + } else if m, ok := v52.(json.Unmarshaler); ok { + _ = m.UnmarshalJSON(in.Raw()) + } else { + v52 = in.Interface() + } + (v51)[key] = v52 + in.WantComma() + } + in.Delim('}') + } + out.Users = append(out.Users, v51) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk19(out *jwriter.Writer, in BackendRoomInCallRequest) { + out.RawByte('{') + first := true + _ = first + if len(in.InCall) != 0 { + const prefix string = ",\"incall\":" + first = false + out.RawString(prefix[1:]) + out.Raw((in.InCall).MarshalJSON()) + } + if in.All { + const prefix string = ",\"all\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Bool(bool(in.All)) + } + if len(in.Changed) != 0 { + const prefix string = ",\"changed\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + { + out.RawByte('[') + for v53, v54 := range in.Changed { + if v53 > 0 { + out.RawByte(',') + } + if v54 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { + out.RawString(`null`) + } else { + out.RawByte('{') + v55First := true + for v55Name, v55Value := range v54 { + if v55First { + v55First = false + } else { + out.RawByte(',') + } + out.String(string(v55Name)) + out.RawByte(':') + if m, ok := v55Value.(easyjson.Marshaler); ok { + m.MarshalEasyJSON(out) + } else if m, ok := v55Value.(json.Marshaler); ok { + out.Raw(m.MarshalJSON()) + } else { + out.Raw(json.Marshal(v55Value)) + } + } + out.RawByte('}') + } + } + out.RawByte(']') + } + } + if len(in.Users) != 0 { + const prefix string = ",\"users\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + { + out.RawByte('[') + for v56, v57 := range in.Users { + if v56 > 0 { + out.RawByte(',') + } + if v57 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { + out.RawString(`null`) + } else { + out.RawByte('{') + v58First := true + for v58Name, v58Value := range v57 { + if v58First { + v58First = false + } else { + out.RawByte(',') + } + out.String(string(v58Name)) + out.RawByte(':') + if m, ok := v58Value.(easyjson.Marshaler); ok { + m.MarshalEasyJSON(out) + } else if m, ok := v58Value.(json.Marshaler); ok { + out.Raw(m.MarshalJSON()) + } else { + out.Raw(json.Marshal(v58Value)) + } + } + out.RawByte('}') + } + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomInCallRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk19(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomInCallRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk19(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomInCallRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk19(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomInCallRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk19(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk20(in *jlexer.Lexer, out *BackendRoomDisinviteRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "userids": + if in.IsNull() { + in.Skip() + out.UserIds = nil + } else { + in.Delim('[') + if out.UserIds == nil { + if !in.IsDelim(']') { + out.UserIds = make([]string, 0, 4) + } else { + out.UserIds = []string{} + } + } else { + out.UserIds = (out.UserIds)[:0] + } + for !in.IsDelim(']') { + var v59 string + if in.IsNull() { + in.Skip() + } else { + v59 = string(in.String()) + } + out.UserIds = append(out.UserIds, v59) + in.WantComma() + } + in.Delim(']') + } + case "sessionids": + if in.IsNull() { + in.Skip() + out.SessionIds = nil + } else { + in.Delim('[') + if out.SessionIds == nil { + if !in.IsDelim(']') { + out.SessionIds = make([]api.RoomSessionId, 0, 4) + } else { + out.SessionIds = []api.RoomSessionId{} + } + } else { + out.SessionIds = (out.SessionIds)[:0] + } + for !in.IsDelim(']') { + var v60 api.RoomSessionId + if in.IsNull() { + in.Skip() + } else { + v60 = api.RoomSessionId(in.String()) + } + out.SessionIds = append(out.SessionIds, v60) + in.WantComma() + } + in.Delim(']') + } + case "alluserids": + if in.IsNull() { + in.Skip() + out.AllUserIds = nil + } else { + in.Delim('[') + if out.AllUserIds == nil { + if !in.IsDelim(']') { + out.AllUserIds = make([]string, 0, 4) + } else { + out.AllUserIds = []string{} + } + } else { + out.AllUserIds = (out.AllUserIds)[:0] + } + for !in.IsDelim(']') { + var v61 string + if in.IsNull() { + in.Skip() + } else { + v61 = string(in.String()) + } + out.AllUserIds = append(out.AllUserIds, v61) + in.WantComma() + } + in.Delim(']') + } + case "properties": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Properties).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk20(out *jwriter.Writer, in BackendRoomDisinviteRequest) { + out.RawByte('{') + first := true + _ = first + if len(in.UserIds) != 0 { + const prefix string = ",\"userids\":" + first = false + out.RawString(prefix[1:]) + { + out.RawByte('[') + for v62, v63 := range in.UserIds { + if v62 > 0 { + out.RawByte(',') + } + out.String(string(v63)) + } + out.RawByte(']') + } + } + if len(in.SessionIds) != 0 { + const prefix string = ",\"sessionids\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + { + out.RawByte('[') + for v64, v65 := range in.SessionIds { + if v64 > 0 { + out.RawByte(',') + } + out.String(string(v65)) + } + out.RawByte(']') + } + } + if len(in.AllUserIds) != 0 { + const prefix string = ",\"alluserids\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + { + out.RawByte('[') + for v66, v67 := range in.AllUserIds { + if v66 > 0 { + out.RawByte(',') + } + out.String(string(v67)) + } + out.RawByte(']') + } + } + if len(in.Properties) != 0 { + const prefix string = ",\"properties\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Raw((in.Properties).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomDisinviteRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk20(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomDisinviteRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk20(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomDisinviteRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk20(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomDisinviteRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk20(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk21(in *jlexer.Lexer, out *BackendRoomDialoutResponse) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "callid": + if in.IsNull() { + in.Skip() + } else { + out.CallId = string(in.String()) + } + case "error": + if in.IsNull() { + in.Skip() + out.Error = nil + } else { + if out.Error == nil { + out.Error = new(api.Error) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Error).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk21(out *jwriter.Writer, in BackendRoomDialoutResponse) { + out.RawByte('{') + first := true + _ = first + if in.CallId != "" { + const prefix string = ",\"callid\":" + first = false + out.RawString(prefix[1:]) + out.String(string(in.CallId)) + } + if in.Error != nil { + const prefix string = ",\"error\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + (*in.Error).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomDialoutResponse) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk21(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomDialoutResponse) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk21(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomDialoutResponse) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk21(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomDialoutResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk21(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk22(in *jlexer.Lexer, out *BackendRoomDialoutRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "number": + if in.IsNull() { + in.Skip() + } else { + out.Number = string(in.String()) + } + case "options": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Options).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk22(out *jwriter.Writer, in BackendRoomDialoutRequest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"number\":" + out.RawString(prefix[1:]) + out.String(string(in.Number)) + } + if len(in.Options) != 0 { + const prefix string = ",\"options\":" + out.RawString(prefix) + out.Raw((in.Options).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomDialoutRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk22(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomDialoutRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk22(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomDialoutRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk22(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomDialoutRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk22(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk23(in *jlexer.Lexer, out *BackendRoomDialoutError) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "code": + if in.IsNull() { + in.Skip() + } else { + out.Code = string(in.String()) + } + case "message": + if in.IsNull() { + in.Skip() + } else { + out.Message = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk23(out *jwriter.Writer, in BackendRoomDialoutError) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"code\":" + out.RawString(prefix[1:]) + out.String(string(in.Code)) + } + if in.Message != "" { + const prefix string = ",\"message\":" + out.RawString(prefix) + out.String(string(in.Message)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomDialoutError) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk23(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomDialoutError) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk23(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomDialoutError) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk23(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomDialoutError) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk23(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk24(in *jlexer.Lexer, out *BackendRoomDeleteRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "userids": + if in.IsNull() { + in.Skip() + out.UserIds = nil + } else { + in.Delim('[') + if out.UserIds == nil { + if !in.IsDelim(']') { + out.UserIds = make([]string, 0, 4) + } else { + out.UserIds = []string{} + } + } else { + out.UserIds = (out.UserIds)[:0] + } + for !in.IsDelim(']') { + var v68 string + if in.IsNull() { + in.Skip() + } else { + v68 = string(in.String()) + } + out.UserIds = append(out.UserIds, v68) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk24(out *jwriter.Writer, in BackendRoomDeleteRequest) { + out.RawByte('{') + first := true + _ = first + if len(in.UserIds) != 0 { + const prefix string = ",\"userids\":" + first = false + out.RawString(prefix[1:]) + { + out.RawByte('[') + for v69, v70 := range in.UserIds { + if v69 > 0 { + out.RawByte(',') + } + out.String(string(v70)) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendRoomDeleteRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk24(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendRoomDeleteRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk24(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendRoomDeleteRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk24(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendRoomDeleteRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk24(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk25(in *jlexer.Lexer, out *BackendPingEntry) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "userid": + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } + case "sessionid": + if in.IsNull() { + in.Skip() + } else { + out.SessionId = api.RoomSessionId(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk25(out *jwriter.Writer, in BackendPingEntry) { + out.RawByte('{') + first := true + _ = first + if in.UserId != "" { + const prefix string = ",\"userid\":" + first = false + out.RawString(prefix[1:]) + out.String(string(in.UserId)) + } + { + const prefix string = ",\"sessionid\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.SessionId)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendPingEntry) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk25(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendPingEntry) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk25(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendPingEntry) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk25(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendPingEntry) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk25(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk26(in *jlexer.Lexer, out *BackendClientSessionResponse) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "roomid": + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk26(out *jwriter.Writer, in BackendClientSessionResponse) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"version\":" + out.RawString(prefix[1:]) + out.String(string(in.Version)) + } + { + const prefix string = ",\"roomid\":" + out.RawString(prefix) + out.String(string(in.RoomId)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendClientSessionResponse) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk26(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendClientSessionResponse) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk26(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendClientSessionResponse) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk26(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendClientSessionResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk26(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk27(in *jlexer.Lexer, out *BackendClientSessionRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "roomid": + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } + case "action": + if in.IsNull() { + in.Skip() + } else { + out.Action = string(in.String()) + } + case "sessionid": + if in.IsNull() { + in.Skip() + } else { + out.SessionId = api.PublicSessionId(in.String()) + } + case "userid": + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } + case "user": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.User).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk27(out *jwriter.Writer, in BackendClientSessionRequest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"version\":" + out.RawString(prefix[1:]) + out.String(string(in.Version)) + } + { + const prefix string = ",\"roomid\":" + out.RawString(prefix) + out.String(string(in.RoomId)) + } + { + const prefix string = ",\"action\":" + out.RawString(prefix) + out.String(string(in.Action)) + } + { + const prefix string = ",\"sessionid\":" + out.RawString(prefix) + out.String(string(in.SessionId)) + } + if in.UserId != "" { + const prefix string = ",\"userid\":" + out.RawString(prefix) + out.String(string(in.UserId)) + } + if len(in.User) != 0 { + const prefix string = ",\"user\":" + out.RawString(prefix) + out.Raw((in.User).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendClientSessionRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk27(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendClientSessionRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk27(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendClientSessionRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk27(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendClientSessionRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk27(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk28(in *jlexer.Lexer, out *BackendClientRoomResponse) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "roomid": + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } + case "properties": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Properties).UnmarshalJSON(data)) + } + } + case "session": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Session).UnmarshalJSON(data)) + } + } + case "permissions": + if in.IsNull() { + in.Skip() + out.Permissions = nil + } else { + if out.Permissions == nil { + out.Permissions = new([]api.Permission) + } + if in.IsNull() { + in.Skip() + *out.Permissions = nil + } else { + in.Delim('[') + if *out.Permissions == nil { + if !in.IsDelim(']') { + *out.Permissions = make([]api.Permission, 0, 4) + } else { + *out.Permissions = []api.Permission{} + } + } else { + *out.Permissions = (*out.Permissions)[:0] + } + for !in.IsDelim(']') { + var v71 api.Permission + if in.IsNull() { + in.Skip() + } else { + v71 = api.Permission(in.String()) + } + *out.Permissions = append(*out.Permissions, v71) + in.WantComma() + } + in.Delim(']') + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk28(out *jwriter.Writer, in BackendClientRoomResponse) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"version\":" + out.RawString(prefix[1:]) + out.String(string(in.Version)) + } + { + const prefix string = ",\"roomid\":" + out.RawString(prefix) + out.String(string(in.RoomId)) + } + { + const prefix string = ",\"properties\":" + out.RawString(prefix) + out.Raw((in.Properties).MarshalJSON()) + } + if len(in.Session) != 0 { + const prefix string = ",\"session\":" + out.RawString(prefix) + out.Raw((in.Session).MarshalJSON()) + } + if in.Permissions != nil { + const prefix string = ",\"permissions\":" + out.RawString(prefix) + if *in.Permissions == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v72, v73 := range *in.Permissions { + if v72 > 0 { + out.RawByte(',') + } + out.String(string(v73)) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendClientRoomResponse) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk28(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendClientRoomResponse) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk28(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendClientRoomResponse) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk28(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendClientRoomResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk28(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk29(in *jlexer.Lexer, out *BackendClientRoomRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "roomid": + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } + case "action": + if in.IsNull() { + in.Skip() + } else { + out.Action = string(in.String()) + } + case "userid": + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } + case "sessionid": + if in.IsNull() { + in.Skip() + } else { + out.SessionId = api.RoomSessionId(in.String()) + } + case "actorid": + if in.IsNull() { + in.Skip() + } else { + out.ActorId = string(in.String()) + } + case "actortype": + if in.IsNull() { + in.Skip() + } else { + out.ActorType = string(in.String()) + } + case "incall": + if in.IsNull() { + in.Skip() + } else { + out.InCall = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk29(out *jwriter.Writer, in BackendClientRoomRequest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"version\":" + out.RawString(prefix[1:]) + out.String(string(in.Version)) + } + { + const prefix string = ",\"roomid\":" + out.RawString(prefix) + out.String(string(in.RoomId)) + } + if in.Action != "" { + const prefix string = ",\"action\":" + out.RawString(prefix) + out.String(string(in.Action)) + } + { + const prefix string = ",\"userid\":" + out.RawString(prefix) + out.String(string(in.UserId)) + } + { + const prefix string = ",\"sessionid\":" + out.RawString(prefix) + out.String(string(in.SessionId)) + } + if in.ActorId != "" { + const prefix string = ",\"actorid\":" + out.RawString(prefix) + out.String(string(in.ActorId)) + } + if in.ActorType != "" { + const prefix string = ",\"actortype\":" + out.RawString(prefix) + out.String(string(in.ActorType)) + } + if in.InCall != 0 { + const prefix string = ",\"incall\":" + out.RawString(prefix) + out.Int(int(in.InCall)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendClientRoomRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk29(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendClientRoomRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk29(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendClientRoomRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk29(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendClientRoomRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk29(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk30(in *jlexer.Lexer, out *BackendClientRingResponse) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "roomid": + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk30(out *jwriter.Writer, in BackendClientRingResponse) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"version\":" + out.RawString(prefix[1:]) + out.String(string(in.Version)) + } + { + const prefix string = ",\"roomid\":" + out.RawString(prefix) + out.String(string(in.RoomId)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendClientRingResponse) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk30(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendClientRingResponse) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk30(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendClientRingResponse) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk30(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendClientRingResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk30(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk31(in *jlexer.Lexer, out *BackendClientResponse) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "error": + if in.IsNull() { + in.Skip() + out.Error = nil + } else { + if out.Error == nil { + out.Error = new(api.Error) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Error).UnmarshalEasyJSON(in) + } + } + case "auth": + if in.IsNull() { + in.Skip() + out.Auth = nil + } else { + if out.Auth == nil { + out.Auth = new(BackendClientAuthResponse) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Auth).UnmarshalEasyJSON(in) + } + } + case "room": + if in.IsNull() { + in.Skip() + out.Room = nil + } else { + if out.Room == nil { + out.Room = new(BackendClientRoomResponse) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Room).UnmarshalEasyJSON(in) + } + } + case "ping": + if in.IsNull() { + in.Skip() + out.Ping = nil + } else { + if out.Ping == nil { + out.Ping = new(BackendClientRingResponse) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Ping).UnmarshalEasyJSON(in) + } + } + case "session": + if in.IsNull() { + in.Skip() + out.Session = nil + } else { + if out.Session == nil { + out.Session = new(BackendClientSessionResponse) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Session).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk31(out *jwriter.Writer, in BackendClientResponse) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"type\":" + out.RawString(prefix[1:]) + out.String(string(in.Type)) + } + if in.Error != nil { + const prefix string = ",\"error\":" + out.RawString(prefix) + (*in.Error).MarshalEasyJSON(out) + } + if in.Auth != nil { + const prefix string = ",\"auth\":" + out.RawString(prefix) + (*in.Auth).MarshalEasyJSON(out) + } + if in.Room != nil { + const prefix string = ",\"room\":" + out.RawString(prefix) + (*in.Room).MarshalEasyJSON(out) + } + if in.Ping != nil { + const prefix string = ",\"ping\":" + out.RawString(prefix) + (*in.Ping).MarshalEasyJSON(out) + } + if in.Session != nil { + const prefix string = ",\"session\":" + out.RawString(prefix) + (*in.Session).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendClientResponse) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk31(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendClientResponse) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk31(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendClientResponse) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk31(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendClientResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk31(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk32(in *jlexer.Lexer, out *BackendClientRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "auth": + if in.IsNull() { + in.Skip() + out.Auth = nil + } else { + if out.Auth == nil { + out.Auth = new(BackendClientAuthRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Auth).UnmarshalEasyJSON(in) + } + } + case "room": + if in.IsNull() { + in.Skip() + out.Room = nil + } else { + if out.Room == nil { + out.Room = new(BackendClientRoomRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Room).UnmarshalEasyJSON(in) + } + } + case "ping": + if in.IsNull() { + in.Skip() + out.Ping = nil + } else { + if out.Ping == nil { + out.Ping = new(BackendClientPingRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Ping).UnmarshalEasyJSON(in) + } + } + case "session": + if in.IsNull() { + in.Skip() + out.Session = nil + } else { + if out.Session == nil { + out.Session = new(BackendClientSessionRequest) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Session).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk32(out *jwriter.Writer, in BackendClientRequest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"type\":" + out.RawString(prefix[1:]) + out.String(string(in.Type)) + } + if in.Auth != nil { + const prefix string = ",\"auth\":" + out.RawString(prefix) + (*in.Auth).MarshalEasyJSON(out) + } + if in.Room != nil { + const prefix string = ",\"room\":" + out.RawString(prefix) + (*in.Room).MarshalEasyJSON(out) + } + if in.Ping != nil { + const prefix string = ",\"ping\":" + out.RawString(prefix) + (*in.Ping).MarshalEasyJSON(out) + } + if in.Session != nil { + const prefix string = ",\"session\":" + out.RawString(prefix) + (*in.Session).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendClientRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk32(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendClientRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk32(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendClientRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk32(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendClientRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk32(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk33(in *jlexer.Lexer, out *BackendClientPingRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "roomid": + if in.IsNull() { + in.Skip() + } else { + out.RoomId = string(in.String()) + } + case "entries": + if in.IsNull() { + in.Skip() + out.Entries = nil + } else { + in.Delim('[') + if out.Entries == nil { + if !in.IsDelim(']') { + out.Entries = make([]BackendPingEntry, 0, 2) + } else { + out.Entries = []BackendPingEntry{} + } + } else { + out.Entries = (out.Entries)[:0] + } + for !in.IsDelim(']') { + var v74 BackendPingEntry + if in.IsNull() { + in.Skip() + } else { + (v74).UnmarshalEasyJSON(in) + } + out.Entries = append(out.Entries, v74) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk33(out *jwriter.Writer, in BackendClientPingRequest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"version\":" + out.RawString(prefix[1:]) + out.String(string(in.Version)) + } + { + const prefix string = ",\"roomid\":" + out.RawString(prefix) + out.String(string(in.RoomId)) + } + { + const prefix string = ",\"entries\":" + out.RawString(prefix) + if in.Entries == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v75, v76 := range in.Entries { + if v75 > 0 { + out.RawByte(',') + } + (v76).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendClientPingRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk33(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendClientPingRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk33(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendClientPingRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk33(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendClientPingRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk33(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk34(in *jlexer.Lexer, out *BackendClientAuthResponse) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "userid": + if in.IsNull() { + in.Skip() + } else { + out.UserId = string(in.String()) + } + case "user": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.User).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk34(out *jwriter.Writer, in BackendClientAuthResponse) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"version\":" + out.RawString(prefix[1:]) + out.String(string(in.Version)) + } + { + const prefix string = ",\"userid\":" + out.RawString(prefix) + out.String(string(in.UserId)) + } + { + const prefix string = ",\"user\":" + out.RawString(prefix) + out.Raw((in.User).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendClientAuthResponse) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk34(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendClientAuthResponse) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk34(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendClientAuthResponse) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk34(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendClientAuthResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk34(l, v) +} +func easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk35(in *jlexer.Lexer, out *BackendClientAuthRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "params": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Params).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk35(out *jwriter.Writer, in BackendClientAuthRequest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"version\":" + out.RawString(prefix[1:]) + out.String(string(in.Version)) + } + { + const prefix string = ",\"params\":" + out.RawString(prefix) + out.Raw((in.Params).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v BackendClientAuthRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk35(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v BackendClientAuthRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonC1cedd36EncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk35(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *BackendClientAuthRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk35(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *BackendClientAuthRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonC1cedd36DecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk35(l, v) +} diff --git a/api_backend_test.go b/talk/api_test.go similarity index 95% rename from api_backend_test.go rename to talk/api_test.go index 724075d..01c4510 100644 --- a/api_backend_test.go +++ b/talk/api_test.go @@ -19,19 +19,21 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( "net/http" "testing" "github.com/stretchr/testify/assert" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" ) func TestBackendChecksum(t *testing.T) { t.Parallel() assert := assert.New(t) - rnd := newRandomString(32) + rnd := internal.RandomString(32) body := []byte{1, 2, 3, 4, 5} secret := []byte("shared-secret") diff --git a/talk/backend.go b/talk/backend.go new file mode 100644 index 0000000..c9ef31c --- /dev/null +++ b/talk/backend.go @@ -0,0 +1,314 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2020 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package talk + +import ( + "bytes" + "fmt" + "net/url" + "slices" + "strings" + "sync" + + "github.com/dlintw/goconf" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" +) + +var ( + SessionLimitExceeded = api.NewError("session_limit_exceeded", "Too many sessions connected for this backend.") // +checklocksignore: Global readonly variable. +) + +func init() { + registerBackendStats() +} + +type Backend struct { + id string + urls []string + secret []byte + + allowHttp bool + + maxStreamBitrate api.Bandwidth + maxScreenBitrate api.Bandwidth + + sessionLimit uint64 + sessionsLock sync.Mutex + // +checklocks:sessionsLock + sessions map[api.PublicSessionId]bool + + counted bool +} + +func NewCompatBackend(cfg *goconf.ConfigFile) *Backend { + if cfg == nil { + return &Backend{ + id: "compat", + } + } + + allowHttp, _ := cfg.GetBool("backend", "allowhttp") + commonSecret, _ := config.GetStringOptionWithEnv(cfg, "backend", "secret") + sessionLimit, err := cfg.GetInt("backend", "sessionlimit") + if err != nil || sessionLimit < 0 { + sessionLimit = 0 + } + maxStreamBitrate, err := cfg.GetInt("backend", "maxstreambitrate") + if err != nil || maxStreamBitrate < 0 { + maxStreamBitrate = 0 + } + maxScreenBitrate, err := cfg.GetInt("backend", "maxscreenbitrate") + if err != nil || maxScreenBitrate < 0 { + maxScreenBitrate = 0 + } + + return &Backend{ + id: "compat", + secret: []byte(commonSecret), + + allowHttp: allowHttp, + + sessionLimit: uint64(sessionLimit), + counted: true, + + maxStreamBitrate: api.BandwidthFromBits(uint64(maxStreamBitrate)), + maxScreenBitrate: api.BandwidthFromBits(uint64(maxScreenBitrate)), + } +} + +func NewBackendFromConfig(logger log.Logger, id string, cfg *goconf.ConfigFile, commonSecret string) (*Backend, error) { + secret, _ := config.GetStringOptionWithEnv(cfg, id, "secret") + if secret == "" && commonSecret != "" { + logger.Printf("Backend %s has no own shared secret set, using common shared secret", id) + secret = commonSecret + } + if secret == "" { + return nil, fmt.Errorf("backend %s is missing or incomplete, skipping", id) + } + + sessionLimit, err := cfg.GetInt(id, "sessionlimit") + if err != nil || sessionLimit < 0 { + sessionLimit = 0 + } + maxStreamBitrate, err := cfg.GetInt(id, "maxstreambitrate") + if err != nil || maxStreamBitrate < 0 { + maxStreamBitrate = 0 + } + maxScreenBitrate, err := cfg.GetInt(id, "maxscreenbitrate") + if err != nil || maxScreenBitrate < 0 { + maxScreenBitrate = 0 + } + + return &Backend{ + id: id, + secret: []byte(secret), + + maxStreamBitrate: api.BandwidthFromBits(uint64(maxStreamBitrate)), + maxScreenBitrate: api.BandwidthFromBits(uint64(maxScreenBitrate)), + + sessionLimit: uint64(sessionLimit), + }, nil +} + +func NewBackendFromEtcd(key string, info *etcd.BackendInformationEtcd) *Backend { + allowHttp := slices.ContainsFunc(info.ParsedUrls, func(u *url.URL) bool { + return u.Scheme == "http" + }) + + return &Backend{ + id: key, + urls: info.Urls, + secret: []byte(info.Secret), + + allowHttp: allowHttp, + + maxStreamBitrate: info.MaxStreamBitrate, + maxScreenBitrate: info.MaxScreenBitrate, + sessionLimit: info.SessionLimit, + } +} + +func (b *Backend) Id() string { + return b.id +} + +func (b *Backend) Secret() []byte { + return b.secret +} + +func (b *Backend) IsCompat() bool { + return len(b.urls) == 0 +} + +func (b *Backend) Equal(other *Backend) bool { + if b == other { + return true + } else if b == nil || other == nil { + return false + } + + return b.id == other.id && + b.allowHttp == other.allowHttp && + b.maxStreamBitrate == other.maxStreamBitrate && + b.maxScreenBitrate == other.maxScreenBitrate && + b.sessionLimit == other.sessionLimit && + bytes.Equal(b.secret, other.secret) && + slices.Equal(b.urls, other.urls) +} + +func (b *Backend) IsUrlAllowed(u *url.URL) bool { + switch u.Scheme { + case "https": + return true + case "http": + return b.allowHttp + default: + return false + } +} + +func (b *Backend) HasUrl(url string) bool { + if b.IsCompat() { + // Old-style configuration, only hosts are configured. + return true + } + + for _, u := range b.urls { + if strings.HasPrefix(url, u) { + return true + } + } + + return false +} + +func (b *Backend) Urls() []string { + return b.urls +} + +func (b *Backend) AddUrl(u *url.URL) { + b.urls = append(b.urls, u.String()) + if u.Scheme == "http" { + b.allowHttp = true + } +} + +func (b *Backend) Limit() int { + return int(b.sessionLimit) +} + +func (b *Backend) SetMaxStreamBitrate(bitrate api.Bandwidth) { + b.maxStreamBitrate = bitrate +} + +func (b *Backend) MaxStreamBitrate() api.Bandwidth { + return b.maxStreamBitrate +} + +func (b *Backend) SetMaxScreenBitrate(bitrate api.Bandwidth) { + b.maxScreenBitrate = bitrate +} + +func (b *Backend) MaxScreenBitrate() api.Bandwidth { + return b.maxScreenBitrate +} + +func (b *Backend) CopyCount(other *Backend) { + b.counted = other.counted +} + +func (b *Backend) Count() bool { + if b.counted { + return false + } + + b.counted = true + return true +} + +func (b *Backend) Uncount() bool { + if !b.counted { + return false + } + + b.counted = false + return true +} + +func (b *Backend) Len() int { + b.sessionsLock.Lock() + defer b.sessionsLock.Unlock() + return len(b.sessions) +} + +type BackendSession interface { + PublicId() api.PublicSessionId + ClientType() api.ClientType +} + +func (b *Backend) AddSession(session BackendSession) error { + if session.ClientType() == api.HelloClientTypeInternal || session.ClientType() == api.HelloClientTypeVirtual { + // Internal and virtual sessions are not counting to the limit. + return nil + } + + if b.sessionLimit == 0 { + // Not limited + return nil + } + + b.sessionsLock.Lock() + defer b.sessionsLock.Unlock() + if b.sessions == nil { + b.sessions = make(map[api.PublicSessionId]bool) + } else if uint64(len(b.sessions)) >= b.sessionLimit { + registerBackendStats() + statsBackendLimitExceededTotal.WithLabelValues(b.id).Inc() + return SessionLimitExceeded + } + + b.sessions[session.PublicId()] = true + return nil +} + +func (b *Backend) RemoveSession(session BackendSession) { + b.sessionsLock.Lock() + defer b.sessionsLock.Unlock() + + delete(b.sessions, session.PublicId()) +} + +func (b *Backend) UpdateStats() { + if b.sessionLimit > 0 { + statsBackendLimit.WithLabelValues(b.id).Set(float64(b.sessionLimit)) + } else { + statsBackendLimit.DeleteLabelValues(b.id) + } +} + +func (b *Backend) DeleteStats() { + statsBackendLimit.DeleteLabelValues(b.id) +} diff --git a/talk/backend_client.go b/talk/backend_client.go new file mode 100644 index 0000000..16b9614 --- /dev/null +++ b/talk/backend_client.go @@ -0,0 +1,276 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2017 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package talk + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/dlintw/goconf" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/pool" +) + +var ( + ErrUnsupportedContentType = errors.New("unsupported_content_type") + + ErrIncompleteResponse = errors.New("incomplete OCS response") + ErrThrottledResponse = errors.New("throttled OCS response") +) + +func init() { + RegisterBackendClientStats() +} + +type FeaturesFunc = func() []string + +var ( + emptyFeaturesFunc = func() []string { + return nil + } +) + +type BackendClient struct { + version string + features FeaturesFunc + backends *BackendConfiguration + + pool *pool.HttpClientPool + capabilities *Capabilities + buffers pool.BufferPool +} + +func NewBackendClient(ctx context.Context, config *goconf.ConfigFile, maxConcurrentRequestsPerHost int, version string, etcdClient etcd.Client) (*BackendClient, error) { + logger := log.LoggerFromContext(ctx) + backends, err := NewBackendConfiguration(logger, config, etcdClient) + if err != nil { + return nil, err + } + + skipverify, _ := config.GetBool("backend", "skipverify") + if skipverify { + logger.Println("WARNING: Backend verification is disabled!") + } + + pool, err := pool.NewHttpClientPool(maxConcurrentRequestsPerHost, skipverify) + if err != nil { + return nil, err + } + + capabilities, err := NewCapabilities(version, pool) + if err != nil { + return nil, err + } + + return &BackendClient{ + version: version, + features: emptyFeaturesFunc, + backends: backends, + + pool: pool, + capabilities: capabilities, + }, nil +} + +func (b *BackendClient) Close() { + b.backends.Close() +} + +func (b *BackendClient) Reload(config *goconf.ConfigFile) { + b.backends.Reload(config) +} + +func (b *BackendClient) GetCompatBackend() *Backend { + return b.backends.GetCompatBackend() +} + +func (b *BackendClient) GetBackend(u *url.URL) *Backend { + return b.backends.GetBackend(u) +} + +func (b *BackendClient) GetBackends() []*Backend { + return b.backends.GetBackends() +} + +func (b *BackendClient) IsUrlAllowed(u *url.URL) bool { + return b.backends.IsUrlAllowed(u) +} + +func (b *BackendClient) HasCapabilityFeature(ctx context.Context, u *url.URL, feature string) bool { + return b.capabilities.HasCapabilityFeature(ctx, u, feature) +} + +func (b *BackendClient) GetStringConfig(ctx context.Context, u *url.URL, group, key string) (string, bool, bool) { + return b.capabilities.GetStringConfig(ctx, u, group, key) +} + +func (b *BackendClient) GetIntegerConfig(ctx context.Context, u *url.URL, group, key string) (int, bool, bool) { + return b.capabilities.GetIntegerConfig(ctx, u, group, key) +} + +func (b *BackendClient) InvalidateCapabilities(u *url.URL) { + b.capabilities.InvalidateCapabilities(u) +} + +func (b *BackendClient) SetFeaturesFunc(f func() []string) { + if f == nil { + f = emptyFeaturesFunc + } + + b.features = f +} + +// PerformJSONRequest sends a JSON POST request to the given url and decodes +// the result into "response". +func (b *BackendClient) PerformJSONRequest(ctx context.Context, u *url.URL, request any, response any) error { + logger := log.LoggerFromContext(ctx) + if u == nil { + return fmt.Errorf("no url passed to perform JSON request %+v", request) + } + + backend := b.backends.GetBackend(u) + if backend == nil { + return fmt.Errorf("no backend configured for %s", u) + } + + var requestUrl *url.URL + if b.capabilities.HasCapabilityFeature(ctx, u, FeatureSignalingV3Api) { + newUrl := *u + newUrl.Path = strings.ReplaceAll(newUrl.Path, "/spreed/api/v1/signaling/", "/spreed/api/v3/signaling/") + newUrl.Path = strings.ReplaceAll(newUrl.Path, "/spreed/api/v2/signaling/", "/spreed/api/v3/signaling/") + requestUrl = &newUrl + } else { + requestUrl = u + } + + c, pool, err := b.pool.Get(ctx, u) + if err != nil { + logger.Printf("Could not get client for host %s: %s", u.Host, err) + return err + } + defer pool.Put(c) + + data, err := b.buffers.MarshalAsJSON(request) + if err != nil { + logger.Printf("Could not marshal request %+v: %s", request, err) + return err + } + + defer b.buffers.Put(data) + req, err := http.NewRequestWithContext(ctx, "POST", requestUrl.String(), data) + if err != nil { + logger.Printf("Could not create request to %s: %s", requestUrl, err) + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("OCS-APIRequest", "true") + req.Header.Set("User-Agent", "nextcloud-spreed-signaling/"+b.version) + if features := b.features(); len(features) > 0 { + req.Header.Set("X-Spreed-Signaling-Features", strings.Join(features, ", ")) + } + + // Add checksum so the backend can validate the request. + AddBackendChecksum(req, data.Bytes(), backend.Secret()) + + start := time.Now() + resp, err := c.Do(req) + end := time.Now() + duration := end.Sub(start) + statsBackendClientRequests.WithLabelValues(backend.Id()).Inc() + statsBackendClientDuration.WithLabelValues(backend.Id()).Observe(duration.Seconds()) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + statsBackendClientError.WithLabelValues(backend.Id(), "timeout").Inc() + } else if errors.Is(err, context.Canceled) { + statsBackendClientError.WithLabelValues(backend.Id(), "canceled").Inc() + } else { + statsBackendClientError.WithLabelValues(backend.Id(), "unknown").Inc() + } + logger.Printf("Could not send request %s to %s: %s", data.String(), req.URL, err) + return err + } + defer resp.Body.Close() + + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + logger.Printf("Received unsupported content-type from %s for %s: %s (%s)", req.URL, data.String(), ct, resp.Status) + statsBackendClientError.WithLabelValues(backend.Id(), "invalid_content_type").Inc() + return ErrUnsupportedContentType + } + + body, err := b.buffers.ReadAll(resp.Body) + if err != nil { + logger.Printf("Could not read response body from %s for %s: %s", req.URL, data.String(), err) + statsBackendClientError.WithLabelValues(backend.Id(), "error_reading_body").Inc() + return err + } + + defer b.buffers.Put(body) + + if IsOcsRequest(u) || req.Header.Get("OCS-APIRequest") != "" { + // OCS response are wrapped in an OCS container that needs to be parsed + // to get the actual contents: + // { + // "ocs": { + // "meta": { ... }, + // "data": { ... } + // } + // } + var ocs OcsResponse + if err := json.Unmarshal(body.Bytes(), &ocs); err != nil { + logger.Printf("Could not decode OCS response %s from %s: %s", body.String(), req.URL, err) + statsBackendClientError.WithLabelValues(backend.Id(), "error_decoding_ocs").Inc() + return err + } else if ocs.Ocs == nil || len(ocs.Ocs.Data) == 0 { + logger.Printf("Incomplete OCS response %s from %s", body.String(), req.URL) + statsBackendClientError.WithLabelValues(backend.Id(), "error_incomplete_ocs").Inc() + return ErrIncompleteResponse + } + + switch ocs.Ocs.Meta.StatusCode { + case http.StatusTooManyRequests: + logger.Printf("Throttled OCS response %s from %s", body.String(), req.URL) + statsBackendClientError.WithLabelValues(backend.Id(), "throttled").Inc() + return ErrThrottledResponse + } + + if err := json.Unmarshal(ocs.Ocs.Data, response); err != nil { + logger.Printf("Could not decode OCS response body %s from %s: %s", string(ocs.Ocs.Data), req.URL, err) + statsBackendClientError.WithLabelValues(backend.Id(), "error_decoding_ocs_data").Inc() + return err + } + } else if err := json.Unmarshal(body.Bytes(), response); err != nil { + logger.Printf("Could not decode response body %s from %s: %s", body.String(), req.URL, err) + statsBackendClientError.WithLabelValues(backend.Id(), "error_decoding_body").Inc() + return err + } + return nil +} diff --git a/talk/backend_client_stats_prometheus.go b/talk/backend_client_stats_prometheus.go new file mode 100644 index 0000000..8c07ee5 --- /dev/null +++ b/talk/backend_client_stats_prometheus.go @@ -0,0 +1,60 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package talk + +import ( + "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" +) + +var ( + statsBackendClientRequests = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "backend_client", + Name: "requests_total", + Help: "The total number of backend client requests", + }, []string{"backend"}) + statsBackendClientDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "signaling", + Subsystem: "backend_client", + Name: "requests_duration", + Help: "The duration of backend client requests in seconds", + Buckets: prometheus.ExponentialBucketsRange(0.01, 30, 30), + }, []string{"backend"}) + statsBackendClientError = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "signaling", + Subsystem: "backend_client", + Name: "requests_errors_total", + Help: "The total number of backend client requests that had an error", + }, []string{"backend", "error"}) + + backendClientStats = []prometheus.Collector{ + statsBackendClientRequests, + statsBackendClientDuration, + statsBackendClientError, + } +) + +func RegisterBackendClientStats() { + metrics.RegisterAll(backendClientStats...) +} diff --git a/backend_client_test.go b/talk/backend_client_test.go similarity index 84% rename from backend_client_test.go rename to talk/backend_client_test.go index d0a4d77..8443c78 100644 --- a/backend_client_test.go +++ b/talk/backend_client_test.go @@ -19,10 +19,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( - "context" "encoding/json" "io" "net/http" @@ -35,6 +34,10 @@ import ( "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/pool" ) func returnOCS(t *testing.T, w http.ResponseWriter, body []byte) { @@ -67,19 +70,21 @@ func returnOCS(t *testing.T, w http.ResponseWriter, body []byte) { func TestPostOnRedirect(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) r := mux.NewRouter() r.HandleFunc("/ocs/v2.php/one", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ocs/v2.php/two", http.StatusTemporaryRedirect) }) r.HandleFunc("/ocs/v2.php/two", func(w http.ResponseWriter, r *http.Request) { + assert := assert.New(t) body, err := io.ReadAll(r.Body) - require.NoError(err) + assert.NoError(err) var request map[string]string err = json.Unmarshal(body, &request) - require.NoError(err) + assert.NoError(err) returnOCS(t, w, body) }) @@ -96,10 +101,9 @@ func TestPostOnRedirect(t *testing.T) { if u.Scheme == "http" { config.AddOption("backend", "allowhttp", "true") } - client, err := NewBackendClient(config, 1, "0.0", nil) + client, err := NewBackendClient(ctx, config, 1, "0.0", nil) require.NoError(err) - ctx := context.Background() request := map[string]string{ "foo": "bar", } @@ -114,7 +118,8 @@ func TestPostOnRedirect(t *testing.T) { func TestPostOnRedirectDifferentHost(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) r := mux.NewRouter() r.HandleFunc("/ocs/v2.php/one", func(w http.ResponseWriter, r *http.Request) { @@ -132,10 +137,9 @@ func TestPostOnRedirectDifferentHost(t *testing.T) { if u.Scheme == "http" { config.AddOption("backend", "allowhttp", "true") } - client, err := NewBackendClient(config, 1, "0.0", nil) + client, err := NewBackendClient(ctx, config, 1, "0.0", nil) require.NoError(err) - ctx := context.Background() request := map[string]string{ "foo": "bar", } @@ -143,7 +147,7 @@ func TestPostOnRedirectDifferentHost(t *testing.T) { err = client.PerformJSONRequest(ctx, u, request, &response) if err != nil { // The redirect to a different host should have failed. - require.ErrorIs(err, ErrNotRedirecting) + require.ErrorIs(err, pool.ErrNotRedirecting) } else { require.Fail("The redirect should have failed") } @@ -151,7 +155,8 @@ func TestPostOnRedirectDifferentHost(t *testing.T) { func TestPostOnRedirectStatusFound(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) r := mux.NewRouter() @@ -160,9 +165,10 @@ func TestPostOnRedirectStatusFound(t *testing.T) { }) r.HandleFunc("/ocs/v2.php/two", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) - require.NoError(err) + if assert.NoError(err) { + assert.Empty(string(body), "Should not have received any body, got %s", string(body)) + } - assert.Empty(string(body), "Should not have received any body, got %s", string(body)) returnOCS(t, w, []byte("{}")) }) server := httptest.NewServer(r) @@ -177,10 +183,9 @@ func TestPostOnRedirectStatusFound(t *testing.T) { if u.Scheme == "http" { config.AddOption("backend", "allowhttp", "true") } - client, err := NewBackendClient(config, 1, "0.0", nil) + client, err := NewBackendClient(ctx, config, 1, "0.0", nil) require.NoError(err) - ctx := context.Background() request := map[string]string{ "foo": "bar", } @@ -193,7 +198,8 @@ func TestPostOnRedirectStatusFound(t *testing.T) { func TestHandleThrottled(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) require := require.New(t) assert := assert.New(t) r := mux.NewRouter() @@ -212,10 +218,9 @@ func TestHandleThrottled(t *testing.T) { if u.Scheme == "http" { config.AddOption("backend", "allowhttp", "true") } - client, err := NewBackendClient(config, 1, "0.0", nil) + client, err := NewBackendClient(ctx, config, 1, "0.0", nil) require.NoError(err) - ctx := context.Background() request := map[string]string{ "foo": "bar", } diff --git a/backend_configuration.go b/talk/backend_configuration.go similarity index 58% rename from backend_configuration.go rename to talk/backend_configuration.go index fa6b45b..e76785e 100644 --- a/backend_configuration.go +++ b/talk/backend_configuration.go @@ -19,15 +19,20 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( "fmt" "net/url" + "slices" "strings" "sync" "github.com/dlintw/goconf" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" ) const ( @@ -37,111 +42,28 @@ const ( DefaultBackendType = BackendTypeStatic ) -var ( - SessionLimitExceeded = NewError("session_limit_exceeded", "Too many sessions connected for this backend.") -) - -type Backend struct { - id string - url string - parsedUrl *url.URL - secret []byte - compat bool - - allowHttp bool - - maxStreamBitrate int - maxScreenBitrate int - - sessionLimit uint64 - sessionsLock sync.Mutex - sessions map[string]bool -} - -func (b *Backend) Id() string { - return b.id -} - -func (b *Backend) Secret() []byte { - return b.secret -} - -func (b *Backend) IsCompat() bool { - return b.compat -} - -func (b *Backend) IsUrlAllowed(u *url.URL) bool { - switch u.Scheme { - case "https": - return true - case "http": - return b.allowHttp - default: - return false - } -} - -func (b *Backend) Url() string { - return b.url -} - -func (b *Backend) ParsedUrl() *url.URL { - return b.parsedUrl -} - -func (b *Backend) Limit() int { - return int(b.sessionLimit) -} - -func (b *Backend) Len() int { - b.sessionsLock.Lock() - defer b.sessionsLock.Unlock() - return len(b.sessions) -} - -func (b *Backend) AddSession(session Session) error { - if session.ClientType() == HelloClientTypeInternal || session.ClientType() == HelloClientTypeVirtual { - // Internal and virtual sessions are not counting to the limit. - return nil - } - - if b.sessionLimit == 0 { - // Not limited - return nil - } - - b.sessionsLock.Lock() - defer b.sessionsLock.Unlock() - if b.sessions == nil { - b.sessions = make(map[string]bool) - } else if uint64(len(b.sessions)) >= b.sessionLimit { - statsBackendLimitExceededTotal.WithLabelValues(b.id).Inc() - return SessionLimitExceeded - } - - b.sessions[session.PublicId()] = true - return nil -} - -func (b *Backend) RemoveSession(session Session) { - b.sessionsLock.Lock() - defer b.sessionsLock.Unlock() - - delete(b.sessions, session.PublicId()) -} - type BackendStorage interface { Close() - Reload(config *goconf.ConfigFile) + Reload(cfg *goconf.ConfigFile) GetCompatBackend() *Backend GetBackend(u *url.URL) *Backend GetBackends() []*Backend } +type BackendStorageStats interface { + AddBackends(count int) + RemoveBackends(count int) + IncBackends() + DecBackends() +} + type backendStorageCommon struct { - mu sync.RWMutex + mu sync.RWMutex + // +checklocks:mu backends map[string][]*Backend + + stats BackendStorageStats // +checklocksignore: Only written to from constructor } func (s *backendStorageCommon) GetBackends() []*Backend { @@ -152,6 +74,12 @@ func (s *backendStorageCommon) GetBackends() []*Backend { for _, entries := range s.backends { result = append(result, entries...) } + slices.SortFunc(result, func(a, b *Backend) int { + return strings.Compare(a.Id(), b.Id()) + }) + result = slices.CompactFunc(result, func(a, b *Backend) bool { + return a.Id() == b.Id() + }) return result } @@ -173,10 +101,7 @@ func (s *backendStorageCommon) getBackendLocked(u *url.URL) *Backend { continue } - if entry.url == "" { - // Old-style configuration, only hosts are configured. - return entry - } else if strings.HasPrefix(url, entry.url) { + if entry.HasUrl(url) { return entry } } @@ -188,21 +113,50 @@ type BackendConfiguration struct { storage BackendStorage } -func NewBackendConfiguration(config *goconf.ConfigFile, etcdClient *EtcdClient) (*BackendConfiguration, error) { +type prometheusBackendStats struct{} + +func (s *prometheusBackendStats) AddBackends(count int) { + statsBackendsCurrent.Add(float64(count)) +} + +func (s *prometheusBackendStats) RemoveBackends(count int) { + statsBackendsCurrent.Sub(float64(count)) +} + +func (s *prometheusBackendStats) IncBackends() { + statsBackendsCurrent.Inc() +} + +func (s *prometheusBackendStats) DecBackends() { + statsBackendsCurrent.Dec() +} + +var ( + defaultBackendStats = &prometheusBackendStats{} +) + +func NewBackendConfiguration(logger log.Logger, config *goconf.ConfigFile, etcdClient etcd.Client) (*BackendConfiguration, error) { + return NewBackendConfigurationWithStats(logger, config, etcdClient, nil) +} + +func NewBackendConfigurationWithStats(logger log.Logger, config *goconf.ConfigFile, etcdClient etcd.Client, stats BackendStorageStats) (*BackendConfiguration, error) { backendType, _ := config.GetString("backend", "backendtype") if backendType == "" { backendType = DefaultBackendType } - RegisterBackendConfigurationStats() + if stats == nil { + RegisterBackendConfigurationStats() + stats = defaultBackendStats + } var storage BackendStorage var err error switch backendType { case BackendTypeStatic: - storage, err = NewBackendStorageStatic(config) + storage, err = NewBackendStorageStatic(logger, config, stats) case BackendTypeEtcd: - storage, err = NewBackendStorageEtcd(config, etcdClient) + storage, err = NewBackendStorageEtcd(logger, config, etcdClient, stats) default: err = fmt.Errorf("unknown backend type: %s", backendType) } @@ -228,10 +182,7 @@ func (b *BackendConfiguration) GetCompatBackend() *Backend { } func (b *BackendConfiguration) GetBackend(u *url.URL) *Backend { - if strings.Contains(u.Host, ":") && hasStandardPort(u) { - u.Host = u.Hostname() - } - + u, _ = internal.CanonicalizeUrl(u) return b.storage.GetBackend(u) } diff --git a/room_stats_prometheus.go b/talk/backend_configuration_stats_prometheus.go similarity index 69% rename from room_stats_prometheus.go rename to talk/backend_configuration_stats_prometheus.go index eec8a96..9941db8 100644 --- a/room_stats_prometheus.go +++ b/talk/backend_configuration_stats_prometheus.go @@ -19,25 +19,27 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" ) var ( - statsRoomSessionsCurrent = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + statsBackendsCurrent = prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: "signaling", - Subsystem: "room", - Name: "sessions", - Help: "The current number of sessions in a room", - }, []string{"backend", "room", "clienttype"}) + Subsystem: "backend", + Name: "current", + Help: "The current number of configured backends", + }) - roomStats = []prometheus.Collector{ - statsRoomSessionsCurrent, + backendConfigurationStats = []prometheus.Collector{ + statsBackendsCurrent, } ) -func RegisterRoomStats() { - registerAll(roomStats...) +func RegisterBackendConfigurationStats() { + metrics.RegisterAll(backendConfigurationStats...) } diff --git a/backend_configuration_test.go b/talk/backend_configuration_test.go similarity index 56% rename from backend_configuration_test.go rename to talk/backend_configuration_test.go index e467cbd..83f2f15 100644 --- a/backend_configuration_test.go +++ b/talk/backend_configuration_test.go @@ -19,25 +19,33 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( "context" "net/url" "reflect" - "sort" + "slices" + "strings" "testing" "github.com/dlintw/goconf" - "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + etcdtest "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd/test" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" +) + +var ( + testBackendSecret = []byte("secret") ) func testUrls(t *testing.T, config *BackendConfiguration, valid_urls []string, invalid_urls []string) { for _, u := range valid_urls { - u := u t.Run(u, func(t *testing.T) { + t.Parallel() assert := assert.New(t) parsed, err := url.ParseRequestURI(u) if !assert.NoError(err, "The url %s should be valid", u) { @@ -49,8 +57,8 @@ func testUrls(t *testing.T, config *BackendConfiguration, valid_urls []string, i }) } for _, u := range invalid_urls { - u := u t.Run(u, func(t *testing.T) { + t.Parallel() assert := assert.New(t) parsed, _ := url.ParseRequestURI(u) assert.False(config.IsUrlAllowed(parsed), "The url %s should not be allowed", u) @@ -60,8 +68,8 @@ func testUrls(t *testing.T, config *BackendConfiguration, valid_urls []string, i func testBackends(t *testing.T, config *BackendConfiguration, valid_urls [][]string, invalid_urls []string) { for _, entry := range valid_urls { - entry := entry t.Run(entry[0], func(t *testing.T) { + t.Parallel() assert := assert.New(t) u := entry[0] parsed, err := url.ParseRequestURI(u) @@ -75,8 +83,8 @@ func testBackends(t *testing.T, config *BackendConfiguration, valid_urls [][]str }) } for _, u := range invalid_urls { - u := u t.Run(u, func(t *testing.T) { + t.Parallel() assert := assert.New(t) parsed, _ := url.ParseRequestURI(u) assert.False(config.IsUrlAllowed(parsed), "The url %s should not be allowed", u) @@ -84,8 +92,29 @@ func testBackends(t *testing.T, config *BackendConfiguration, valid_urls [][]str } } +type mockBackendStats struct { + value int +} + +func (s *mockBackendStats) AddBackends(count int) { + s.value += count +} + +func (s *mockBackendStats) RemoveBackends(count int) { + s.value -= count +} + +func (s *mockBackendStats) IncBackends() { + s.value++ +} + +func (s *mockBackendStats) DecBackends() { + s.value-- +} + func TestIsUrlAllowed_Compat(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + logger := logtest.NewLoggerForTest(t) // Old-style configuration valid_urls := []string{ "http://domain.invalid", @@ -100,13 +129,14 @@ func TestIsUrlAllowed_Compat(t *testing.T) { config.AddOption("backend", "allowed", "domain.invalid") config.AddOption("backend", "allowhttp", "true") config.AddOption("backend", "secret", string(testBackendSecret)) - cfg, err := NewBackendConfiguration(config, nil) + cfg, err := NewBackendConfiguration(logger, config, nil) require.NoError(t, err) testUrls(t, cfg, valid_urls, invalid_urls) } func TestIsUrlAllowed_CompatForceHttps(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + logger := logtest.NewLoggerForTest(t) // Old-style configuration, force HTTPS valid_urls := []string{ "https://domain.invalid", @@ -120,13 +150,14 @@ func TestIsUrlAllowed_CompatForceHttps(t *testing.T) { config := goconf.NewConfigFile() config.AddOption("backend", "allowed", "domain.invalid") config.AddOption("backend", "secret", string(testBackendSecret)) - cfg, err := NewBackendConfiguration(config, nil) + cfg, err := NewBackendConfiguration(logger, config, nil) require.NoError(t, err) testUrls(t, cfg, valid_urls, invalid_urls) } func TestIsUrlAllowed(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + logger := logtest.NewLoggerForTest(t) valid_urls := [][]string{ {"https://domain.invalid/foo", string(testBackendSecret) + "-foo"}, {"https://domain.invalid/foo/", string(testBackendSecret) + "-foo"}, @@ -164,13 +195,14 @@ func TestIsUrlAllowed(t *testing.T) { config.AddOption("baz", "secret", string(testBackendSecret)+"-baz") config.AddOption("lala", "url", "https://otherdomain.invalid/") config.AddOption("lala", "secret", string(testBackendSecret)+"-lala") - cfg, err := NewBackendConfiguration(config, nil) + cfg, err := NewBackendConfiguration(logger, config, nil) require.NoError(t, err) testBackends(t, cfg, valid_urls, invalid_urls) } func TestIsUrlAllowed_EmptyAllowlist(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + logger := logtest.NewLoggerForTest(t) valid_urls := []string{} invalid_urls := []string{ "http://domain.invalid", @@ -180,13 +212,14 @@ func TestIsUrlAllowed_EmptyAllowlist(t *testing.T) { config := goconf.NewConfigFile() config.AddOption("backend", "allowed", "") config.AddOption("backend", "secret", string(testBackendSecret)) - cfg, err := NewBackendConfiguration(config, nil) + cfg, err := NewBackendConfiguration(logger, config, nil) require.NoError(t, err) testUrls(t, cfg, valid_urls, invalid_urls) } func TestIsUrlAllowed_AllowAll(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + logger := logtest.NewLoggerForTest(t) valid_urls := []string{ "http://domain.invalid", "https://domain.invalid", @@ -199,7 +232,7 @@ func TestIsUrlAllowed_AllowAll(t *testing.T) { config.AddOption("backend", "allowall", "true") config.AddOption("backend", "allowed", "") config.AddOption("backend", "secret", string(testBackendSecret)) - cfg, err := NewBackendConfiguration(config, nil) + cfg, err := NewBackendConfiguration(logger, config, nil) require.NoError(t, err) testUrls(t, cfg, valid_urls, invalid_urls) } @@ -210,7 +243,7 @@ type ParseBackendIdsTestcase struct { } func TestParseBackendIds(t *testing.T) { - CatchLogForTest(t) + t.Parallel() testcases := []ParseBackendIdsTestcase{ {"", nil}, {"backend1", []string{"backend1"}}, @@ -229,9 +262,12 @@ func TestParseBackendIds(t *testing.T) { } func TestBackendReloadNoChange(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + stats := &mockBackendStats{} + + logger := logtest.NewLoggerForTest(t) require := require.New(t) - current := testutil.ToFloat64(statsBackendsCurrent) + assert := assert.New(t) original_config := goconf.NewConfigFile() original_config.AddOption("backend", "backends", "backend1, backend2") original_config.AddOption("backend", "allowall", "false") @@ -239,9 +275,9 @@ func TestBackendReloadNoChange(t *testing.T) { original_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend1") original_config.AddOption("backend2", "url", "http://domain2.invalid") original_config.AddOption("backend2", "secret", string(testBackendSecret)+"-backend2") - o_cfg, err := NewBackendConfiguration(original_config, nil) + o_cfg, err := NewBackendConfigurationWithStats(logger, original_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+2) + assert.Equal(2, stats.value) new_config := goconf.NewConfigFile() new_config.AddOption("backend", "backends", "backend1, backend2") @@ -250,21 +286,24 @@ func TestBackendReloadNoChange(t *testing.T) { new_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend1") new_config.AddOption("backend2", "url", "http://domain2.invalid") new_config.AddOption("backend2", "secret", string(testBackendSecret)+"-backend2") - n_cfg, err := NewBackendConfiguration(new_config, nil) + n_cfg, err := NewBackendConfigurationWithStats(logger, new_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+4) + assert.Equal(4, stats.value) o_cfg.Reload(original_config) - checkStatsValue(t, statsBackendsCurrent, current+4) + assert.Equal(4, stats.value) if !reflect.DeepEqual(n_cfg, o_cfg) { - assert.Fail(t, "BackendConfiguration should be equal after Reload") + assert.Fail("BackendConfiguration should be equal after Reload") } } func TestBackendReloadChangeExistingURL(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + stats := &mockBackendStats{} + + logger := logtest.NewLoggerForTest(t) require := require.New(t) - current := testutil.ToFloat64(statsBackendsCurrent) + assert := assert.New(t) original_config := goconf.NewConfigFile() original_config.AddOption("backend", "backends", "backend1, backend2") original_config.AddOption("backend", "allowall", "false") @@ -272,10 +311,10 @@ func TestBackendReloadChangeExistingURL(t *testing.T) { original_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend1") original_config.AddOption("backend2", "url", "http://domain2.invalid") original_config.AddOption("backend2", "secret", string(testBackendSecret)+"-backend2") - o_cfg, err := NewBackendConfiguration(original_config, nil) + o_cfg, err := NewBackendConfigurationWithStats(logger, original_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+2) + assert.Equal(2, stats.value) new_config := goconf.NewConfigFile() new_config.AddOption("backend", "backends", "backend1, backend2") new_config.AddOption("backend", "allowall", "false") @@ -284,25 +323,28 @@ func TestBackendReloadChangeExistingURL(t *testing.T) { new_config.AddOption("backend1", "sessionlimit", "10") new_config.AddOption("backend2", "url", "http://domain2.invalid") new_config.AddOption("backend2", "secret", string(testBackendSecret)+"-backend2") - n_cfg, err := NewBackendConfiguration(new_config, nil) + n_cfg, err := NewBackendConfigurationWithStats(logger, new_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+4) + assert.Equal(4, stats.value) original_config.RemoveOption("backend1", "url") original_config.AddOption("backend1", "url", "http://domain3.invalid") original_config.AddOption("backend1", "sessionlimit", "10") o_cfg.Reload(original_config) - checkStatsValue(t, statsBackendsCurrent, current+4) + assert.Equal(4, stats.value) if !reflect.DeepEqual(n_cfg, o_cfg) { - assert.Fail(t, "BackendConfiguration should be equal after Reload") + assert.Fail("BackendConfiguration should be equal after Reload") } } func TestBackendReloadChangeSecret(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + stats := &mockBackendStats{} + + logger := logtest.NewLoggerForTest(t) require := require.New(t) - current := testutil.ToFloat64(statsBackendsCurrent) + assert := assert.New(t) original_config := goconf.NewConfigFile() original_config.AddOption("backend", "backends", "backend1, backend2") original_config.AddOption("backend", "allowall", "false") @@ -310,10 +352,10 @@ func TestBackendReloadChangeSecret(t *testing.T) { original_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend1") original_config.AddOption("backend2", "url", "http://domain2.invalid") original_config.AddOption("backend2", "secret", string(testBackendSecret)+"-backend2") - o_cfg, err := NewBackendConfiguration(original_config, nil) + o_cfg, err := NewBackendConfigurationWithStats(logger, original_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+2) + assert.Equal(2, stats.value) new_config := goconf.NewConfigFile() new_config.AddOption("backend", "backends", "backend1, backend2") new_config.AddOption("backend", "allowall", "false") @@ -321,33 +363,34 @@ func TestBackendReloadChangeSecret(t *testing.T) { new_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend3") new_config.AddOption("backend2", "url", "http://domain2.invalid") new_config.AddOption("backend2", "secret", string(testBackendSecret)+"-backend2") - n_cfg, err := NewBackendConfiguration(new_config, nil) + n_cfg, err := NewBackendConfigurationWithStats(logger, new_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+4) + assert.Equal(4, stats.value) original_config.RemoveOption("backend1", "secret") original_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend3") o_cfg.Reload(original_config) - checkStatsValue(t, statsBackendsCurrent, current+4) - if !reflect.DeepEqual(n_cfg, o_cfg) { - assert.Fail(t, "BackendConfiguration should be equal after Reload") - } + assert.Equal(4, stats.value) + assert.Equal(n_cfg, o_cfg, "BackendConfiguration should be equal after Reload") } func TestBackendReloadAddBackend(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + stats := &mockBackendStats{} + + logger := logtest.NewLoggerForTest(t) require := require.New(t) - current := testutil.ToFloat64(statsBackendsCurrent) + assert := assert.New(t) original_config := goconf.NewConfigFile() original_config.AddOption("backend", "backends", "backend1") original_config.AddOption("backend", "allowall", "false") original_config.AddOption("backend1", "url", "http://domain1.invalid") original_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend1") - o_cfg, err := NewBackendConfiguration(original_config, nil) + o_cfg, err := NewBackendConfigurationWithStats(logger, original_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+1) + assert.Equal(1, stats.value) new_config := goconf.NewConfigFile() new_config.AddOption("backend", "backends", "backend1, backend2") new_config.AddOption("backend", "allowall", "false") @@ -356,10 +399,10 @@ func TestBackendReloadAddBackend(t *testing.T) { new_config.AddOption("backend2", "url", "http://domain2.invalid") new_config.AddOption("backend2", "secret", string(testBackendSecret)+"-backend2") new_config.AddOption("backend2", "sessionlimit", "10") - n_cfg, err := NewBackendConfiguration(new_config, nil) + n_cfg, err := NewBackendConfigurationWithStats(logger, new_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+3) + assert.Equal(3, stats.value) original_config.RemoveOption("backend", "backends") original_config.AddOption("backend", "backends", "backend1, backend2") original_config.AddOption("backend2", "url", "http://domain2.invalid") @@ -367,16 +410,19 @@ func TestBackendReloadAddBackend(t *testing.T) { original_config.AddOption("backend2", "sessionlimit", "10") o_cfg.Reload(original_config) - checkStatsValue(t, statsBackendsCurrent, current+4) + assert.Equal(4, stats.value) if !reflect.DeepEqual(n_cfg, o_cfg) { - assert.Fail(t, "BackendConfiguration should be equal after Reload") + assert.Fail("BackendConfiguration should be equal after Reload") } } func TestBackendReloadRemoveHost(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + stats := &mockBackendStats{} + + logger := logtest.NewLoggerForTest(t) require := require.New(t) - current := testutil.ToFloat64(statsBackendsCurrent) + assert := assert.New(t) original_config := goconf.NewConfigFile() original_config.AddOption("backend", "backends", "backend1, backend2") original_config.AddOption("backend", "allowall", "false") @@ -384,34 +430,37 @@ func TestBackendReloadRemoveHost(t *testing.T) { original_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend1") original_config.AddOption("backend2", "url", "http://domain2.invalid") original_config.AddOption("backend2", "secret", string(testBackendSecret)+"-backend2") - o_cfg, err := NewBackendConfiguration(original_config, nil) + o_cfg, err := NewBackendConfigurationWithStats(logger, original_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+2) + assert.Equal(2, stats.value) new_config := goconf.NewConfigFile() new_config.AddOption("backend", "backends", "backend1") new_config.AddOption("backend", "allowall", "false") new_config.AddOption("backend1", "url", "http://domain1.invalid") new_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend1") - n_cfg, err := NewBackendConfiguration(new_config, nil) + n_cfg, err := NewBackendConfigurationWithStats(logger, new_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+3) + assert.Equal(3, stats.value) original_config.RemoveOption("backend", "backends") original_config.AddOption("backend", "backends", "backend1") original_config.RemoveSection("backend2") o_cfg.Reload(original_config) - checkStatsValue(t, statsBackendsCurrent, current+2) + assert.Equal(2, stats.value) if !reflect.DeepEqual(n_cfg, o_cfg) { - assert.Fail(t, "BackendConfiguration should be equal after Reload") + assert.Fail("BackendConfiguration should be equal after Reload") } } func TestBackendReloadRemoveBackendFromSharedHost(t *testing.T) { - CatchLogForTest(t) + t.Parallel() + stats := &mockBackendStats{} + + logger := logtest.NewLoggerForTest(t) require := require.New(t) - current := testutil.ToFloat64(statsBackendsCurrent) + assert := assert.New(t) original_config := goconf.NewConfigFile() original_config.AddOption("backend", "backends", "backend1, backend2") original_config.AddOption("backend", "allowall", "false") @@ -419,36 +468,34 @@ func TestBackendReloadRemoveBackendFromSharedHost(t *testing.T) { original_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend1") original_config.AddOption("backend2", "url", "http://domain1.invalid/bar/") original_config.AddOption("backend2", "secret", string(testBackendSecret)+"-backend2") - o_cfg, err := NewBackendConfiguration(original_config, nil) + o_cfg, err := NewBackendConfigurationWithStats(logger, original_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+2) + assert.Equal(2, stats.value) new_config := goconf.NewConfigFile() new_config.AddOption("backend", "backends", "backend1") new_config.AddOption("backend", "allowall", "false") new_config.AddOption("backend1", "url", "http://domain1.invalid/foo/") new_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend1") - n_cfg, err := NewBackendConfiguration(new_config, nil) + n_cfg, err := NewBackendConfigurationWithStats(logger, new_config, nil, stats) require.NoError(err) - checkStatsValue(t, statsBackendsCurrent, current+3) + assert.Equal(3, stats.value) original_config.RemoveOption("backend", "backends") original_config.AddOption("backend", "backends", "backend1") original_config.RemoveSection("backend2") o_cfg.Reload(original_config) - checkStatsValue(t, statsBackendsCurrent, current+2) + assert.Equal(2, stats.value) if !reflect.DeepEqual(n_cfg, o_cfg) { - assert.Fail(t, "BackendConfiguration should be equal after Reload") + assert.Fail("BackendConfiguration should be equal after Reload") } } func sortBackends(backends []*Backend) []*Backend { - result := make([]*Backend, len(backends)) - copy(result, backends) - - sort.Slice(result, func(i, j int) bool { - return result[i].Id() < result[j].Id() + result := slices.Clone(backends) + slices.SortFunc(result, func(a, b *Backend) int { + return strings.Compare(a.Id(), b.Id()) }) return result } @@ -461,24 +508,26 @@ func mustParse(s string) *url.URL { return p } -func TestBackendConfiguration_Etcd(t *testing.T) { +func TestBackendConfiguration_EtcdCompat(t *testing.T) { t.Parallel() - CatchLogForTest(t) + stats := &mockBackendStats{} + + logger := logtest.NewLoggerForTest(t) require := require.New(t) assert := assert.New(t) - etcd, client := NewEtcdClientForTest(t) + embedEtcd, client := etcdtest.NewClientForTest(t) url1 := "https://domain1.invalid/foo" initialSecret1 := string(testBackendSecret) + "-backend1-initial" secret1 := string(testBackendSecret) + "-backend1" - SetEtcdValue(etcd, "/backends/1_one", []byte("{\"url\":\""+url1+"\",\"secret\":\""+initialSecret1+"\"}")) + embedEtcd.SetValue("/backends/1_one", []byte("{\"url\":\""+url1+"\",\"secret\":\""+initialSecret1+"\"}")) config := goconf.NewConfigFile() config.AddOption("backend", "backendtype", "etcd") config.AddOption("backend", "backendprefix", "/backends") - cfg, err := NewBackendConfiguration(config, client) + cfg, err := NewBackendConfigurationWithStats(logger, config, client, stats) require.NoError(err) defer cfg.Close() @@ -491,90 +540,96 @@ func TestBackendConfiguration_Etcd(t *testing.T) { require.NoError(storage.WaitForInitialized(ctx)) if backends := sortBackends(cfg.GetBackends()); assert.Len(backends, 1) && - assert.Equal(url1, backends[0].url) && - assert.Equal(initialSecret1, string(backends[0].secret)) { - if backend := cfg.GetBackend(mustParse(url1)); backend != backends[0] { - assert.Fail("Expected backend %+v, got %+v", backends[0], backend) + assert.Equal([]string{url1}, backends[0].Urls()) && + assert.Equal(initialSecret1, string(backends[0].Secret())) { + if backend := cfg.GetBackend(mustParse(url1)); assert.NotNil(backend) { + assert.Equal(backends[0], backend) } } - drainWakeupChannel(ch) - SetEtcdValue(etcd, "/backends/1_one", []byte("{\"url\":\""+url1+"\",\"secret\":\""+secret1+"\"}")) + test.DrainWakeupChannel(ch) + embedEtcd.SetValue("/backends/1_one", []byte("{\"url\":\""+url1+"\",\"secret\":\""+secret1+"\"}")) <-ch + assert.Equal(1, stats.value) if backends := sortBackends(cfg.GetBackends()); assert.Len(backends, 1) && - assert.Equal(url1, backends[0].url) && - assert.Equal(secret1, string(backends[0].secret)) { - if backend := cfg.GetBackend(mustParse(url1)); backend != backends[0] { - assert.Fail("Expected backend %+v, got %+v", backends[0], backend) + assert.Equal([]string{url1}, backends[0].Urls()) && + assert.Equal(secret1, string(backends[0].Secret())) { + if backend := cfg.GetBackend(mustParse(url1)); assert.NotNil(backend) { + assert.Equal(backends[0], backend) } } url2 := "https://domain1.invalid/bar" secret2 := string(testBackendSecret) + "-backend2" - drainWakeupChannel(ch) - SetEtcdValue(etcd, "/backends/2_two", []byte("{\"url\":\""+url2+"\",\"secret\":\""+secret2+"\"}")) + test.DrainWakeupChannel(ch) + embedEtcd.SetValue("/backends/2_two", []byte("{\"url\":\""+url2+"\",\"secret\":\""+secret2+"\"}")) <-ch + assert.Equal(2, stats.value) if backends := sortBackends(cfg.GetBackends()); assert.Len(backends, 2) && - assert.Equal(url1, backends[0].url) && - assert.Equal(secret1, string(backends[0].secret)) && - assert.Equal(url2, backends[1].url) && - assert.Equal(secret2, string(backends[1].secret)) { - if backend := cfg.GetBackend(mustParse(url1)); backend != backends[0] { - assert.Fail("Expected backend %+v, got %+v", backends[0], backend) - } else if backend := cfg.GetBackend(mustParse(url2)); backend != backends[1] { - assert.Fail("Expected backend %+v, got %+v", backends[1], backend) + assert.Equal([]string{url1}, backends[0].Urls()) && + assert.Equal(secret1, string(backends[0].Secret())) && + assert.Equal([]string{url2}, backends[1].Urls()) && + assert.Equal(secret2, string(backends[1].Secret())) { + if backend := cfg.GetBackend(mustParse(url1)); assert.NotNil(backend) { + assert.Equal(backends[0], backend) + } else if backend := cfg.GetBackend(mustParse(url2)); assert.NotNil(backend) { + assert.Equal(backends[1], backend) } } url3 := "https://domain2.invalid/foo" secret3 := string(testBackendSecret) + "-backend3" - drainWakeupChannel(ch) - SetEtcdValue(etcd, "/backends/3_three", []byte("{\"url\":\""+url3+"\",\"secret\":\""+secret3+"\"}")) + test.DrainWakeupChannel(ch) + embedEtcd.SetValue("/backends/3_three", []byte("{\"url\":\""+url3+"\",\"secret\":\""+secret3+"\"}")) <-ch + assert.Equal(3, stats.value) if backends := sortBackends(cfg.GetBackends()); assert.Len(backends, 3) && - assert.Equal(url1, backends[0].url) && - assert.Equal(secret1, string(backends[0].secret)) && - assert.Equal(url2, backends[1].url) && - assert.Equal(secret2, string(backends[1].secret)) && - assert.Equal(url3, backends[2].url) && - assert.Equal(secret3, string(backends[2].secret)) { - if backend := cfg.GetBackend(mustParse(url1)); backend != backends[0] { - assert.Fail("Expected backend %+v, got %+v", backends[0], backend) - } else if backend := cfg.GetBackend(mustParse(url2)); backend != backends[1] { - assert.Fail("Expected backend %+v, got %+v", backends[1], backend) - } else if backend := cfg.GetBackend(mustParse(url3)); backend != backends[2] { - assert.Fail("Expected backend %+v, got %+v", backends[2], backend) + assert.Equal([]string{url1}, backends[0].Urls()) && + assert.Equal(secret1, string(backends[0].Secret())) && + assert.Equal([]string{url2}, backends[1].Urls()) && + assert.Equal(secret2, string(backends[1].Secret())) && + assert.Equal([]string{url3}, backends[2].Urls()) && + assert.Equal(secret3, string(backends[2].Secret())) { + if backend := cfg.GetBackend(mustParse(url1)); assert.NotNil(backend) { + assert.Equal(backends[0], backend) + } else if backend := cfg.GetBackend(mustParse(url2)); assert.NotNil(backend) { + assert.Equal(backends[1], backend) + } else if backend := cfg.GetBackend(mustParse(url3)); assert.NotNil(backend) { + assert.Equal(backends[2], backend) } } - drainWakeupChannel(ch) - DeleteEtcdValue(etcd, "/backends/1_one") + test.DrainWakeupChannel(ch) + embedEtcd.DeleteValue("/backends/1_one") <-ch + assert.Equal(2, stats.value) if backends := sortBackends(cfg.GetBackends()); assert.Len(backends, 2) { - assert.Equal(url2, backends[0].url) - assert.Equal(secret2, string(backends[0].secret)) - assert.Equal(url3, backends[1].url) - assert.Equal(secret3, string(backends[1].secret)) + assert.Equal([]string{url2}, backends[0].Urls()) + assert.Equal(secret2, string(backends[0].Secret())) + assert.Equal([]string{url3}, backends[1].Urls()) + assert.Equal(secret3, string(backends[1].Secret())) } - drainWakeupChannel(ch) - DeleteEtcdValue(etcd, "/backends/2_two") + test.DrainWakeupChannel(ch) + embedEtcd.DeleteValue("/backends/2_two") <-ch + assert.Equal(1, stats.value) if backends := sortBackends(cfg.GetBackends()); assert.Len(backends, 1) { - assert.Equal(url3, backends[0].url) - assert.Equal(secret3, string(backends[0].secret)) + assert.Equal([]string{url3}, backends[0].Urls()) + assert.Equal(secret3, string(backends[0].Secret())) } - if _, found := storage.backends["domain1.invalid"]; found { - assert.Fail("Should have removed host information for %s", "domain1.invalid") - } + storage.mu.RLock() + _, found := storage.backends["domain1.invalid"] + storage.mu.RUnlock() + assert.False(found, "Should have removed host information") } func TestBackendCommonSecret(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) require := require.New(t) assert := assert.New(t) u1, err := url.Parse("http://domain1.invalid") @@ -587,7 +642,7 @@ func TestBackendCommonSecret(t *testing.T) { original_config.AddOption("backend1", "url", u1.String()) original_config.AddOption("backend2", "url", u2.String()) original_config.AddOption("backend2", "secret", string(testBackendSecret)+"-backend2") - cfg, err := NewBackendConfiguration(original_config, nil) + cfg, err := NewBackendConfiguration(logger, original_config, nil) require.NoError(err) if b1 := cfg.GetBackend(u1); assert.NotNil(b1) { @@ -612,3 +667,195 @@ func TestBackendCommonSecret(t *testing.T) { assert.Equal(string(testBackendSecret), string(b2.Secret())) } } + +func TestBackendChangeUrls(t *testing.T) { + t.Parallel() + stats := &mockBackendStats{} + + logger := logtest.NewLoggerForTest(t) + require := require.New(t) + assert := assert.New(t) + u1, err := url.Parse("http://domain1.invalid/") + require.NoError(err) + u2, err := url.Parse("http://domain2.invalid/") + require.NoError(err) + original_config := goconf.NewConfigFile() + original_config.AddOption("backend", "backends", "backend1,backend2") + original_config.AddOption("backend", "secret", string(testBackendSecret)) + original_config.AddOption("backend1", "urls", u1.String()) + original_config.AddOption("backend2", "urls", u2.String()) + + cfg, err := NewBackendConfigurationWithStats(logger, original_config, nil, stats) + require.NoError(err) + + assert.Equal(2, stats.value) + if b1 := cfg.GetBackend(u1); assert.NotNil(b1) { + assert.Equal("backend1", b1.Id()) + assert.Equal(string(testBackendSecret), string(b1.Secret())) + assert.Equal([]string{u1.String()}, b1.Urls()) + } + if b2 := cfg.GetBackend(u2); assert.NotNil(b2) { + assert.Equal("backend2", b2.Id()) + assert.Equal(string(testBackendSecret), string(b2.Secret())) + assert.Equal([]string{u2.String()}, b2.Urls()) + } + + // Add url. + updated_config := goconf.NewConfigFile() + updated_config.AddOption("backend", "backends", "backend1") + updated_config.AddOption("backend", "secret", string(testBackendSecret)) + updated_config.AddOption("backend1", "secret", string(testBackendSecret)+"-backend1") + updated_config.AddOption("backend1", "urls", strings.Join([]string{u1.String(), u2.String()}, ",")) + cfg.Reload(updated_config) + + assert.Equal(1, stats.value) + if b1 := cfg.GetBackend(u1); assert.NotNil(b1) { + assert.Equal("backend1", b1.Id()) + assert.Equal(string(testBackendSecret)+"-backend1", string(b1.Secret())) + assert.Equal([]string{u1.String(), u2.String()}, b1.Urls()) + } + if b1 := cfg.GetBackend(u2); assert.NotNil(b1) { + assert.Equal("backend1", b1.Id()) + assert.Equal(string(testBackendSecret)+"-backend1", string(b1.Secret())) + assert.Equal([]string{u1.String(), u2.String()}, b1.Urls()) + } + + // No change reload. + cfg.Reload(updated_config) + assert.Equal(1, stats.value) + if b1 := cfg.GetBackend(u1); assert.NotNil(b1) { + assert.Equal("backend1", b1.Id()) + assert.Equal(string(testBackendSecret)+"-backend1", string(b1.Secret())) + assert.Equal([]string{u1.String(), u2.String()}, b1.Urls()) + } + if b1 := cfg.GetBackend(u2); assert.NotNil(b1) { + assert.Equal("backend1", b1.Id()) + assert.Equal(string(testBackendSecret)+"-backend1", string(b1.Secret())) + assert.Equal([]string{u1.String(), u2.String()}, b1.Urls()) + } + + // Remove url. + updated_config = goconf.NewConfigFile() + updated_config.AddOption("backend", "backends", "backend1") + updated_config.AddOption("backend", "secret", string(testBackendSecret)) + updated_config.AddOption("backend1", "urls", u2.String()) + cfg.Reload(updated_config) + + assert.Equal(1, stats.value) + if b1 := cfg.GetBackend(u2); assert.NotNil(b1) { + assert.Equal("backend1", b1.Id()) + assert.Equal(string(testBackendSecret), string(b1.Secret())) + assert.Equal([]string{u2.String()}, b1.Urls()) + } + + updated_config = goconf.NewConfigFile() + updated_config.AddOption("backend", "backends", "") + updated_config.AddOption("backend", "secret", string(testBackendSecret)) + cfg.Reload(updated_config) + + assert.Equal(0, stats.value) + b1 := cfg.GetBackend(u2) + assert.Nil(b1) +} + +func TestBackendConfiguration_EtcdChangeUrls(t *testing.T) { + t.Parallel() + stats := &mockBackendStats{} + + logger := logtest.NewLoggerForTest(t) + require := require.New(t) + assert := assert.New(t) + embedEtcd, client := etcdtest.NewClientForTest(t) + + url1 := "https://domain1.invalid/foo" + initialSecret1 := string(testBackendSecret) + "-backend1-initial" + secret1 := string(testBackendSecret) + "-backend1" + + embedEtcd.SetValue("/backends/1_one", []byte("{\"urls\":[\""+url1+"\"],\"secret\":\""+initialSecret1+"\"}")) + + config := goconf.NewConfigFile() + config.AddOption("backend", "backendtype", "etcd") + config.AddOption("backend", "backendprefix", "/backends") + + cfg, err := NewBackendConfigurationWithStats(logger, config, client, stats) + require.NoError(err) + defer cfg.Close() + + storage := cfg.storage.(*backendStorageEtcd) + ch := storage.getWakeupChannelForTesting() + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + require.NoError(storage.WaitForInitialized(ctx)) + + assert.Equal(1, stats.value) + if backends := sortBackends(cfg.GetBackends()); assert.Len(backends, 1) && + assert.Equal([]string{url1}, backends[0].Urls()) && + assert.Equal(initialSecret1, string(backends[0].Secret())) { + if backend := cfg.GetBackend(mustParse(url1)); assert.NotNil(backend) { + assert.Equal(backends[0], backend) + } + } + + url2 := "https://domain1.invalid/bar" + + test.DrainWakeupChannel(ch) + embedEtcd.SetValue("/backends/1_one", []byte("{\"urls\":[\""+url1+"\",\""+url2+"\"],\"secret\":\""+secret1+"\"}")) + <-ch + assert.Equal(1, stats.value) + if backends := sortBackends(cfg.GetBackends()); assert.Len(backends, 1) && + assert.Equal([]string{url2, url1}, backends[0].Urls()) && + assert.Equal(secret1, string(backends[0].Secret())) { + if backend := cfg.GetBackend(mustParse(url1)); assert.NotNil(backend) { + assert.Equal(backends[0], backend) + } + if backend := cfg.GetBackend(mustParse(url2)); assert.NotNil(backend) { + assert.Equal(backends[0], backend) + } + } + + url3 := "https://domain2.invalid/foo" + secret3 := string(testBackendSecret) + "-backend3" + + url4 := "https://domain3.invalid/foo" + + test.DrainWakeupChannel(ch) + embedEtcd.SetValue("/backends/3_three", []byte("{\"urls\":[\""+url3+"\",\""+url4+"\"],\"secret\":\""+secret3+"\"}")) + <-ch + assert.Equal(2, stats.value) + if backends := sortBackends(cfg.GetBackends()); assert.Len(backends, 2) && + assert.Equal([]string{url2, url1}, backends[0].Urls()) && + assert.Equal(secret1, string(backends[0].Secret())) && + assert.Equal([]string{url3, url4}, backends[1].Urls()) && + assert.Equal(secret3, string(backends[1].Secret())) { + if backend := cfg.GetBackend(mustParse(url1)); assert.NotNil(backend) { + assert.Equal(backends[0], backend) + } else if backend := cfg.GetBackend(mustParse(url2)); assert.NotNil(backend) { + assert.Equal(backends[0], backend) + } else if backend := cfg.GetBackend(mustParse(url3)); assert.NotNil(backend) { + assert.Equal(backends[1], backend) + } else if backend := cfg.GetBackend(mustParse(url4)); assert.NotNil(backend) { + assert.Equal(backends[1], backend) + } + } + + test.DrainWakeupChannel(ch) + embedEtcd.DeleteValue("/backends/1_one") + <-ch + assert.Equal(1, stats.value) + if backends := sortBackends(cfg.GetBackends()); assert.Len(backends, 1) { + assert.Equal([]string{url3, url4}, backends[0].Urls()) + assert.Equal(secret3, string(backends[0].Secret())) + } + + test.DrainWakeupChannel(ch) + embedEtcd.DeleteValue("/backends/3_three") + <-ch + + assert.Equal(0, stats.value) + storage.mu.RLock() + _, found := storage.backends["domain1.invalid"] + storage.mu.RUnlock() + assert.False(found, "Should have removed host information") +} diff --git a/backend_configuration_stats_prometheus.go b/talk/backend_stats_prometheus.go similarity index 67% rename from backend_configuration_stats_prometheus.go rename to talk/backend_stats_prometheus.go index d19d7f9..f335df4 100644 --- a/backend_configuration_stats_prometheus.go +++ b/talk/backend_stats_prometheus.go @@ -19,10 +19,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( "github.com/prometheus/client_golang/prometheus" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/metrics" ) var ( @@ -32,38 +34,19 @@ var ( Name: "session_limit", Help: "The session limit of a backend", }, []string{"backend"}) - statsBackendLimitExceededTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + statsBackendLimitExceededTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ // +checklocksignore: Global readonly variable. Namespace: "signaling", Subsystem: "backend", Name: "session_limit_exceeded_total", Help: "The number of times the session limit exceeded", }, []string{"backend"}) - statsBackendsCurrent = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "signaling", - Subsystem: "backend", - Name: "current", - Help: "The current number of configured backends", - }) - backendConfigurationStats = []prometheus.Collector{ + backendStats = []prometheus.Collector{ statsBackendLimit, statsBackendLimitExceededTotal, - statsBackendsCurrent, } ) -func RegisterBackendConfigurationStats() { - registerAll(backendConfigurationStats...) -} - -func updateBackendStats(backend *Backend) { - if backend.sessionLimit > 0 { - statsBackendLimit.WithLabelValues(backend.id).Set(float64(backend.sessionLimit)) - } else { - statsBackendLimit.DeleteLabelValues(backend.id) - } -} - -func deleteBackendStats(backend *Backend) { - statsBackendLimit.DeleteLabelValues(backend.id) +func registerBackendStats() { + metrics.RegisterAll(backendStats...) } diff --git a/backend_storage_etcd.go b/talk/backend_storage_etcd.go similarity index 52% rename from backend_storage_etcd.go rename to talk/backend_storage_etcd.go index ce82bef..caf30af 100644 --- a/backend_storage_etcd.go +++ b/talk/backend_storage_etcd.go @@ -19,44 +19,57 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( "context" "encoding/json" "errors" - "fmt" - "log" "net/url" + "slices" + "sync" + "sync/atomic" "time" "github.com/dlintw/goconf" clientv3 "go.etcd.io/etcd/client/v3" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/async" + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" +) + +const ( + initialWaitDelay = time.Second + maxWaitDelay = 8 * time.Second ) type backendStorageEtcd struct { backendStorageCommon - etcdClient *EtcdClient + logger log.Logger + etcdClient etcd.Client keyPrefix string - keyInfos map[string]*BackendInformationEtcd + keyInfos map[string]*etcd.BackendInformationEtcd + initializing atomic.Bool initializedCtx context.Context initializedFunc context.CancelFunc wakeupChanForTesting chan struct{} + runningDone sync.WaitGroup closeCtx context.Context closeFunc context.CancelFunc } -func NewBackendStorageEtcd(config *goconf.ConfigFile, etcdClient *EtcdClient) (BackendStorage, error) { +func NewBackendStorageEtcd(logger log.Logger, config *goconf.ConfigFile, etcdClient etcd.Client, stats BackendStorageStats) (BackendStorage, error) { if etcdClient == nil || !etcdClient.IsConfigured() { - return nil, fmt.Errorf("no etcd endpoints configured") + return nil, errors.New("no etcd endpoints configured") } keyPrefix, _ := config.GetString("backend", "backendprefix") if keyPrefix == "" { - return nil, fmt.Errorf("no backend prefix configured") + return nil, errors.New("no backend prefix configured") } initializedCtx, initializedFunc := context.WithCancel(context.Background()) @@ -64,10 +77,12 @@ func NewBackendStorageEtcd(config *goconf.ConfigFile, etcdClient *EtcdClient) (B result := &backendStorageEtcd{ backendStorageCommon: backendStorageCommon{ backends: make(map[string][]*Backend), + stats: stats, }, + logger: logger, etcdClient: etcdClient, keyPrefix: keyPrefix, - keyInfos: make(map[string]*BackendInformationEtcd), + keyInfos: make(map[string]*etcd.BackendInformationEtcd), initializedCtx: initializedCtx, initializedFunc: initializedFunc, @@ -99,8 +114,16 @@ func (s *backendStorageEtcd) wakeupForTesting() { } } -func (s *backendStorageEtcd) EtcdClientCreated(client *EtcdClient) { - go func() { +func (s *backendStorageEtcd) EtcdClientCreated(client etcd.Client) { + s.initializing.Store(true) + if s.closeCtx.Err() != nil { + // Stopped before etcd client was connected. + s.initializedFunc() + return + } + + s.runningDone.Go(func() { + defer s.initializedFunc() if err := client.WaitForConnection(s.closeCtx); err != nil { if errors.Is(err, context.Canceled) { return @@ -109,7 +132,7 @@ func (s *backendStorageEtcd) EtcdClientCreated(client *EtcdClient) { panic(err) } - backoff, err := NewExponentialBackoff(initialWaitDelay, maxWaitDelay) + backoff, err := async.NewExponentialBackoff(initialWaitDelay, maxWaitDelay) if err != nil { panic(err) } @@ -119,9 +142,9 @@ func (s *backendStorageEtcd) EtcdClientCreated(client *EtcdClient) { if errors.Is(err, context.Canceled) { return } else if errors.Is(err, context.DeadlineExceeded) { - log.Printf("Timeout getting initial list of backends, retry in %s", backoff.NextWait()) + s.logger.Printf("Timeout getting initial list of backends, retry in %s", backoff.NextWait()) } else { - log.Printf("Could not get initial list of backends, retry in %s: %s", backoff.NextWait(), err) + s.logger.Printf("Could not get initial list of backends, retry in %s: %s", backoff.NextWait(), err) } backoff.Wait(s.closeCtx) @@ -139,7 +162,7 @@ func (s *backendStorageEtcd) EtcdClientCreated(client *EtcdClient) { for s.closeCtx.Err() == nil { var err error if nextRevision, err = client.Watch(s.closeCtx, s.keyPrefix, nextRevision, s, clientv3.WithPrefix()); err != nil { - log.Printf("Error processing watch for %s (%s), retry in %s", s.keyPrefix, err, backoff.NextWait()) + s.logger.Printf("Error processing watch for %s (%s), retry in %s", s.keyPrefix, err, backoff.NextWait()) backoff.Wait(s.closeCtx) continue } @@ -148,89 +171,80 @@ func (s *backendStorageEtcd) EtcdClientCreated(client *EtcdClient) { backoff.Reset() prevRevision = nextRevision } else { - log.Printf("Processing watch for %s interrupted, retry in %s", s.keyPrefix, backoff.NextWait()) + s.logger.Printf("Processing watch for %s interrupted, retry in %s", s.keyPrefix, backoff.NextWait()) backoff.Wait(s.closeCtx) } } return } - }() + }) } -func (s *backendStorageEtcd) EtcdWatchCreated(client *EtcdClient, key string) { +func (s *backendStorageEtcd) EtcdWatchCreated(client etcd.Client, key string) { } -func (s *backendStorageEtcd) getBackends(ctx context.Context, client *EtcdClient, keyPrefix string) (*clientv3.GetResponse, error) { +func (s *backendStorageEtcd) getBackends(ctx context.Context, client etcd.Client, keyPrefix string) (*clientv3.GetResponse, error) { ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() return client.Get(ctx, keyPrefix, clientv3.WithPrefix()) } -func (s *backendStorageEtcd) EtcdKeyUpdated(client *EtcdClient, key string, data []byte, prevValue []byte) { - var info BackendInformationEtcd +func (s *backendStorageEtcd) EtcdKeyUpdated(client etcd.Client, key string, data []byte, prevValue []byte) { + var info etcd.BackendInformationEtcd if err := json.Unmarshal(data, &info); err != nil { - log.Printf("Could not decode backend information %s: %s", string(data), err) + s.logger.Printf("Could not decode backend information %s: %s", string(data), err) return } if err := info.CheckValid(); err != nil { - log.Printf("Received invalid backend information %s: %s", string(data), err) + s.logger.Printf("Received invalid backend information %s: %s", string(data), err) return } - backend := &Backend{ - id: key, - url: info.Url, - parsedUrl: info.parsedUrl, - secret: []byte(info.Secret), - - allowHttp: info.parsedUrl.Scheme == "http", - - maxStreamBitrate: info.MaxStreamBitrate, - maxScreenBitrate: info.MaxScreenBitrate, - sessionLimit: info.SessionLimit, - } - - host := info.parsedUrl.Host + backend := NewBackendFromEtcd(key, &info) s.mu.Lock() defer s.mu.Unlock() s.keyInfos[key] = &info - entries, found := s.backends[host] - if !found { - // Simple case, first backend for this host - log.Printf("Added backend %s (from %s)", info.Url, key) - s.backends[host] = []*Backend{backend} - updateBackendStats(backend) - statsBackendsCurrent.Inc() - s.wakeupForTesting() - return - } + added := false + for idx, u := range info.ParsedUrls { + host := u.Host + entries, found := s.backends[host] + if !found { + // Simple case, first backend for this host + s.logger.Printf("Added backend %s (from %s)", info.Urls[idx], key) + s.backends[host] = []*Backend{backend} + added = true + continue + } - // Was the backend changed? - replaced := false - for idx, entry := range entries { - if entry.id == key { - log.Printf("Updated backend %s (from %s)", info.Url, key) - updateBackendStats(backend) - entries[idx] = backend - replaced = true - break + // Was the backend changed? + replaced := false + for idx, entry := range entries { + if entry.Id() == key { + s.logger.Printf("Updated backend %s (from %s)", info.Urls[idx], key) + entries[idx] = backend + replaced = true + break + } + } + + if !replaced { + // New backend, add to list. + s.logger.Printf("Added backend %s (from %s)", info.Urls[idx], key) + s.backends[host] = append(entries, backend) + added = true } } - - if !replaced { - // New backend, add to list. - log.Printf("Added backend %s (from %s)", info.Url, key) - s.backends[host] = append(entries, backend) - updateBackendStats(backend) - statsBackendsCurrent.Inc() + backend.UpdateStats() + if added { + s.stats.IncBackends() } s.wakeupForTesting() } -func (s *backendStorageEtcd) EtcdKeyDeleted(client *EtcdClient, key string, prevValue []byte) { +func (s *backendStorageEtcd) EtcdKeyDeleted(client etcd.Client, key string, prevValue []byte) { s.mu.Lock() defer s.mu.Unlock() @@ -240,34 +254,61 @@ func (s *backendStorageEtcd) EtcdKeyDeleted(client *EtcdClient, key string, prev } delete(s.keyInfos, key) - host := info.parsedUrl.Host - entries, found := s.backends[host] - if !found { - return - } - - log.Printf("Removing backend %s (from %s)", info.Url, key) - newEntries := make([]*Backend, 0, len(entries)-1) - for _, entry := range entries { - if entry.id == key { - updateBackendStats(entry) - statsBackendsCurrent.Dec() + var deleted map[string][]*Backend + seen := make(map[string]bool) + for idx, u := range info.ParsedUrls { + host := u.Host + entries, found := s.backends[host] + if !found { + if d, ok := deleted[host]; ok { + if slices.ContainsFunc(d, func(b *Backend) bool { + return slices.Contains(b.Urls(), u.String()) + }) { + s.logger.Printf("Removing backend %s (from %s)", info.Urls[idx], key) + } + } continue } - newEntries = append(newEntries, entry) - } - if len(newEntries) > 0 { - s.backends[host] = newEntries - } else { - delete(s.backends, host) + s.logger.Printf("Removing backend %s (from %s)", info.Urls[idx], key) + newEntries := make([]*Backend, 0, len(entries)-1) + for _, entry := range entries { + if entry.Id() == key { + if len(info.ParsedUrls) > 1 { + if deleted == nil { + deleted = make(map[string][]*Backend) + } + deleted[host] = append(deleted[host], entry) + } + if !seen[entry.Id()] { + seen[entry.Id()] = true + entry.UpdateStats() + s.stats.DecBackends() + } + continue + } + + newEntries = append(newEntries, entry) + } + if len(newEntries) > 0 { + s.backends[host] = newEntries + } else { + delete(s.backends, host) + } } s.wakeupForTesting() } func (s *backendStorageEtcd) Close() { - s.etcdClient.RemoveListener(s) + firstStop := s.closeCtx.Err() == nil s.closeFunc() + s.etcdClient.RemoveListener(s) + if firstStop { + if s.initializing.Load() { + <-s.initializedCtx.Done() + } + s.runningDone.Wait() + } } func (s *backendStorageEtcd) Reload(config *goconf.ConfigFile) { diff --git a/backend_storage_etcd_test.go b/talk/backend_storage_etcd_test.go similarity index 70% rename from backend_storage_etcd_test.go rename to talk/backend_storage_etcd_test.go index d9bd77c..b00feb0 100644 --- a/backend_storage_etcd_test.go +++ b/talk/backend_storage_etcd_test.go @@ -19,14 +19,18 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( "testing" "github.com/dlintw/goconf" "github.com/stretchr/testify/require" - "go.etcd.io/etcd/server/v3/embed" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd" + etcdtest "github.com/strukturag/nextcloud-spreed-signaling/v2/etcd/test" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/test" ) func (s *backendStorageEtcd) getWakeupChannelForTesting() <-chan struct{} { @@ -43,21 +47,20 @@ func (s *backendStorageEtcd) getWakeupChannelForTesting() <-chan struct{} { } type testListener struct { - etcd *embed.Etcd + etcd *etcdtest.Server closed chan struct{} } -func (tl *testListener) EtcdClientCreated(client *EtcdClient) { - tl.etcd.Server.Stop() +func (tl *testListener) EtcdClientCreated(client etcd.Client) { close(tl.closed) } -func Test_BackendStorageEtcdNoLeak(t *testing.T) { - CatchLogForTest(t) - ensureNoGoroutinesLeak(t, func(t *testing.T) { - etcd, client := NewEtcdClientForTest(t) +func Test_BackendStorageEtcdNoLeak(t *testing.T) { // nolint:paralleltest + logger := logtest.NewLoggerForTest(t) + test.EnsureNoGoroutinesLeak(t, func(t *testing.T) { + embedEtcd, client := etcdtest.NewClientForTest(t) tl := &testListener{ - etcd: etcd, + etcd: embedEtcd, closed: make(chan struct{}), } client.AddListener(tl) @@ -67,7 +70,7 @@ func Test_BackendStorageEtcdNoLeak(t *testing.T) { config.AddOption("backend", "backendtype", "etcd") config.AddOption("backend", "backendprefix", "/backends") - cfg, err := NewBackendConfiguration(config, client) + cfg, err := NewBackendConfiguration(logger, config, client) require.NoError(t, err) <-tl.closed diff --git a/talk/backend_storage_static.go b/talk/backend_storage_static.go new file mode 100644 index 0000000..b1ced2b --- /dev/null +++ b/talk/backend_storage_static.go @@ -0,0 +1,372 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2022 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package talk + +import ( + "net/url" + "slices" + "strings" + + "github.com/dlintw/goconf" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/config" + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" +) + +type backendStorageStatic struct { + backendStorageCommon + + logger log.Logger + backendsById map[string]*Backend + + // Deprecated + allowAll bool + commonSecret []byte + compatBackend *Backend +} + +func NewBackendStorageStatic(logger log.Logger, cfg *goconf.ConfigFile, stats BackendStorageStats) (BackendStorage, error) { + allowAll, _ := cfg.GetBool("backend", "allowall") + commonSecret, _ := config.GetStringOptionWithEnv(cfg, "backend", "secret") + backends := make(map[string][]*Backend) + backendsById := make(map[string]*Backend) + var compatBackend *Backend + numBackends := 0 + if allowAll { + logger.Println("WARNING: All backend hostnames are allowed, only use for development!") + compatBackend = NewCompatBackend(cfg) + if sessionLimit := compatBackend.Limit(); sessionLimit > 0 { + logger.Printf("Allow a maximum of %d sessions", sessionLimit) + } + compatBackend.UpdateStats() + backendsById[compatBackend.Id()] = compatBackend + numBackends++ + } else if backendIds, _ := cfg.GetString("backend", "backends"); backendIds != "" { + added := make(map[string]*Backend) + for host, configuredBackends := range getConfiguredHosts(logger, backendIds, cfg, commonSecret) { + backends[host] = append(backends[host], configuredBackends...) + for _, be := range configuredBackends { + added[be.Id()] = be + } + } + for _, be := range added { + logger.Printf("Backend %s added for %s", be.Id(), strings.Join(be.Urls(), ", ")) + backendsById[be.Id()] = be + be.UpdateStats() + be.Count() + } + numBackends += len(added) + } else if allowedUrls, _ := cfg.GetString("backend", "allowed"); allowedUrls != "" { + // Old-style configuration, only hosts are configured and are using a common secret. + allowMap := make(map[string]bool) + for u := range internal.SplitEntries(allowedUrls, ",") { + if idx := strings.IndexByte(u, '/'); idx != -1 { + logger.Printf("WARNING: Removing path from allowed hostname \"%s\", check your configuration!", u) + if u = u[:idx]; u == "" { + continue + } + } + + allowMap[strings.ToLower(u)] = true + } + + if len(allowMap) == 0 { + logger.Println("WARNING: No backend hostnames are allowed, check your configuration!") + } else { + compatBackend = NewCompatBackend(cfg) + hosts := make([]string, 0, len(allowMap)) + for host := range allowMap { + hosts = append(hosts, host) + backends[host] = []*Backend{compatBackend} + } + if len(hosts) > 1 { + logger.Println("WARNING: Using deprecated backend configuration. Please migrate the \"allowed\" setting to the new \"backends\" configuration.") + } + logger.Printf("Allowed backend hostnames: %s", hosts) + if sessionLimit := compatBackend.Limit(); sessionLimit > 0 { + logger.Printf("Allow a maximum of %d sessions", sessionLimit) + } + compatBackend.UpdateStats() + backendsById[compatBackend.Id()] = compatBackend + numBackends++ + } + } + + if numBackends == 0 { + logger.Printf("WARNING: No backends configured, client connections will not be possible.") + } + + stats.AddBackends(numBackends) + return &backendStorageStatic{ + backendStorageCommon: backendStorageCommon{ + backends: backends, + stats: stats, + }, + + logger: logger, + backendsById: backendsById, + + allowAll: allowAll, + commonSecret: []byte(commonSecret), + compatBackend: compatBackend, + }, nil +} + +func (s *backendStorageStatic) Close() { +} + +// +checklocks:s.mu +func (s *backendStorageStatic) RemoveBackendsForHost(host string, seen map[string]seenState) { + if oldBackends := s.backends[host]; len(oldBackends) > 0 { + deleted := 0 + for _, backend := range oldBackends { + if seen[backend.Id()] == seenDeleted { + continue + } + + seen[backend.Id()] = seenDeleted + urls := slices.DeleteFunc(backend.Urls(), func(s string) bool { + return !strings.Contains(s, "://"+host) + }) + s.logger.Printf("Backend %s removed for %s", backend.Id(), strings.Join(urls, ", ")) + if len(urls) == len(backend.Urls()) && backend.Uncount() { + backend.DeleteStats() + delete(s.backendsById, backend.Id()) + deleted++ + } + } + s.stats.RemoveBackends(deleted) + } + delete(s.backends, host) +} + +type seenState int + +const ( + seenNotSeen seenState = iota + seenAdded + seenUpdated + seenDeleted +) + +// +checklocks:s.mu +func (s *backendStorageStatic) UpsertHost(host string, backends []*Backend, seen map[string]seenState) { + for existingIndex, existingBackend := range s.backends[host] { + found := false + index := 0 + for _, newBackend := range backends { + if existingBackend.Equal(newBackend) { + found = true + backends = slices.Delete(backends, index, index+1) + break + } else if newBackend.Id() == existingBackend.Id() { + found = true + s.backends[host][existingIndex] = newBackend + backends = slices.Delete(backends, index, index+1) + if seen[newBackend.Id()] != seenUpdated { + seen[newBackend.Id()] = seenUpdated + s.logger.Printf("Backend %s updated for %s", newBackend.Id(), strings.Join(newBackend.Urls(), ", ")) + newBackend.UpdateStats() + newBackend.CopyCount(existingBackend) + s.backendsById[newBackend.Id()] = newBackend + } + break + } + index++ + } + if !found { + removed := s.backends[host][existingIndex] + s.backends[host] = slices.Delete(s.backends[host], existingIndex, existingIndex+1) + if seen[removed.Id()] != seenDeleted { + seen[removed.Id()] = seenDeleted + urls := slices.DeleteFunc(removed.Urls(), func(s string) bool { + return !strings.Contains(s, "://"+host) + }) + s.logger.Printf("Backend %s removed for %s", removed.Id(), strings.Join(urls, ", ")) + if len(urls) == len(removed.Urls()) && removed.Uncount() { + removed.DeleteStats() + delete(s.backendsById, removed.Id()) + s.stats.DecBackends() + } + } + } + } + + s.backends[host] = append(s.backends[host], backends...) + + addedBackends := 0 + for _, added := range backends { + if seen[added.Id()] == seenAdded { + continue + } + + seen[added.Id()] = seenAdded + if prev, found := s.backendsById[added.Id()]; found { + added.CopyCount(prev) + } else { + s.backendsById[added.Id()] = added + } + + s.logger.Printf("Backend %s added for %s", added.Id(), strings.Join(added.Urls(), ", ")) + if added.Count() { + added.UpdateStats() + addedBackends++ + } + } + s.stats.AddBackends(addedBackends) +} + +func getConfiguredBackendIDs(backendIds string) (ids []string) { + seen := make(map[string]bool) + + for id := range internal.SplitEntries(backendIds, ",") { + if seen[id] { + continue + } + + ids = append(ids, id) + seen[id] = true + } + + return ids +} + +func getConfiguredHosts(logger log.Logger, backendIds string, cfg *goconf.ConfigFile, commonSecret string) (hosts map[string][]*Backend) { + hosts = make(map[string][]*Backend) + seenUrls := make(map[string]string) + for _, id := range getConfiguredBackendIDs(backendIds) { + var urls []string + if u, _ := config.GetStringOptionWithEnv(cfg, id, "urls"); u != "" { + urls = slices.Sorted(internal.SplitEntries(u, ",")) + urls = slices.Compact(urls) + } else if u, _ := config.GetStringOptionWithEnv(cfg, id, "url"); u != "" { + if u = strings.TrimSpace(u); u != "" { + urls = []string{u} + } + } + + if len(urls) == 0 { + logger.Printf("Backend %s is missing or incomplete, skipping", id) + continue + } + + backend, err := NewBackendFromConfig(logger, id, cfg, commonSecret) + if err != nil { + logger.Printf("%s", err) + continue + } + + if sessionLimit := backend.Limit(); sessionLimit > 0 { + logger.Printf("Backend %s allows a maximum of %d sessions", id, sessionLimit) + } + + added := make(map[string]bool) + for _, u := range urls { + if u[len(u)-1] != '/' { + u += "/" + } + + parsed, err := url.Parse(u) + if err != nil { + logger.Printf("Backend %s has an invalid url %s configured (%s), skipping", id, u, err) + continue + } + + var changed bool + if parsed, changed = internal.CanonicalizeUrl(parsed); changed { + u = parsed.String() + } + + if prev, found := seenUrls[u]; found { + logger.Printf("Url %s in backend %s was already used in backend %s, skipping", u, id, prev) + continue + } + + seenUrls[u] = id + backend.AddUrl(parsed) + + if !added[parsed.Host] { + hosts[parsed.Host] = append(hosts[parsed.Host], backend) + added[parsed.Host] = true + } + } + } + + return hosts +} + +func (s *backendStorageStatic) Reload(cfg *goconf.ConfigFile) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.compatBackend != nil { + s.logger.Println("Old-style configuration active, reload is not supported") + return + } + + commonSecret, _ := config.GetStringOptionWithEnv(cfg, "backend", "secret") + + if backendIds, _ := cfg.GetString("backend", "backends"); backendIds != "" { + configuredHosts := getConfiguredHosts(s.logger, backendIds, cfg, commonSecret) + + // remove backends that are no longer configured + seen := make(map[string]seenState) + for hostname := range s.backends { + if _, ok := configuredHosts[hostname]; !ok { + s.RemoveBackendsForHost(hostname, seen) + } + } + + // rewrite backends adding newly configured ones and rewriting existing ones + for hostname, configuredBackends := range configuredHosts { + s.UpsertHost(hostname, configuredBackends, seen) + } + } else { + // remove all backends + seen := make(map[string]seenState) + for hostname := range s.backends { + s.RemoveBackendsForHost(hostname, seen) + } + } +} + +func (s *backendStorageStatic) GetCompatBackend() *Backend { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.compatBackend +} + +func (s *backendStorageStatic) GetBackend(u *url.URL) *Backend { + s.mu.RLock() + defer s.mu.RUnlock() + + if _, found := s.backends[u.Host]; !found { + if s.allowAll { + return s.compatBackend + } + return nil + } + + return s.getBackendLocked(u) +} diff --git a/capabilities.go b/talk/capabilities.go similarity index 71% rename from capabilities.go rename to talk/capabilities.go index e606bf0..4df1267 100644 --- a/capabilities.go +++ b/talk/capabilities.go @@ -19,21 +19,23 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( "context" "encoding/json" "errors" - "io" - "log" "net/http" "net/url" "strings" "sync" "time" - "github.com/marcw/cachecontrol" + "github.com/pquerna/cachecontrol/cacheobject" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + "github.com/strukturag/nextcloud-spreed-signaling/v2/pool" ) const ( @@ -56,16 +58,22 @@ const ( ) var ( - ErrUnexpectedHttpStatus = errors.New("unexpected_http_status") + ErrUnexpectedHttpStatus = errors.New("unexpected_http_status") // +checklocksignore: Global readonly variable. ) type capabilitiesEntry struct { - c *Capabilities - mu sync.RWMutex - nextUpdate time.Time - etag string + mu sync.RWMutex + // +checklocks:mu + c *Capabilities + + // +checklocks:mu + nextUpdate time.Time + // +checklocks:mu + etag string + // +checklocks:mu mustRevalidate bool - capabilities map[string]interface{} + // +checklocks:mu + capabilities api.StringMap } func newCapabilitiesEntry(c *Capabilities) *capabilitiesEntry { @@ -81,10 +89,12 @@ func (e *capabilitiesEntry) valid(now time.Time) bool { return e.validLocked(now) } +// +checklocksread:e.mu func (e *capabilitiesEntry) validLocked(now time.Time) bool { return e.nextUpdate.After(now) } +// +checklocks:e.mu func (e *capabilitiesEntry) updateRequest(r *http.Request) { if e.etag != "" { r.Header.Set("If-None-Match", e.etag) @@ -98,6 +108,7 @@ func (e *capabilitiesEntry) invalidate() { e.nextUpdate = time.Now() } +// +checklocks:e.mu func (e *capabilitiesEntry) errorIfMustRevalidate(err error) (bool, error) { if !e.mustRevalidate { return false, nil @@ -108,6 +119,7 @@ func (e *capabilitiesEntry) errorIfMustRevalidate(err error) (bool, error) { } func (e *capabilitiesEntry) update(ctx context.Context, u *url.URL, now time.Time) (bool, error) { + logger := log.LoggerFromContext(ctx) e.mu.Lock() defer e.mu.Unlock() @@ -121,23 +133,23 @@ func (e *capabilitiesEntry) update(ctx context.Context, u *url.URL, now time.Tim if !strings.HasSuffix(capUrl.Path, "/") { capUrl.Path += "/" } - capUrl.Path = capUrl.Path + "ocs/v2.php/cloud/capabilities" + capUrl.Path += "ocs/v2.php/cloud/capabilities" } else if pos := strings.Index(capUrl.Path, "/ocs/v2.php/"); pos >= 0 { capUrl.Path = capUrl.Path[:pos+11] + "/cloud/capabilities" } - log.Printf("Capabilities expired for %s, updating", capUrl.String()) + logger.Printf("Capabilities expired for %s, updating", capUrl.String()) client, pool, err := e.c.pool.Get(ctx, &capUrl) if err != nil { - log.Printf("Could not get client for host %s: %s", capUrl.Host, err) + logger.Printf("Could not get client for host %s: %s", capUrl.Host, err) return false, err } defer pool.Put(client) req, err := http.NewRequestWithContext(ctx, "GET", capUrl.String(), nil) if err != nil { - log.Printf("Could not create request to %s: %s", &capUrl, err) + logger.Printf("Could not create request to %s: %s", &capUrl, err) return false, err } req.Header.Set("Accept", "application/json") @@ -156,74 +168,75 @@ func (e *capabilitiesEntry) update(ctx context.Context, u *url.URL, now time.Tim var maxAge time.Duration if cacheControl := response.Header.Get("Cache-Control"); cacheControl != "" { - cc := cachecontrol.Parse(cacheControl) - if nc, _ := cc.NoCache(); !nc { - maxAge = cc.MaxAge() + if cc, err := cacheobject.ParseResponseCacheControl(cacheControl); err == nil { + if !cc.NoCachePresent && cc.MaxAge > 0 { + maxAge = time.Duration(cc.MaxAge) * time.Second + } + e.mustRevalidate = cc.MustRevalidate } - if maxAge < minCapabilitiesCacheDuration { - maxAge = minCapabilitiesCacheDuration - } - e.mustRevalidate = cc.MustRevalidate() - } else { + } + if maxAge < minCapabilitiesCacheDuration { maxAge = minCapabilitiesCacheDuration } e.nextUpdate = now.Add(maxAge) if response.StatusCode == http.StatusNotModified { - log.Printf("Capabilities %+v from %s have not changed", e.capabilities, url) + logger.Printf("Capabilities %+v from %s have not changed", e.capabilities, url) return false, nil } else if response.StatusCode != http.StatusOK { - log.Printf("Received unexpected HTTP status from %s: %s", url, response.Status) + logger.Printf("Received unexpected HTTP status from %s: %s", url, response.Status) return e.errorIfMustRevalidate(ErrUnexpectedHttpStatus) } ct := response.Header.Get("Content-Type") if !strings.HasPrefix(ct, "application/json") { - log.Printf("Received unsupported content-type from %s: %s (%s)", url, ct, response.Status) + logger.Printf("Received unsupported content-type from %s: %s (%s)", url, ct, response.Status) return e.errorIfMustRevalidate(ErrUnsupportedContentType) } - body, err := io.ReadAll(response.Body) + body, err := e.c.buffers.ReadAll(response.Body) if err != nil { - log.Printf("Could not read response body from %s: %s", url, err) + logger.Printf("Could not read response body from %s: %s", url, err) return e.errorIfMustRevalidate(err) } + defer e.c.buffers.Put(body) + var ocs OcsResponse - if err := json.Unmarshal(body, &ocs); err != nil { - log.Printf("Could not decode OCS response %s from %s: %s", string(body), url, err) + if err := json.Unmarshal(body.Bytes(), &ocs); err != nil { + logger.Printf("Could not decode OCS response %s from %s: %s", body.String(), url, err) return e.errorIfMustRevalidate(err) } else if ocs.Ocs == nil || len(ocs.Ocs.Data) == 0 { - log.Printf("Incomplete OCS response %s from %s", string(body), url) + logger.Printf("Incomplete OCS response %s from %s", body.String(), url) return e.errorIfMustRevalidate(ErrIncompleteResponse) } var capaResponse CapabilitiesResponse if err := json.Unmarshal(ocs.Ocs.Data, &capaResponse); err != nil { - log.Printf("Could not decode OCS response body %s from %s: %s", string(ocs.Ocs.Data), url, err) + logger.Printf("Could not decode OCS response body %s from %s: %s", string(ocs.Ocs.Data), url, err) return e.errorIfMustRevalidate(err) } capaObj, found := capaResponse.Capabilities[AppNameSpreed] if !found || len(capaObj) == 0 { - log.Printf("No capabilities received for app spreed from %s: %+v", url, capaResponse) + logger.Printf("No capabilities received for app spreed from %s: %+v", url, capaResponse) e.capabilities = nil return false, nil } - var capa map[string]interface{} + var capa api.StringMap if err := json.Unmarshal(capaObj, &capa); err != nil { - log.Printf("Unsupported capabilities received for app spreed from %s: %+v", url, capaResponse) + logger.Printf("Unsupported capabilities received for app spreed from %s: %+v", url, capaResponse) e.capabilities = nil return false, nil } - log.Printf("Received capabilities %+v from %s", capa, url) + logger.Printf("Received capabilities %+v from %s", capa, url) e.capabilities = capa return true, nil } -func (e *capabilitiesEntry) GetCapabilities() map[string]interface{} { +func (e *capabilitiesEntry) GetCapabilities() api.StringMap { e.mu.RLock() defer e.mu.RUnlock() @@ -236,13 +249,17 @@ type Capabilities struct { // Can be overwritten by tests. getNow func() time.Time - version string - pool *HttpClientPool - entries map[string]*capabilitiesEntry + version string + pool *pool.HttpClientPool + // +checklocks:mu + entries map[string]*capabilitiesEntry + // +checklocks:mu nextInvalidate map[string]time.Time + + buffers pool.BufferPool } -func NewCapabilities(version string, pool *HttpClientPool) (*Capabilities, error) { +func NewCapabilities(version string, pool *pool.HttpClientPool) (*Capabilities, error) { result := &Capabilities{ getNow: time.Now, @@ -320,7 +337,7 @@ func (c *Capabilities) getKeyForUrl(u *url.URL) string { return key } -func (c *Capabilities) loadCapabilities(ctx context.Context, u *url.URL) (map[string]interface{}, bool, error) { +func (c *Capabilities) loadCapabilities(ctx context.Context, u *url.URL) (api.StringMap, bool, error) { key := c.getKeyForUrl(u) entry, valid := c.getCapabilities(key) if valid { @@ -336,9 +353,10 @@ func (c *Capabilities) loadCapabilities(ctx context.Context, u *url.URL) (map[st } func (c *Capabilities) HasCapabilityFeature(ctx context.Context, u *url.URL, feature string) bool { + logger := log.LoggerFromContext(ctx) caps, _, err := c.loadCapabilities(ctx, u) if err != nil { - log.Printf("Could not get capabilities for %s: %s", u, err) + logger.Printf("Could not get capabilities for %s: %s", u, err) return false } @@ -347,9 +365,9 @@ func (c *Capabilities) HasCapabilityFeature(ctx context.Context, u *url.URL, fea return false } - features, ok := featuresInterface.([]interface{}) + features, ok := featuresInterface.([]any) if !ok { - log.Printf("Invalid features list received for %s: %+v", u, featuresInterface) + logger.Printf("Invalid features list received for %s: %+v", u, featuresInterface) return false } @@ -361,10 +379,11 @@ func (c *Capabilities) HasCapabilityFeature(ctx context.Context, u *url.URL, fea return false } -func (c *Capabilities) getConfigGroup(ctx context.Context, u *url.URL, group string) (map[string]interface{}, bool, bool) { +func (c *Capabilities) getConfigGroup(ctx context.Context, u *url.URL, group string) (api.StringMap, bool, bool) { + logger := log.LoggerFromContext(ctx) caps, cached, err := c.loadCapabilities(ctx, u) if err != nil { - log.Printf("Could not get capabilities for %s: %s", u, err) + logger.Printf("Could not get capabilities for %s: %s", u, err) return nil, cached, false } @@ -373,9 +392,9 @@ func (c *Capabilities) getConfigGroup(ctx context.Context, u *url.URL, group str return nil, cached, false } - config, ok := configInterface.(map[string]interface{}) + config, ok := api.ConvertStringMap(configInterface) if !ok { - log.Printf("Invalid config mapping received from %s: %+v", u, configInterface) + logger.Printf("Invalid config mapping received from %s: %+v", u, configInterface) return nil, cached, false } @@ -384,9 +403,9 @@ func (c *Capabilities) getConfigGroup(ctx context.Context, u *url.URL, group str return nil, cached, false } - groupConfig, ok := groupInterface.(map[string]interface{}) + groupConfig, ok := api.ConvertStringMap(groupInterface) if !ok { - log.Printf("Invalid group mapping \"%s\" received from %s: %+v", group, u, groupInterface) + logger.Printf("Invalid group mapping \"%s\" received from %s: %+v", group, u, groupInterface) return nil, cached, false } @@ -412,7 +431,8 @@ func (c *Capabilities) GetIntegerConfig(ctx context.Context, u *url.URL, group, case float64: return int(value), cached, true default: - log.Printf("Invalid config value for \"%s\" received from %s: %+v", key, u, value) + logger := log.LoggerFromContext(ctx) + logger.Printf("Invalid config value for \"%s\" received from %s: %+v", key, u, value) } return 0, cached, false @@ -433,7 +453,8 @@ func (c *Capabilities) GetStringConfig(ctx context.Context, u *url.URL, group, k case string: return value, cached, true default: - log.Printf("Invalid config value for \"%s\" received from %s: %+v", key, u, value) + logger := log.LoggerFromContext(ctx) + logger.Printf("Invalid config value for \"%s\" received from %s: %+v", key, u, value) } return "", cached, false diff --git a/capabilities_test.go b/talk/capabilities_test.go similarity index 88% rename from capabilities_test.go rename to talk/capabilities_test.go index d8857b4..8e2ab17 100644 --- a/capabilities_test.go +++ b/talk/capabilities_test.go @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package talk import ( "context" @@ -40,11 +40,21 @@ import ( "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/api" + "github.com/strukturag/nextcloud-spreed-signaling/v2/log" + logtest "github.com/strukturag/nextcloud-spreed-signaling/v2/log/test" + "github.com/strukturag/nextcloud-spreed-signaling/v2/pool" +) + +const ( + testTimeout = 10 * time.Second ) func NewCapabilitiesForTestWithCallback(t *testing.T, callback func(*CapabilitiesResponse, http.ResponseWriter) error) (*url.URL, *Capabilities) { require := require.New(t) - pool, err := NewHttpClientPool(1, false) + assert := assert.New(t) + pool, err := pool.NewHttpClientPool(1, false) require.NoError(err) capabilities, err := NewCapabilities("0.0", pool) require.NoError(err) @@ -66,17 +76,18 @@ func NewCapabilitiesForTestWithCallback(t *testing.T, callback func(*Capabilitie if strings.Contains(t.Name(), "V3Api") { features = append(features, "signaling-v3") } - signaling := map[string]interface{}{ + signaling := api.StringMap{ "foo": "bar", "baz": 42, } - config := map[string]interface{}{ + config := api.StringMap{ "signaling": signaling, } - spreedCapa, _ := json.Marshal(map[string]interface{}{ + spreedCapa, err := json.Marshal(api.StringMap{ "features": features, "config": config, }) + assert.NoError(err) emptyArray := []byte("[]") response := &CapabilitiesResponse{ Version: CapabilitiesVersion{ @@ -89,7 +100,7 @@ func NewCapabilitiesForTestWithCallback(t *testing.T, callback func(*Capabilitie } data, err := json.Marshal(response) - assert.NoError(t, err, "Could not marshal %+v", response) + assert.NoError(err, "Could not marshal %+v", response) var ocs OcsResponse ocs.Ocs = &OcsBody{ @@ -101,7 +112,7 @@ func NewCapabilitiesForTestWithCallback(t *testing.T, callback func(*Capabilitie Data: data, } data, err = json.Marshal(ocs) - require.NoError(err) + assert.NoError(err) var cc []string if !strings.Contains(t.Name(), "NoCache") { @@ -172,11 +183,12 @@ func SetCapabilitiesGetNow(t *testing.T, capabilities *Capabilities, f func() ti func TestCapabilities(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) url, capabilities := NewCapabilitiesForTest(t) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() assert.True(capabilities.HasCapabilityFeature(ctx, url, "foo")) @@ -215,7 +227,8 @@ func TestCapabilities(t *testing.T) { func TestInvalidateCapabilities(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { @@ -223,7 +236,7 @@ func TestInvalidateCapabilities(t *testing.T) { return nil }) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() expectedString := "bar" @@ -275,7 +288,8 @@ func TestInvalidateCapabilities(t *testing.T) { func TestCapabilitiesNoCache(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { @@ -283,7 +297,7 @@ func TestCapabilitiesNoCache(t *testing.T) { return nil }) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() expectedString := "bar" @@ -319,7 +333,8 @@ func TestCapabilitiesNoCache(t *testing.T) { func TestCapabilitiesShortCache(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { @@ -327,7 +342,7 @@ func TestCapabilitiesShortCache(t *testing.T) { return nil }) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() expectedString := "bar" @@ -348,7 +363,7 @@ func TestCapabilitiesShortCache(t *testing.T) { value = called.Load() assert.EqualValues(1, value) - // The capabilities are cached for a minumum duration. + // The capabilities are cached for a minimum duration. SetCapabilitiesGetNow(t, capabilities, func() time.Time { return time.Now().Add(minCapabilitiesCacheDuration / 2) }) @@ -373,7 +388,8 @@ func TestCapabilitiesShortCache(t *testing.T) { func TestCapabilitiesNoCacheETag(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { @@ -387,7 +403,7 @@ func TestCapabilitiesNoCacheETag(t *testing.T) { return nil }) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() expectedString := "bar" @@ -414,7 +430,8 @@ func TestCapabilitiesNoCacheETag(t *testing.T) { func TestCapabilitiesCacheNoMustRevalidate(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { @@ -425,7 +442,7 @@ func TestCapabilitiesCacheNoMustRevalidate(t *testing.T) { return nil }) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() expectedString := "bar" @@ -454,7 +471,8 @@ func TestCapabilitiesCacheNoMustRevalidate(t *testing.T) { func TestCapabilitiesNoCacheNoMustRevalidate(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { @@ -465,7 +483,7 @@ func TestCapabilitiesNoCacheNoMustRevalidate(t *testing.T) { return nil }) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() expectedString := "bar" @@ -494,7 +512,8 @@ func TestCapabilitiesNoCacheNoMustRevalidate(t *testing.T) { func TestCapabilitiesNoCacheMustRevalidate(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { @@ -505,7 +524,7 @@ func TestCapabilitiesNoCacheMustRevalidate(t *testing.T) { return nil }) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() expectedString := "bar" @@ -532,7 +551,8 @@ func TestCapabilitiesNoCacheMustRevalidate(t *testing.T) { func TestConcurrentExpired(t *testing.T) { t.Parallel() - CatchLogForTest(t) + logger := logtest.NewLoggerForTest(t) + ctx := log.NewLoggerContext(t.Context(), logger) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { @@ -540,7 +560,7 @@ func TestConcurrentExpired(t *testing.T) { return nil }) - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(ctx, testTimeout) defer cancel() expectedString := "bar" @@ -554,10 +574,8 @@ func TestConcurrentExpired(t *testing.T) { var numCached atomic.Uint32 var numFetched atomic.Uint32 var finished sync.WaitGroup - for i := 0; i < count; i++ { - finished.Add(1) - go func() { - defer finished.Done() + for range count { + finished.Go(func() { <-start if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) @@ -567,7 +585,7 @@ func TestConcurrentExpired(t *testing.T) { numFetched.Add(1) } } - }() + }) } SetCapabilitiesGetNow(t, capabilities, func() time.Time { diff --git a/talk/ocs.go b/talk/ocs.go new file mode 100644 index 0000000..32b933c --- /dev/null +++ b/talk/ocs.go @@ -0,0 +1,50 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package talk + +import ( + "encoding/json" + "net/url" + "strings" +) + +type OcsMeta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` +} + +type OcsBody struct { + Meta OcsMeta `json:"meta"` + Data json.RawMessage `json:"data"` +} + +type OcsResponse struct { + json.Marshaler + json.Unmarshaler + + Ocs *OcsBody `json:"ocs"` +} + +func IsOcsRequest(u *url.URL) bool { + return strings.Contains(u.Path, "/ocs/v2.php") || strings.Contains(u.Path, "/ocs/v1.php") +} diff --git a/talk/ocs_easyjson.go b/talk/ocs_easyjson.go new file mode 100644 index 0000000..efcec5f --- /dev/null +++ b/talk/ocs_easyjson.go @@ -0,0 +1,261 @@ +// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. + +package talk + +import ( + json "encoding/json" + easyjson "github.com/mailru/easyjson" + jlexer "github.com/mailru/easyjson/jlexer" + jwriter "github.com/mailru/easyjson/jwriter" +) + +// suppress unused package warning +var ( + _ *json.RawMessage + _ *jlexer.Lexer + _ *jwriter.Writer + _ easyjson.Marshaler +) + +func easyjsonD5833a6fDecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(in *jlexer.Lexer, out *OcsResponse) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "ocs": + if in.IsNull() { + in.Skip() + out.Ocs = nil + } else { + if out.Ocs == nil { + out.Ocs = new(OcsBody) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Ocs).UnmarshalEasyJSON(in) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonD5833a6fEncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(out *jwriter.Writer, in OcsResponse) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"ocs\":" + out.RawString(prefix[1:]) + if in.Ocs == nil { + out.RawString("null") + } else { + (*in.Ocs).MarshalEasyJSON(out) + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v OcsResponse) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonD5833a6fEncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v OcsResponse) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonD5833a6fEncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *OcsResponse) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonD5833a6fDecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *OcsResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonD5833a6fDecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk(l, v) +} +func easyjsonD5833a6fDecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(in *jlexer.Lexer, out *OcsMeta) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "status": + if in.IsNull() { + in.Skip() + } else { + out.Status = string(in.String()) + } + case "statuscode": + if in.IsNull() { + in.Skip() + } else { + out.StatusCode = int(in.Int()) + } + case "message": + if in.IsNull() { + in.Skip() + } else { + out.Message = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonD5833a6fEncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(out *jwriter.Writer, in OcsMeta) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"status\":" + out.RawString(prefix[1:]) + out.String(string(in.Status)) + } + { + const prefix string = ",\"statuscode\":" + out.RawString(prefix) + out.Int(int(in.StatusCode)) + } + { + const prefix string = ",\"message\":" + out.RawString(prefix) + out.String(string(in.Message)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v OcsMeta) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonD5833a6fEncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v OcsMeta) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonD5833a6fEncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *OcsMeta) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonD5833a6fDecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *OcsMeta) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonD5833a6fDecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk1(l, v) +} +func easyjsonD5833a6fDecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(in *jlexer.Lexer, out *OcsBody) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "meta": + if in.IsNull() { + in.Skip() + } else { + (out.Meta).UnmarshalEasyJSON(in) + } + case "data": + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((out.Data).UnmarshalJSON(data)) + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonD5833a6fEncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(out *jwriter.Writer, in OcsBody) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"meta\":" + out.RawString(prefix[1:]) + (in.Meta).MarshalEasyJSON(out) + } + { + const prefix string = ",\"data\":" + out.RawString(prefix) + out.Raw((in.Data).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v OcsBody) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonD5833a6fEncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v OcsBody) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonD5833a6fEncodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *OcsBody) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonD5833a6fDecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *OcsBody) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonD5833a6fDecodeGithubComStrukturagNextcloudSpreedSignalingV2Talk2(l, v) +} diff --git a/testutils_test.go b/test/goroutines.go similarity index 76% rename from testutils_test.go rename to test/goroutines.go index f2d507a..e062e24 100644 --- a/testutils_test.go +++ b/test/goroutines.go @@ -1,6 +1,6 @@ /** * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2021 struktur AG + * Copyright (C) 2025 struktur AG * * @author Joachim Bauch * @@ -19,7 +19,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package test import ( "bytes" @@ -36,7 +36,13 @@ import ( var listenSignalOnce sync.Once -func ensureNoGoroutinesLeak(t *testing.T, f func(t *testing.T)) { +func EnsureNoGoroutinesLeak(t *testing.T, f func(t *testing.T)) { + t.Helper() + + ensureNoGoroutinesLeak(t, f, false) +} + +func ensureNoGoroutinesLeak(t *testing.T, f func(t *testing.T), fromTest bool) (int, int) { t.Helper() // Make sure test is not executed with "t.Parallel()" t.Setenv("PARALLEL_CHECK", "1") @@ -57,17 +63,24 @@ func ensureNoGoroutinesLeak(t *testing.T, f func(t *testing.T)) { profile := pprof.Lookup("goroutine") // Give time for things to settle before capturing the number of // go routines - time.Sleep(500 * time.Millisecond) - before := profile.Count() + var before int + timeout := time.Now().Add(time.Second) + for time.Now().Before(timeout) { + before = profile.Count() + time.Sleep(10 * time.Millisecond) + if profile.Count() == before { + break + } + } var prev bytes.Buffer - dumpGoroutines("Before:", &prev) + DumpGoroutines("Before:", &prev) t.Run("leakcheck", f) var after int // Give time for things to settle before capturing the number of // go routines - timeout := time.Now().Add(time.Second) + timeout = time.Now().Add(time.Second) for time.Now().Before(timeout) { after = profile.Count() if after == before { @@ -75,14 +88,15 @@ func ensureNoGoroutinesLeak(t *testing.T, f func(t *testing.T)) { } } - if after != before { + if after != before && !fromTest { io.Copy(os.Stderr, &prev) // nolint - dumpGoroutines("After:", os.Stderr) + DumpGoroutines("After:", os.Stderr) require.Equal(t, before, after, "Number of Go routines has changed") } + return before, after } -func dumpGoroutines(prefix string, w io.Writer) { +func DumpGoroutines(prefix string, w io.Writer) { if prefix != "" { io.WriteString(w, prefix+"\n") // nolint } diff --git a/channel_waiter.go b/test/goroutines_test.go similarity index 54% rename from channel_waiter.go rename to test/goroutines_test.go index 20b0883..65cf390 100644 --- a/channel_waiter.go +++ b/test/goroutines_test.go @@ -1,6 +1,6 @@ /** * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2023 struktur AG + * Copyright (C) 2025 struktur AG * * @author Joachim Bauch * @@ -19,44 +19,42 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package test import ( - "sync" + "testing" + + "github.com/stretchr/testify/assert" ) -type ChannelWaiters struct { - mu sync.RWMutex - id uint64 - waiters map[uint64]chan struct{} +func TestNoGoroutineLeak(t *testing.T) { // nolint:paralleltest + EnsureNoGoroutinesLeak(t, func(t *testing.T) { + stop := make(chan struct{}) + stopped := make(chan struct{}) + + go func() { + defer close(stopped) + <-stop + }() + + close(stop) + <-stopped + }) } -func (w *ChannelWaiters) Wakeup() { - w.mu.RLock() - defer w.mu.RUnlock() - for _, ch := range w.waiters { - select { - case ch <- struct{}{}: - default: - // Receiver is still processing previous wakeup. - } - } -} +func TestLeakGoroutine(t *testing.T) { // nolint:paralleltest + stop := make(chan struct{}) + stopped := make(chan struct{}) -func (w *ChannelWaiters) Add(ch chan struct{}) uint64 { - w.mu.Lock() - defer w.mu.Unlock() - if w.waiters == nil { - w.waiters = make(map[uint64]chan struct{}) - } - id := w.id - w.id++ - w.waiters[id] = ch - return id -} + before, after := ensureNoGoroutinesLeak(t, func(t *testing.T) { + go func() { + defer close(stopped) + <-stop + }() -func (w *ChannelWaiters) Remove(id uint64) { - w.mu.Lock() - defer w.mu.Unlock() - delete(w.waiters, id) + }, true) + close(stop) + <-stopped + + assert.Equal(t, 1, after-before, "wrong number of leaked goroutines") } diff --git a/test/network.go b/test/network.go new file mode 100644 index 0000000..dfc4326 --- /dev/null +++ b/test/network.go @@ -0,0 +1,49 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "os" + "runtime" + "syscall" + + "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" +) + +func IsErrorAddressAlreadyInUse(err error) bool { + eOsSyscall, ok := internal.AsErrorType[*os.SyscallError](err) + if !ok { + return false + } + errErrno, ok := internal.AsErrorType[syscall.Errno](eOsSyscall) + if !ok { + return false + } + if errErrno == syscall.EADDRINUSE { + return true + } + const WSAEADDRINUSE = 10048 + if runtime.GOOS == "windows" && errErrno == WSAEADDRINUSE { + return true + } + return false +} diff --git a/test/network_test.go b/test/network_test.go new file mode 100644 index 0000000..69a24fe --- /dev/null +++ b/test/network_test.go @@ -0,0 +1,43 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsErrorAddressAlreadyInUse(t *testing.T) { + t.Parallel() + require := require.New(t) + assert := assert.New(t) + + listener1, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(err) + + listener2, err := net.Listen("tcp", listener1.Addr().String()) + assert.Nil(listener2) + assert.True(IsErrorAddressAlreadyInUse(err), "expected address already in use, got %+v", err) +} diff --git a/mcu_common_test.go b/test/storage.go similarity index 50% rename from mcu_common_test.go rename to test/storage.go index 6304638..9b23ad3 100644 --- a/mcu_common_test.go +++ b/test/storage.go @@ -1,6 +1,6 @@ /** * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2021 struktur AG + * Copyright (C) 2025 struktur AG * * @author Joachim Bauch * @@ -19,52 +19,60 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package signaling +package test import ( + "sync" "testing" ) -func TestCommonMcuStats(t *testing.T) { - collectAndLint(t, commonMcuStats...) +type Storage[T any] struct { + mu sync.Mutex + // +checklocks:mu + entries map[string]T } -type MockMcuListener struct { - publicId string +func (s *Storage[T]) cleanup(key string) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.entries, key) + if len(s.entries) == 0 { + s.entries = nil + } } -func (m *MockMcuListener) PublicId() string { - return m.publicId +func (s *Storage[T]) Set(tb testing.TB, value T) { + s.mu.Lock() + defer s.mu.Unlock() + + key := tb.Name() + if _, found := s.entries[key]; !found { + tb.Cleanup(func() { + s.cleanup(key) + }) + } + + if s.entries == nil { + s.entries = make(map[string]T) + } + s.entries[key] = value } -func (m *MockMcuListener) OnUpdateOffer(client McuClient, offer map[string]interface{}) { +func (s *Storage[T]) Get(tb testing.TB) (T, bool) { + s.mu.Lock() + defer s.mu.Unlock() + key := tb.Name() + if value, found := s.entries[key]; found { + return value, true + } + + var defaultValue T + return defaultValue, false } -func (m *MockMcuListener) OnIceCandidate(client McuClient, candidate interface{}) { - -} - -func (m *MockMcuListener) OnIceCompleted(client McuClient) { - -} - -func (m *MockMcuListener) SubscriberSidUpdated(subscriber McuSubscriber) { - -} - -func (m *MockMcuListener) PublisherClosed(publisher McuPublisher) { - -} - -func (m *MockMcuListener) SubscriberClosed(subscriber McuSubscriber) { - -} - -type MockMcuInitiator struct { - country string -} - -func (m *MockMcuInitiator) Country() string { - return m.country +func (s *Storage[T]) Del(tb testing.TB) { + key := tb.Name() + s.cleanup(key) } diff --git a/test/storage_test.go b/test/storage_test.go new file mode 100644 index 0000000..32d6729 --- /dev/null +++ b/test/storage_test.go @@ -0,0 +1,64 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_TestStorage(t *testing.T) { + t.Parallel() + assert := assert.New(t) + var storage Storage[int] + + t.Cleanup(func() { + storage.mu.Lock() + defer storage.mu.Unlock() + + assert.Nil(storage.entries) + }) + + v, found := storage.Get(t) + assert.False(found, "expected missing value, got %d", v) + + storage.Set(t, 10) + v, found = storage.Get(t) + assert.True(found) + assert.Equal(10, v) + + storage.Set(t, 20) + v, found = storage.Get(t) + assert.True(found) + assert.Equal(20, v) + + storage.Del(t) + + v, found = storage.Get(t) + assert.False(found, "expected missing value, got %d", v) + + storage.Set(t, 30) + v, found = storage.Get(t) + assert.True(found) + assert.Equal(30, v) +} diff --git a/test/wakeup_channel.go b/test/wakeup_channel.go new file mode 100644 index 0000000..39476be --- /dev/null +++ b/test/wakeup_channel.go @@ -0,0 +1,32 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +func DrainWakeupChannel(ch <-chan struct{}) { + for { + select { + case <-ch: + default: + return + } + } +} diff --git a/test/wakeup_channel_test.go b/test/wakeup_channel_test.go new file mode 100644 index 0000000..12dbc6a --- /dev/null +++ b/test/wakeup_channel_test.go @@ -0,0 +1,39 @@ +/** + * Standalone signaling server for the Nextcloud Spreed app. + * Copyright (C) 2025 struktur AG + * + * @author Joachim Bauch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package test + +import ( + "testing" +) + +func TestDrainWakeupChannel(t *testing.T) { + t.Parallel() + + ch := make(chan struct{}, 2) + ch <- struct{}{} + ch <- struct{}{} + + DrainWakeupChannel(ch) + + ch <- struct{}{} + ch <- struct{}{} +} diff --git a/testclient_test.go b/testclient_test.go deleted file mode 100644 index 9078bcc..0000000 --- a/testclient_test.go +++ /dev/null @@ -1,1130 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2017 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "net" - "net/http/httptest" - "reflect" - "strconv" - "strings" - "sync" - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/gorilla/websocket" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var ( - testBackendSecret = []byte("secret") - testInternalSecret = []byte("internal-secret") - - ErrNoMessageReceived = fmt.Errorf("no message was received by the server") -) - -type TestBackendClientAuthParams struct { - UserId string `json:"userid"` -} - -func getWebsocketUrl(url string) string { - if strings.HasPrefix(url, "http://") { - return "ws://" + url[7:] + "/spreed" - } else if strings.HasPrefix(url, "https://") { - return "wss://" + url[8:] + "/spreed" - } else { - panic("Unsupported URL: " + url) - } -} - -func getPubliceSessionIdData(h *Hub, publicId string) *SessionIdData { - decodedPublic := h.decodePublicSessionId(publicId) - if decodedPublic == nil { - panic("invalid public session id") - } - return decodedPublic -} - -func checkUnexpectedClose(err error) error { - if err != nil && websocket.IsUnexpectedCloseError(err, - websocket.CloseNormalClosure, - websocket.CloseGoingAway, - websocket.CloseNoStatusReceived) { - return fmt.Errorf("Connection was closed with unexpected error: %s", err) - } - - return nil -} - -func checkMessageType(message *ServerMessage, expectedType string) error { - if message == nil { - return ErrNoMessageReceived - } - - if message.Type != expectedType { - return fmt.Errorf("Expected \"%s\" message, got %+v", expectedType, message) - } - switch message.Type { - case "hello": - if message.Hello == nil { - return fmt.Errorf("Expected \"%s\" message, got %+v", expectedType, message) - } - case "message": - if message.Message == nil { - return fmt.Errorf("Expected \"%s\" message, got %+v", expectedType, message) - } else if len(message.Message.Data) == 0 { - return fmt.Errorf("Received message without data") - } - case "room": - if message.Room == nil { - return fmt.Errorf("Expected \"%s\" message, got %+v", expectedType, message) - } - case "event": - if message.Event == nil { - return fmt.Errorf("Expected \"%s\" message, got %+v", expectedType, message) - } - case "transient": - if message.TransientData == nil { - return fmt.Errorf("Expected \"%s\" message, got %+v", expectedType, message) - } - } - - return nil -} - -func checkMessageSender(hub *Hub, sender *MessageServerMessageSender, senderType string, hello *HelloServerMessage) error { - if sender.Type != senderType { - return fmt.Errorf("Expected sender type %s, got %s", senderType, sender.SessionId) - } else if sender.SessionId != hello.SessionId { - return fmt.Errorf("Expected session id %+v, got %+v", - getPubliceSessionIdData(hub, hello.SessionId), getPubliceSessionIdData(hub, sender.SessionId)) - } else if sender.UserId != hello.UserId { - return fmt.Errorf("Expected user id %s, got %s", hello.UserId, sender.UserId) - } - - return nil -} - -func checkReceiveClientMessageWithSenderAndRecipient(ctx context.Context, client *TestClient, senderType string, hello *HelloServerMessage, payload interface{}, sender **MessageServerMessageSender, recipient **MessageClientMessageRecipient) error { - message, err := client.RunUntilMessage(ctx) - if err := checkUnexpectedClose(err); err != nil { - return err - } else if err := checkMessageType(message, "message"); err != nil { - return err - } else if err := checkMessageSender(client.hub, message.Message.Sender, senderType, hello); err != nil { - return err - } else { - if err := json.Unmarshal(message.Message.Data, payload); err != nil { - return err - } - } - if sender != nil { - *sender = message.Message.Sender - } - if recipient != nil { - *recipient = message.Message.Recipient - } - return nil -} - -func checkReceiveClientMessageWithSender(ctx context.Context, client *TestClient, senderType string, hello *HelloServerMessage, payload interface{}, sender **MessageServerMessageSender) error { - return checkReceiveClientMessageWithSenderAndRecipient(ctx, client, senderType, hello, payload, sender, nil) -} - -func checkReceiveClientMessage(ctx context.Context, client *TestClient, senderType string, hello *HelloServerMessage, payload interface{}) error { - return checkReceiveClientMessageWithSenderAndRecipient(ctx, client, senderType, hello, payload, nil, nil) -} - -func checkReceiveClientControlWithSenderAndRecipient(ctx context.Context, client *TestClient, senderType string, hello *HelloServerMessage, payload interface{}, sender **MessageServerMessageSender, recipient **MessageClientMessageRecipient) error { - message, err := client.RunUntilMessage(ctx) - if err := checkUnexpectedClose(err); err != nil { - return err - } else if err := checkMessageType(message, "control"); err != nil { - return err - } else if err := checkMessageSender(client.hub, message.Control.Sender, senderType, hello); err != nil { - return err - } else { - if err := json.Unmarshal(message.Control.Data, payload); err != nil { - return err - } - } - if sender != nil { - *sender = message.Control.Sender - } - if recipient != nil { - *recipient = message.Control.Recipient - } - return nil -} - -func checkReceiveClientControlWithSender(ctx context.Context, client *TestClient, senderType string, hello *HelloServerMessage, payload interface{}, sender **MessageServerMessageSender) error { // nolint - return checkReceiveClientControlWithSenderAndRecipient(ctx, client, senderType, hello, payload, sender, nil) -} - -func checkReceiveClientControl(ctx context.Context, client *TestClient, senderType string, hello *HelloServerMessage, payload interface{}) error { - return checkReceiveClientControlWithSenderAndRecipient(ctx, client, senderType, hello, payload, nil, nil) -} - -func checkReceiveClientEvent(ctx context.Context, client *TestClient, eventType string, msg **EventServerMessage) error { - message, err := client.RunUntilMessage(ctx) - if err := checkUnexpectedClose(err); err != nil { - return err - } else if err := checkMessageType(message, "event"); err != nil { - return err - } else if message.Event.Type != eventType { - return fmt.Errorf("Expected \"%s\" event type, got \"%s\"", eventType, message.Event.Type) - } else { - if msg != nil { - *msg = message.Event - } - } - return nil -} - -type TestClient struct { - t *testing.T - hub *Hub - server *httptest.Server - - mu sync.Mutex - conn *websocket.Conn - localAddr net.Addr - - messageChan chan []byte - readErrorChan chan error - - publicId string -} - -func NewTestClientContext(ctx context.Context, t *testing.T, server *httptest.Server, hub *Hub) *TestClient { - // Reference "hub" to prevent compiler error. - conn, _, err := websocket.DefaultDialer.DialContext(ctx, getWebsocketUrl(server.URL), nil) - require.NoError(t, err) - - messageChan := make(chan []byte) - readErrorChan := make(chan error, 1) - - go func() { - for { - messageType, data, err := conn.ReadMessage() - if err != nil { - readErrorChan <- err - return - } else if !assert.Equal(t, websocket.TextMessage, messageType) { - return - } - - messageChan <- data - } - }() - - return &TestClient{ - t: t, - hub: hub, - server: server, - - conn: conn, - localAddr: conn.LocalAddr(), - - messageChan: messageChan, - readErrorChan: readErrorChan, - } -} - -func NewTestClient(t *testing.T, server *httptest.Server, hub *Hub) *TestClient { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - client := NewTestClientContext(ctx, t, server, hub) - msg, err := client.RunUntilMessage(ctx) - require.NoError(t, err) - assert.Equal(t, "welcome", msg.Type) - return client -} - -func (c *TestClient) CloseWithBye() { - c.SendBye() // nolint - c.Close() -} - -func (c *TestClient) Close() { - c.mu.Lock() - defer c.mu.Unlock() - if err := c.conn.WriteMessage(websocket.CloseMessage, []byte{}); err == websocket.ErrCloseSent { - // Already closed - return - } - - // Wait a bit for close message to be processed. - time.Sleep(100 * time.Millisecond) - c.conn.Close() - - // Drain any entries in the channels to terminate the read goroutine. -loop: - for { - select { - case <-c.readErrorChan: - case <-c.messageChan: - default: - break loop - } - } -} - -func (c *TestClient) WaitForClientRemoved(ctx context.Context) error { - c.hub.mu.Lock() - defer c.hub.mu.Unlock() - for { - found := false - for _, client := range c.hub.clients { - if cc, ok := client.(*Client); ok { - cc.mu.Lock() - conn := cc.conn - cc.mu.Unlock() - if conn != nil && conn.RemoteAddr().String() == c.localAddr.String() { - found = true - break - } - } - } - if !found { - break - } - - c.hub.mu.Unlock() - select { - case <-ctx.Done(): - c.hub.mu.Lock() - return ctx.Err() - default: - time.Sleep(time.Millisecond) - } - c.hub.mu.Lock() - } - return nil -} - -func (c *TestClient) WaitForSessionRemoved(ctx context.Context, sessionId string) error { - data := c.hub.decodePublicSessionId(sessionId) - if data == nil { - return fmt.Errorf("Invalid session id passed") - } - - c.hub.mu.Lock() - defer c.hub.mu.Unlock() - for { - _, found := c.hub.sessions[data.Sid] - if !found { - break - } - - c.hub.mu.Unlock() - select { - case <-ctx.Done(): - c.hub.mu.Lock() - return ctx.Err() - default: - time.Sleep(time.Millisecond) - } - c.hub.mu.Lock() - } - return nil -} - -func (c *TestClient) WriteJSON(data interface{}) error { - if !strings.Contains(c.t.Name(), "HelloUnsupportedVersion") { - if msg, ok := data.(*ClientMessage); ok { - if err := msg.CheckValid(); err != nil { - return err - } - } - } - - c.mu.Lock() - defer c.mu.Unlock() - return c.conn.WriteJSON(data) -} - -func (c *TestClient) EnsuerWriteJSON(data interface{}) { - require.NoError(c.t, c.WriteJSON(data), "Could not write JSON %+v", data) -} - -func (c *TestClient) SendHello(userid string) error { - return c.SendHelloV1(userid) -} - -func (c *TestClient) SendHelloV1(userid string) error { - params := TestBackendClientAuthParams{ - UserId: userid, - } - return c.SendHelloParams(c.server.URL, HelloVersionV1, "", nil, params) -} - -func (c *TestClient) SendHelloV2(userid string) error { - return c.SendHelloV2WithFeatures(userid, nil) -} - -func (c *TestClient) SendHelloV2WithFeatures(userid string, features []string) error { - now := time.Now() - return c.SendHelloV2WithTimesAndFeatures(userid, now, now.Add(time.Minute), features) -} - -func (c *TestClient) CreateHelloV2TokenWithUserdata(userid string, issuedAt time.Time, expiresAt time.Time, userdata map[string]interface{}) (string, error) { - data, err := json.Marshal(userdata) - if err != nil { - return "", err - } - - claims := &HelloV2TokenClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - Issuer: c.server.URL, - Subject: userid, - }, - UserData: data, - } - if !issuedAt.IsZero() { - claims.IssuedAt = jwt.NewNumericDate(issuedAt) - } - if !expiresAt.IsZero() { - claims.ExpiresAt = jwt.NewNumericDate(expiresAt) - } - - var token *jwt.Token - if strings.Contains(c.t.Name(), "ECDSA") { - token = jwt.NewWithClaims(jwt.SigningMethodES256, claims) - } else if strings.Contains(c.t.Name(), "Ed25519") { - token = jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) - } else { - token = jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - } - private := getPrivateAuthToken(c.t) - return token.SignedString(private) -} - -func (c *TestClient) CreateHelloV2Token(userid string, issuedAt time.Time, expiresAt time.Time) (string, error) { - userdata := map[string]interface{}{ - "displayname": "Displayname " + userid, - } - - return c.CreateHelloV2TokenWithUserdata(userid, issuedAt, expiresAt, userdata) -} - -func (c *TestClient) SendHelloV2WithTimes(userid string, issuedAt time.Time, expiresAt time.Time) error { - return c.SendHelloV2WithTimesAndFeatures(userid, issuedAt, expiresAt, nil) -} - -func (c *TestClient) SendHelloV2WithTimesAndFeatures(userid string, issuedAt time.Time, expiresAt time.Time, features []string) error { - tokenString, err := c.CreateHelloV2Token(userid, issuedAt, expiresAt) - require.NoError(c.t, err) - - params := HelloV2AuthParams{ - Token: tokenString, - } - return c.SendHelloParams(c.server.URL, HelloVersionV2, "", features, params) -} - -func (c *TestClient) SendHelloResume(resumeId string) error { - hello := &ClientMessage{ - Id: "1234", - Type: "hello", - Hello: &HelloClientMessage{ - Version: HelloVersionV1, - ResumeId: resumeId, - }, - } - return c.WriteJSON(hello) -} - -func (c *TestClient) SendHelloClient(userid string) error { - return c.SendHelloClientWithFeatures(userid, nil) -} - -func (c *TestClient) SendHelloClientWithFeatures(userid string, features []string) error { - params := TestBackendClientAuthParams{ - UserId: userid, - } - return c.SendHelloParams(c.server.URL, HelloVersionV1, "client", features, params) -} - -func (c *TestClient) SendHelloInternal() error { - return c.SendHelloInternalWithFeatures(nil) -} - -func (c *TestClient) SendHelloInternalWithFeatures(features []string) error { - random := newRandomString(48) - mac := hmac.New(sha256.New, testInternalSecret) - mac.Write([]byte(random)) // nolint - token := hex.EncodeToString(mac.Sum(nil)) - backend := c.server.URL - - params := ClientTypeInternalAuthParams{ - Random: random, - Token: token, - Backend: backend, - } - return c.SendHelloParams("", HelloVersionV1, "internal", features, params) -} - -func (c *TestClient) SendHelloParams(url string, version string, clientType string, features []string, params interface{}) error { - data, err := json.Marshal(params) - require.NoError(c.t, err) - - hello := &ClientMessage{ - Id: "1234", - Type: "hello", - Hello: &HelloClientMessage{ - Version: version, - Features: features, - Auth: &HelloClientMessageAuth{ - Type: clientType, - Url: url, - Params: data, - }, - }, - } - return c.WriteJSON(hello) -} - -func (c *TestClient) SendBye() error { - hello := &ClientMessage{ - Id: "9876", - Type: "bye", - Bye: &ByeClientMessage{}, - } - return c.WriteJSON(hello) -} - -func (c *TestClient) SendMessage(recipient MessageClientMessageRecipient, data interface{}) error { - payload, err := json.Marshal(data) - require.NoError(c.t, err) - - message := &ClientMessage{ - Id: "abcd", - Type: "message", - Message: &MessageClientMessage{ - Recipient: recipient, - Data: payload, - }, - } - return c.WriteJSON(message) -} - -func (c *TestClient) SendControl(recipient MessageClientMessageRecipient, data interface{}) error { - payload, err := json.Marshal(data) - require.NoError(c.t, err) - - message := &ClientMessage{ - Id: "abcd", - Type: "control", - Control: &ControlClientMessage{ - MessageClientMessage: MessageClientMessage{ - Recipient: recipient, - Data: payload, - }, - }, - } - return c.WriteJSON(message) -} - -func (c *TestClient) SendInternalAddSession(msg *AddSessionInternalClientMessage) error { - message := &ClientMessage{ - Id: "abcd", - Type: "internal", - Internal: &InternalClientMessage{ - Type: "addsession", - AddSession: msg, - }, - } - return c.WriteJSON(message) -} - -func (c *TestClient) SendInternalUpdateSession(msg *UpdateSessionInternalClientMessage) error { - message := &ClientMessage{ - Id: "abcd", - Type: "internal", - Internal: &InternalClientMessage{ - Type: "updatesession", - UpdateSession: msg, - }, - } - return c.WriteJSON(message) -} - -func (c *TestClient) SendInternalRemoveSession(msg *RemoveSessionInternalClientMessage) error { - message := &ClientMessage{ - Id: "abcd", - Type: "internal", - Internal: &InternalClientMessage{ - Type: "removesession", - RemoveSession: msg, - }, - } - return c.WriteJSON(message) -} - -func (c *TestClient) SendInternalDialout(msg *DialoutInternalClientMessage) error { - message := &ClientMessage{ - Id: "abcd", - Type: "internal", - Internal: &InternalClientMessage{ - Type: "dialout", - Dialout: msg, - }, - } - return c.WriteJSON(message) -} - -func (c *TestClient) SetTransientData(key string, value interface{}, ttl time.Duration) error { - payload, err := json.Marshal(value) - require.NoError(c.t, err) - - message := &ClientMessage{ - Id: "efgh", - Type: "transient", - TransientData: &TransientDataClientMessage{ - Type: "set", - Key: key, - Value: payload, - TTL: ttl, - }, - } - return c.WriteJSON(message) -} - -func (c *TestClient) RemoveTransientData(key string) error { - message := &ClientMessage{ - Id: "ijkl", - Type: "transient", - TransientData: &TransientDataClientMessage{ - Type: "remove", - Key: key, - }, - } - return c.WriteJSON(message) -} - -func (c *TestClient) DrainMessages(ctx context.Context) error { - select { - case err := <-c.readErrorChan: - return err - case <-c.messageChan: - n := len(c.messageChan) - for i := 0; i < n; i++ { - <-c.messageChan - } - case <-ctx.Done(): - return ctx.Err() - } - return nil -} - -func (c *TestClient) GetPendingMessages(ctx context.Context) ([]*ServerMessage, error) { - var result []*ServerMessage - select { - case err := <-c.readErrorChan: - return nil, err - case msg := <-c.messageChan: - var m ServerMessage - if err := json.Unmarshal(msg, &m); err != nil { - return nil, err - } - result = append(result, &m) - n := len(c.messageChan) - for i := 0; i < n; i++ { - var m ServerMessage - msg = <-c.messageChan - if err := json.Unmarshal(msg, &m); err != nil { - return nil, err - } - result = append(result, &m) - } - case <-ctx.Done(): - return nil, ctx.Err() - } - return result, nil -} - -func (c *TestClient) RunUntilMessage(ctx context.Context) (message *ServerMessage, err error) { - select { - case err = <-c.readErrorChan: - case msg := <-c.messageChan: - var m ServerMessage - if err = json.Unmarshal(msg, &m); err == nil { - message = &m - } - case <-ctx.Done(): - err = ctx.Err() - } - return -} - -func (c *TestClient) RunUntilHello(ctx context.Context) (message *ServerMessage, err error) { - if message, err = c.RunUntilMessage(ctx); err != nil { - return nil, err - } - if err := checkUnexpectedClose(err); err != nil { - return nil, err - } - if err := checkMessageType(message, "hello"); err != nil { - return nil, err - } - c.publicId = message.Hello.SessionId - return message, nil -} - -func (c *TestClient) JoinRoom(ctx context.Context, roomId string) (message *ServerMessage, err error) { - return c.JoinRoomWithRoomSession(ctx, roomId, roomId+"-"+c.publicId) -} - -func (c *TestClient) JoinRoomWithRoomSession(ctx context.Context, roomId string, roomSessionId string) (message *ServerMessage, err error) { - msg := &ClientMessage{ - Id: "ABCD", - Type: "room", - Room: &RoomClientMessage{ - RoomId: roomId, - SessionId: roomSessionId, - }, - } - if err := c.WriteJSON(msg); err != nil { - return nil, err - } - - if message, err = c.RunUntilMessage(ctx); err != nil { - return nil, err - } - if err := checkUnexpectedClose(err); err != nil { - return nil, err - } - if err := checkMessageType(message, "room"); err != nil { - return nil, err - } - if message.Id != msg.Id { - return nil, fmt.Errorf("expected message id %s, got %s", msg.Id, message.Id) - } - return message, nil -} - -func checkMessageRoomId(message *ServerMessage, roomId string) error { - if err := checkMessageType(message, "room"); err != nil { - return err - } - if message.Room.RoomId != roomId { - return fmt.Errorf("Expected room id %s, got %+v", roomId, message.Room) - } - return nil -} - -func (c *TestClient) RunUntilRoom(ctx context.Context, roomId string) error { - message, err := c.RunUntilMessage(ctx) - if err != nil { - return err - } - if err := checkUnexpectedClose(err); err != nil { - return err - } - return checkMessageRoomId(message, roomId) -} - -func (c *TestClient) checkMessageJoined(message *ServerMessage, hello *HelloServerMessage) error { - return c.checkMessageJoinedSession(message, hello.SessionId, hello.UserId) -} - -func (c *TestClient) checkSingleMessageJoined(message *ServerMessage) error { - if err := checkMessageType(message, "event"); err != nil { - return err - } else if message.Event.Target != "room" { - return fmt.Errorf("Expected event target room, got %+v", message.Event) - } else if message.Event.Type != "join" { - return fmt.Errorf("Expected event type join, got %+v", message.Event) - } else if len(message.Event.Join) != 1 { - return fmt.Errorf("Expected one join event entry, got %+v", message.Event) - } - return nil -} - -func (c *TestClient) checkMessageJoinedSession(message *ServerMessage, sessionId string, userId string) error { - if err := c.checkSingleMessageJoined(message); err != nil { - return err - } - - evt := message.Event.Join[0] - if sessionId != "" && evt.SessionId != sessionId { - return fmt.Errorf("Expected join session id %+v, got %+v", - getPubliceSessionIdData(c.hub, sessionId), getPubliceSessionIdData(c.hub, evt.SessionId)) - } - if evt.UserId != userId { - return fmt.Errorf("Expected join user id %s, got %+v", userId, evt) - } - return nil -} - -func (c *TestClient) RunUntilJoinedAndReturn(ctx context.Context, hello ...*HelloServerMessage) ([]*EventServerMessageSessionEntry, []*ServerMessage, error) { - received := make([]*EventServerMessageSessionEntry, len(hello)) - var ignored []*ServerMessage - hellos := make(map[*HelloServerMessage]int, len(hello)) - for idx, h := range hello { - hellos[h] = idx - } - for len(hellos) > 0 { - message, err := c.RunUntilMessage(ctx) - if err != nil { - return nil, nil, fmt.Errorf("got error while waiting for %+v: %w", hellos, err) - } - - if err := checkMessageType(message, "event"); err != nil { - ignored = append(ignored, message) - continue - } else if message.Event.Target != "room" || message.Event.Type != "join" { - ignored = append(ignored, message) - continue - } - - for len(message.Event.Join) > 0 { - found := false - loop: - for h, idx := range hellos { - for idx2, evt := range message.Event.Join { - if evt.SessionId == h.SessionId && evt.UserId == h.UserId { - received[idx] = evt - delete(hellos, h) - message.Event.Join = append(message.Event.Join[:idx2], message.Event.Join[idx2+1:]...) - found = true - break loop - } - } - } - if !found { - return nil, nil, fmt.Errorf("expected one of the passed hello sessions, got %+v", message.Event.Join[0]) - } - } - } - return received, ignored, nil -} - -func (c *TestClient) RunUntilJoined(ctx context.Context, hello ...*HelloServerMessage) error { - _, unexpected, err := c.RunUntilJoinedAndReturn(ctx, hello...) - if err != nil { - return err - } - if len(unexpected) > 0 { - return fmt.Errorf("Received unexpected messages: %+v", unexpected) - } - return nil -} - -func (c *TestClient) checkMessageRoomLeave(message *ServerMessage, hello *HelloServerMessage) error { - return c.checkMessageRoomLeaveSession(message, hello.SessionId) -} - -func (c *TestClient) checkMessageRoomLeaveSession(message *ServerMessage, sessionId string) error { - if err := checkMessageType(message, "event"); err != nil { - return err - } else if message.Event.Target != "room" { - return fmt.Errorf("Expected event target room, got %+v", message.Event) - } else if message.Event.Type != "leave" { - return fmt.Errorf("Expected event type leave, got %+v", message.Event) - } else if len(message.Event.Leave) != 1 { - return fmt.Errorf("Expected one leave event entry, got %+v", message.Event) - } else if message.Event.Leave[0] != sessionId { - return fmt.Errorf("Expected leave session id %+v, got %+v", - getPubliceSessionIdData(c.hub, sessionId), getPubliceSessionIdData(c.hub, message.Event.Leave[0])) - } - return nil -} - -func (c *TestClient) RunUntilLeft(ctx context.Context, hello *HelloServerMessage) error { - message, err := c.RunUntilMessage(ctx) - if err != nil { - return err - } - - return c.checkMessageRoomLeave(message, hello) -} - -func checkMessageRoomlistUpdate(message *ServerMessage) (*RoomEventServerMessage, error) { - if err := checkMessageType(message, "event"); err != nil { - return nil, err - } else if message.Event.Target != "roomlist" { - return nil, fmt.Errorf("Expected event target room, got %+v", message.Event) - } else if message.Event.Type != "update" || message.Event.Update == nil { - return nil, fmt.Errorf("Expected event type update, got %+v", message.Event) - } else { - return message.Event.Update, nil - } -} - -func (c *TestClient) RunUntilRoomlistUpdate(ctx context.Context) (*RoomEventServerMessage, error) { - message, err := c.RunUntilMessage(ctx) - if err != nil { - return nil, err - } - - return checkMessageRoomlistUpdate(message) -} - -func checkMessageRoomlistDisinvite(message *ServerMessage) (*RoomDisinviteEventServerMessage, error) { - if err := checkMessageType(message, "event"); err != nil { - return nil, err - } else if message.Event.Target != "roomlist" { - return nil, fmt.Errorf("Expected event target room, got %+v", message.Event) - } else if message.Event.Type != "disinvite" || message.Event.Disinvite == nil { - return nil, fmt.Errorf("Expected event type disinvite, got %+v", message.Event) - } - - return message.Event.Disinvite, nil -} - -func (c *TestClient) RunUntilRoomlistDisinvite(ctx context.Context) (*RoomDisinviteEventServerMessage, error) { - message, err := c.RunUntilMessage(ctx) - if err != nil { - return nil, err - } - - return checkMessageRoomlistDisinvite(message) -} - -func checkMessageParticipantsInCall(message *ServerMessage) (*RoomEventServerMessage, error) { - if err := checkMessageType(message, "event"); err != nil { - return nil, err - } else if message.Event.Target != "participants" { - return nil, fmt.Errorf("Expected event target participants, got %+v", message.Event) - } else if message.Event.Type != "update" || message.Event.Update == nil { - return nil, fmt.Errorf("Expected event type update, got %+v", message.Event) - } - - return message.Event.Update, nil -} - -func checkMessageParticipantFlags(message *ServerMessage) (*RoomFlagsServerMessage, error) { - if err := checkMessageType(message, "event"); err != nil { - return nil, err - } else if message.Event.Target != "participants" { - return nil, fmt.Errorf("Expected event target room, got %+v", message.Event) - } else if message.Event.Type != "flags" || message.Event.Flags == nil { - return nil, fmt.Errorf("Expected event type flags, got %+v", message.Event) - } - - return message.Event.Flags, nil -} - -func checkMessageRoomMessage(message *ServerMessage) (*RoomEventMessage, error) { - if err := checkMessageType(message, "event"); err != nil { - return nil, err - } else if message.Event.Target != "room" { - return nil, fmt.Errorf("Expected event target room, got %+v", message.Event) - } else if message.Event.Type != "message" || message.Event.Message == nil { - return nil, fmt.Errorf("Expected event type message, got %+v", message.Event) - } - - return message.Event.Message, nil -} - -func (c *TestClient) RunUntilRoomMessage(ctx context.Context) (*RoomEventMessage, error) { - message, err := c.RunUntilMessage(ctx) - if err != nil { - return nil, err - } - - return checkMessageRoomMessage(message) -} - -func checkMessageError(message *ServerMessage, msgid string) error { - if err := checkMessageType(message, "error"); err != nil { - return err - } else if message.Error.Code != msgid { - return fmt.Errorf("Expected error \"%s\", got \"%s\" (%+v)", msgid, message.Error.Code, message.Error) - } - - return nil -} - -func (c *TestClient) RunUntilOffer(ctx context.Context, offer string) error { - message, err := c.RunUntilMessage(ctx) - if err != nil { - return err - } - if err := checkUnexpectedClose(err); err != nil { - return err - } else if err := checkMessageType(message, "message"); err != nil { - return err - } - - var data map[string]interface{} - if err := json.Unmarshal(message.Message.Data, &data); err != nil { - return err - } - - if data["type"].(string) != "offer" { - return fmt.Errorf("expected data type offer, got %+v", data) - } - - payload := data["payload"].(map[string]interface{}) - if payload["type"].(string) != "offer" { - return fmt.Errorf("expected payload type offer, got %+v", payload) - } - if payload["sdp"].(string) != offer { - return fmt.Errorf("expected payload answer %s, got %+v", offer, payload) - } - - return nil -} - -func (c *TestClient) RunUntilAnswer(ctx context.Context, answer string) error { - return c.RunUntilAnswerFromSender(ctx, answer, nil) -} - -func (c *TestClient) RunUntilAnswerFromSender(ctx context.Context, answer string, sender *MessageServerMessageSender) error { - message, err := c.RunUntilMessage(ctx) - if err != nil { - return err - } - if err := checkUnexpectedClose(err); err != nil { - return err - } else if err := checkMessageType(message, "message"); err != nil { - return err - } - - if sender != nil { - if err := checkMessageSender(c.hub, message.Message.Sender, sender.Type, &HelloServerMessage{ - SessionId: sender.SessionId, - UserId: sender.UserId, - }); err != nil { - return err - } - } - - var data map[string]interface{} - if err := json.Unmarshal(message.Message.Data, &data); err != nil { - return err - } - - if data["type"].(string) != "answer" { - return fmt.Errorf("expected data type answer, got %+v", data) - } - - payload := data["payload"].(map[string]interface{}) - if payload["type"].(string) != "answer" { - return fmt.Errorf("expected payload type answer, got %+v", payload) - } - if payload["sdp"].(string) != answer { - return fmt.Errorf("expected payload answer %s, got %+v", answer, payload) - } - - return nil -} - -func checkMessageTransientSet(message *ServerMessage, key string, value interface{}, oldValue interface{}) error { - if err := checkMessageType(message, "transient"); err != nil { - return err - } else if message.TransientData.Type != "set" { - return fmt.Errorf("Expected transient set, got %+v", message.TransientData) - } else if message.TransientData.Key != key { - return fmt.Errorf("Expected transient set key %s, got %+v", key, message.TransientData) - } else if !reflect.DeepEqual(message.TransientData.Value, value) { - return fmt.Errorf("Expected transient set value %+v, got %+v", value, message.TransientData.Value) - } else if !reflect.DeepEqual(message.TransientData.OldValue, oldValue) { - return fmt.Errorf("Expected transient set old value %+v, got %+v", oldValue, message.TransientData.OldValue) - } - - return nil -} - -func checkMessageTransientRemove(message *ServerMessage, key string, oldValue interface{}) error { - if err := checkMessageType(message, "transient"); err != nil { - return err - } else if message.TransientData.Type != "remove" { - return fmt.Errorf("Expected transient remove, got %+v", message.TransientData) - } else if message.TransientData.Key != key { - return fmt.Errorf("Expected transient remove key %s, got %+v", key, message.TransientData) - } else if !reflect.DeepEqual(message.TransientData.OldValue, oldValue) { - return fmt.Errorf("Expected transient remove old value %+v, got %+v", oldValue, message.TransientData.OldValue) - } - - return nil -} - -func checkMessageTransientInitial(message *ServerMessage, data map[string]interface{}) error { - if err := checkMessageType(message, "transient"); err != nil { - return err - } else if message.TransientData.Type != "initial" { - return fmt.Errorf("Expected transient initial, got %+v", message.TransientData) - } else if !reflect.DeepEqual(message.TransientData.Data, data) { - return fmt.Errorf("Expected transient initial data %+v, got %+v", data, message.TransientData.Data) - } - - return nil -} - -func checkMessageInCallAll(message *ServerMessage, roomId string, inCall int) error { - if err := checkMessageType(message, "event"); err != nil { - return err - } else if message.Event.Type != "update" { - return fmt.Errorf("Expected update event, got %+v", message.Event) - } else if message.Event.Target != "participants" { - return fmt.Errorf("Expected participants update event, got %+v", message.Event) - } else if message.Event.Update.RoomId != roomId { - return fmt.Errorf("Expected participants update event for room %s, got %+v", roomId, message.Event.Update) - } else if !message.Event.Update.All { - return fmt.Errorf("Expected participants update event for all, got %+v", message.Event.Update) - } else if !bytes.Equal(message.Event.Update.InCall, []byte(strconv.FormatInt(int64(inCall), 10))) { - return fmt.Errorf("Expected incall flags %d, got %+v", inCall, message.Event.Update) - } - return nil -} - -func checkMessageSwitchTo(message *ServerMessage, roomId string, details json.RawMessage) (*EventServerMessageSwitchTo, error) { - if err := checkMessageType(message, "event"); err != nil { - return nil, err - } else if message.Event.Type != "switchto" { - return nil, fmt.Errorf("Expected switchto event, got %+v", message.Event) - } else if message.Event.Target != "room" { - return nil, fmt.Errorf("Expected room switchto event, got %+v", message.Event) - } else if message.Event.SwitchTo.RoomId != roomId { - return nil, fmt.Errorf("Expected room switchto event for room %s, got %+v", roomId, message.Event) - } - if details != nil { - if message.Event.SwitchTo.Details == nil || !bytes.Equal(details, message.Event.SwitchTo.Details) { - return nil, fmt.Errorf("Expected details %s, got %+v", string(details), message.Event) - } - } else if message.Event.SwitchTo.Details != nil { - return nil, fmt.Errorf("Expected no details, got %+v", message.Event) - } - return message.Event.SwitchTo, nil -} - -func (c *TestClient) RunUntilSwitchTo(ctx context.Context, roomId string, details json.RawMessage) (*EventServerMessageSwitchTo, error) { - message, err := c.RunUntilMessage(ctx) - if err != nil { - return nil, err - } - - return checkMessageSwitchTo(message, roomId, details) -} diff --git a/throttle_test.go b/throttle_test.go deleted file mode 100644 index a95102a..0000000 --- a/throttle_test.go +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2024 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newMemoryThrottlerForTest(t *testing.T) *memoryThrottler { - t.Helper() - result, err := NewMemoryThrottler() - require.NoError(t, err) - - t.Cleanup(func() { - result.Close() - }) - - return result.(*memoryThrottler) -} - -type throttlerTiming struct { - t *testing.T - - now time.Time - expectedSleep time.Duration -} - -func (t *throttlerTiming) getNow() time.Time { - return t.now -} - -func (t *throttlerTiming) doDelay(ctx context.Context, duration time.Duration) { - t.t.Helper() - assert.Equal(t.t, t.expectedSleep, duration) -} - -func TestThrottler(t *testing.T) { - assert := assert.New(t) - timing := &throttlerTiming{ - t: t, - now: time.Now(), - } - th := newMemoryThrottlerForTest(t) - th.getNow = timing.getNow - th.doDelay = timing.doDelay - - ctx := context.Background() - - throttle1, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle1(ctx) - - timing.now = timing.now.Add(time.Millisecond) - throttle2, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 200 * time.Millisecond - throttle2(ctx) - - timing.now = timing.now.Add(time.Millisecond) - throttle3, err := th.CheckBruteforce(ctx, "192.168.0.2", "action1") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle3(ctx) - - timing.now = timing.now.Add(time.Millisecond) - throttle4, err := th.CheckBruteforce(ctx, "192.168.0.1", "action2") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle4(ctx) -} - -func TestThrottlerIPv6(t *testing.T) { - assert := assert.New(t) - timing := &throttlerTiming{ - t: t, - now: time.Now(), - } - th := newMemoryThrottlerForTest(t) - th.getNow = timing.getNow - th.doDelay = timing.doDelay - - ctx := context.Background() - - // Make sure full /64 subnets are throttled for IPv6. - throttle1, err := th.CheckBruteforce(ctx, "2001:db8:abcd:0012::1", "action1") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle1(ctx) - - timing.now = timing.now.Add(time.Millisecond) - throttle2, err := th.CheckBruteforce(ctx, "2001:db8:abcd:0012::2", "action1") - assert.NoError(err) - timing.expectedSleep = 200 * time.Millisecond - throttle2(ctx) - - // A diffent /64 subnet is not throttled yet. - timing.now = timing.now.Add(time.Millisecond) - throttle3, err := th.CheckBruteforce(ctx, "2001:db8:abcd:0013::1", "action1") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle3(ctx) - - // A different action is not throttled. - timing.now = timing.now.Add(time.Millisecond) - throttle4, err := th.CheckBruteforce(ctx, "2001:db8:abcd:0012::1", "action2") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle4(ctx) -} - -func TestThrottler_Bruteforce(t *testing.T) { - assert := assert.New(t) - timing := &throttlerTiming{ - t: t, - now: time.Now(), - } - th := newMemoryThrottlerForTest(t) - th.getNow = timing.getNow - th.doDelay = timing.doDelay - - ctx := context.Background() - - for i := 0; i < maxBruteforceAttempts; i++ { - timing.now = timing.now.Add(time.Millisecond) - throttle, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - if i == 0 { - timing.expectedSleep = 100 * time.Millisecond - } else { - timing.expectedSleep *= 2 - if timing.expectedSleep > maxThrottleDelay { - timing.expectedSleep = maxThrottleDelay - } - } - throttle(ctx) - } - - timing.now = timing.now.Add(time.Millisecond) - _, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.ErrorIs(err, ErrBruteforceDetected) -} - -func TestThrottler_Cleanup(t *testing.T) { - assert := assert.New(t) - timing := &throttlerTiming{ - t: t, - now: time.Now(), - } - th := newMemoryThrottlerForTest(t) - th.getNow = timing.getNow - th.doDelay = timing.doDelay - - ctx := context.Background() - - throttle1, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle1(ctx) - - throttle2, err := th.CheckBruteforce(ctx, "192.168.0.2", "action1") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle2(ctx) - - timing.now = timing.now.Add(time.Hour) - throttle3, err := th.CheckBruteforce(ctx, "192.168.0.1", "action2") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle3(ctx) - - throttle4, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 200 * time.Millisecond - throttle4(ctx) - - timing.now = timing.now.Add(-time.Hour).Add(maxBruteforceAge).Add(time.Second) - th.cleanup(timing.now) - - assert.Len(th.getEntries("192.168.0.1", "action1"), 1) - assert.Len(th.getEntries("192.168.0.1", "action2"), 1) - - th.mu.RLock() - if _, found := th.clients["192.168.0.2"]; found { - assert.Fail("should have removed client \"192.168.0.2\"") - } - th.mu.RUnlock() - - throttle5, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 200 * time.Millisecond - throttle5(ctx) -} - -func TestThrottler_ExpirePartial(t *testing.T) { - assert := assert.New(t) - timing := &throttlerTiming{ - t: t, - now: time.Now(), - } - th := newMemoryThrottlerForTest(t) - th.getNow = timing.getNow - th.doDelay = timing.doDelay - - ctx := context.Background() - - throttle1, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle1(ctx) - - timing.now = timing.now.Add(time.Minute) - - throttle2, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 200 * time.Millisecond - throttle2(ctx) - - timing.now = timing.now.Add(maxBruteforceAge).Add(-time.Minute + time.Second) - - throttle3, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 200 * time.Millisecond - throttle3(ctx) -} - -func TestThrottler_ExpireAll(t *testing.T) { - assert := assert.New(t) - timing := &throttlerTiming{ - t: t, - now: time.Now(), - } - th := newMemoryThrottlerForTest(t) - th.getNow = timing.getNow - th.doDelay = timing.doDelay - - ctx := context.Background() - - throttle1, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle1(ctx) - - timing.now = timing.now.Add(time.Millisecond) - - throttle2, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 200 * time.Millisecond - throttle2(ctx) - - timing.now = timing.now.Add(maxBruteforceAge).Add(time.Second) - - throttle3, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - assert.NoError(err) - timing.expectedSleep = 100 * time.Millisecond - throttle3(ctx) -} - -func TestThrottler_Negative(t *testing.T) { - assert := assert.New(t) - timing := &throttlerTiming{ - t: t, - now: time.Now(), - } - th := newMemoryThrottlerForTest(t) - th.getNow = timing.getNow - th.doDelay = timing.doDelay - - ctx := context.Background() - - for i := 0; i < maxBruteforceAttempts*10; i++ { - timing.now = timing.now.Add(time.Millisecond) - throttle, err := th.CheckBruteforce(ctx, "192.168.0.1", "action1") - if err != nil { - assert.ErrorIs(err, ErrBruteforceDetected) - } - if i == 0 { - timing.expectedSleep = 100 * time.Millisecond - } else { - timing.expectedSleep *= 2 - if timing.expectedSleep > maxThrottleDelay { - timing.expectedSleep = maxThrottleDelay - } - } - throttle(ctx) - } -} diff --git a/transient_data.go b/transient_data.go deleted file mode 100644 index 120a454..0000000 --- a/transient_data.go +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2021 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "reflect" - "sync" - "time" -) - -type TransientListener interface { - SendMessage(message *ServerMessage) bool -} - -type TransientData struct { - mu sync.Mutex - data map[string]interface{} - listeners map[TransientListener]bool - timers map[string]*time.Timer - ttlCh chan<- struct{} -} - -// NewTransientData creates a new transient data container. -func NewTransientData() *TransientData { - return &TransientData{} -} - -func (t *TransientData) notifySet(key string, prev, value interface{}) { - msg := &ServerMessage{ - Type: "transient", - TransientData: &TransientDataServerMessage{ - Type: "set", - Key: key, - OldValue: prev, - Value: value, - }, - } - for listener := range t.listeners { - listener.SendMessage(msg) - } -} - -func (t *TransientData) notifyDeleted(key string, prev interface{}) { - msg := &ServerMessage{ - Type: "transient", - TransientData: &TransientDataServerMessage{ - Type: "remove", - Key: key, - OldValue: prev, - }, - } - for listener := range t.listeners { - listener.SendMessage(msg) - } -} - -// AddListener adds a new listener to be notified about changes. -func (t *TransientData) AddListener(listener TransientListener) { - t.mu.Lock() - defer t.mu.Unlock() - - if t.listeners == nil { - t.listeners = make(map[TransientListener]bool) - } - t.listeners[listener] = true - if len(t.data) > 0 { - msg := &ServerMessage{ - Type: "transient", - TransientData: &TransientDataServerMessage{ - Type: "initial", - Data: t.data, - }, - } - listener.SendMessage(msg) - } -} - -// RemoveListener removes a previously registered listener. -func (t *TransientData) RemoveListener(listener TransientListener) { - t.mu.Lock() - defer t.mu.Unlock() - - delete(t.listeners, listener) -} - -func (t *TransientData) updateTTL(key string, value interface{}, ttl time.Duration) { - if ttl <= 0 { - delete(t.timers, key) - } else { - t.removeAfterTTL(key, value, ttl) - } -} - -func (t *TransientData) removeAfterTTL(key string, value interface{}, ttl time.Duration) { - if ttl <= 0 { - return - } - - if old, found := t.timers[key]; found { - old.Stop() - } - - timer := time.AfterFunc(ttl, func() { - t.mu.Lock() - defer t.mu.Unlock() - - t.compareAndRemove(key, value) - if t.ttlCh != nil { - select { - case t.ttlCh <- struct{}{}: - default: - } - } - }) - if t.timers == nil { - t.timers = make(map[string]*time.Timer) - } - t.timers[key] = timer -} - -func (t *TransientData) doSet(key string, value interface{}, prev interface{}, ttl time.Duration) { - if t.data == nil { - t.data = make(map[string]interface{}) - } - t.data[key] = value - t.notifySet(key, prev, value) - t.removeAfterTTL(key, value, ttl) -} - -// Set sets a new value for the given key and notifies listeners -// if the value has been changed. -func (t *TransientData) Set(key string, value interface{}) bool { - return t.SetTTL(key, value, 0) -} - -// SetTTL sets a new value for the given key with a time-to-live and notifies -// listeners if the value has been changed. -func (t *TransientData) SetTTL(key string, value interface{}, ttl time.Duration) bool { - if value == nil { - return t.Remove(key) - } - - t.mu.Lock() - defer t.mu.Unlock() - - prev, found := t.data[key] - if found && reflect.DeepEqual(prev, value) { - t.updateTTL(key, value, ttl) - return false - } - - t.doSet(key, value, prev, ttl) - return true -} - -// CompareAndSet sets a new value for the given key only for a given old value -// and notifies listeners if the value has been changed. -func (t *TransientData) CompareAndSet(key string, old, value interface{}) bool { - return t.CompareAndSetTTL(key, old, value, 0) -} - -// CompareAndSetTTL sets a new value for the given key with a time-to-live, -// only for a given old value and notifies listeners if the value has been -// changed. -func (t *TransientData) CompareAndSetTTL(key string, old, value interface{}, ttl time.Duration) bool { - if value == nil { - return t.CompareAndRemove(key, old) - } - - t.mu.Lock() - defer t.mu.Unlock() - - prev, found := t.data[key] - if old != nil && (!found || !reflect.DeepEqual(prev, old)) { - return false - } else if old == nil && found { - return false - } - - t.doSet(key, value, prev, ttl) - return true -} - -func (t *TransientData) doRemove(key string, prev interface{}) { - delete(t.data, key) - if old, found := t.timers[key]; found { - old.Stop() - delete(t.timers, key) - } - t.notifyDeleted(key, prev) -} - -// Remove deletes the value with the given key and notifies listeners -// if the key was removed. -func (t *TransientData) Remove(key string) bool { - t.mu.Lock() - defer t.mu.Unlock() - - prev, found := t.data[key] - if !found { - return false - } - - t.doRemove(key, prev) - return true -} - -// CompareAndRemove deletes the value with the given key if it has a given value -// and notifies listeners if the key was removed. -func (t *TransientData) CompareAndRemove(key string, old interface{}) bool { - t.mu.Lock() - defer t.mu.Unlock() - - return t.compareAndRemove(key, old) -} - -func (t *TransientData) compareAndRemove(key string, old interface{}) bool { - prev, found := t.data[key] - if !found || !reflect.DeepEqual(prev, old) { - return false - } - - t.doRemove(key, prev) - return true -} - -// GetData returns a copy of the internal data. -func (t *TransientData) GetData() map[string]interface{} { - t.mu.Lock() - defer t.mu.Unlock() - - result := make(map[string]interface{}) - for k, v := range t.data { - result[k] = v - } - return result -} diff --git a/transient_data_test.go b/transient_data_test.go deleted file mode 100644 index 66ac3d6..0000000 --- a/transient_data_test.go +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2021 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func (t *TransientData) SetTTLChannel(ch chan<- struct{}) { - t.mu.Lock() - defer t.mu.Unlock() - - t.ttlCh = ch -} - -func Test_TransientData(t *testing.T) { - assert := assert.New(t) - data := NewTransientData() - assert.False(data.Set("foo", nil)) - assert.True(data.Set("foo", "bar")) - assert.False(data.Set("foo", "bar")) - assert.True(data.Set("foo", "baz")) - assert.False(data.CompareAndSet("foo", "bar", "lala")) - assert.True(data.CompareAndSet("foo", "baz", "lala")) - assert.False(data.CompareAndSet("test", nil, nil)) - assert.True(data.CompareAndSet("test", nil, "123")) - assert.False(data.CompareAndSet("test", nil, "456")) - assert.False(data.CompareAndRemove("test", "1234")) - assert.True(data.CompareAndRemove("test", "123")) - assert.False(data.Remove("lala")) - assert.True(data.Remove("foo")) - - ttlCh := make(chan struct{}) - data.SetTTLChannel(ttlCh) - assert.True(data.SetTTL("test", "1234", time.Millisecond)) - assert.Equal("1234", data.GetData()["test"]) - // Data is removed after the TTL - <-ttlCh - assert.Nil(data.GetData()["test"]) - - assert.True(data.SetTTL("test", "1234", time.Millisecond)) - assert.Equal("1234", data.GetData()["test"]) - assert.True(data.SetTTL("test", "2345", 3*time.Millisecond)) - assert.Equal("2345", data.GetData()["test"]) - // Data is removed after the TTL only if the value still matches - time.Sleep(2 * time.Millisecond) - assert.Equal("2345", data.GetData()["test"]) - // Data is removed after the (second) TTL - <-ttlCh - assert.Nil(data.GetData()["test"]) - - // Setting existing key will update the TTL - assert.True(data.SetTTL("test", "1234", time.Millisecond)) - assert.False(data.SetTTL("test", "1234", 3*time.Millisecond)) - // Data still exists after the first TTL - time.Sleep(2 * time.Millisecond) - assert.Equal("1234", data.GetData()["test"]) - // Data is removed after the (updated) TTL - <-ttlCh - assert.Nil(data.GetData()["test"]) -} - -func Test_TransientMessages(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - hub, _, _, server := CreateHubForTest(t) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - client1 := NewTestClient(t, server, hub) - defer client1.CloseWithBye() - require.NoError(client1.SendHello(testDefaultUserId + "1")) - hello1, err := client1.RunUntilHello(ctx) - require.NoError(err) - - require.NoError(client1.SetTransientData("foo", "bar", 0)) - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - require.NoError(checkMessageError(msg, "not_in_room")) - } - - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - hello2, err := client2.RunUntilHello(ctx) - require.NoError(err) - - // Join room by id. - roomId := "test-room" - roomMsg, err := client1.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - // Give message processing some time. - time.Sleep(10 * time.Millisecond) - - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - WaitForUsersJoined(ctx, t, client1, hello1, client2, hello2) - - session1 := hub.GetSessionByPublicId(hello1.Hello.SessionId).(*ClientSession) - require.NotNil(session1, "Session %s does not exist", hello1.Hello.SessionId) - session2 := hub.GetSessionByPublicId(hello2.Hello.SessionId).(*ClientSession) - require.NotNil(session2, "Session %s does not exist", hello2.Hello.SessionId) - - // Client 1 may modify transient data. - session1.SetPermissions([]Permission{PERMISSION_TRANSIENT_DATA}) - // Client 2 may not modify transient data. - session2.SetPermissions([]Permission{}) - - require.NoError(client2.SetTransientData("foo", "bar", 0)) - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - require.NoError(checkMessageError(msg, "not_allowed")) - } - - require.NoError(client1.SetTransientData("foo", "bar", 0)) - - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - require.NoError(checkMessageTransientSet(msg, "foo", "bar", nil)) - } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - require.NoError(checkMessageTransientSet(msg, "foo", "bar", nil)) - } - - require.NoError(client2.RemoveTransientData("foo")) - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - require.NoError(checkMessageError(msg, "not_allowed")) - } - - // Setting the same value is ignored by the server. - require.NoError(client1.SetTransientData("foo", "bar", 0)) - ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel2() - - if msg, err := client1.RunUntilMessage(ctx2); err == nil { - assert.Fail("Expected no payload, got %+v", msg) - } else { - require.ErrorIs(err, context.DeadlineExceeded) - } - - data := map[string]interface{}{ - "hello": "world", - } - require.NoError(client1.SetTransientData("foo", data, 0)) - - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - require.NoError(checkMessageTransientSet(msg, "foo", data, "bar")) - } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - require.NoError(checkMessageTransientSet(msg, "foo", data, "bar")) - } - - require.NoError(client1.RemoveTransientData("foo")) - - if msg, err := client1.RunUntilMessage(ctx); assert.NoError(err) { - require.NoError(checkMessageTransientRemove(msg, "foo", data)) - } - if msg, err := client2.RunUntilMessage(ctx); assert.NoError(err) { - require.NoError(checkMessageTransientRemove(msg, "foo", data)) - } - - // Removing a non-existing key is ignored by the server. - require.NoError(client1.RemoveTransientData("foo")) - ctx3, cancel3 := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel3() - - if msg, err := client1.RunUntilMessage(ctx3); err == nil { - assert.Fail("Expected no payload, got %+v", msg) - } else { - require.ErrorIs(err, context.DeadlineExceeded) - } - - require.NoError(client1.SetTransientData("abc", data, 10*time.Millisecond)) - - client3 := NewTestClient(t, server, hub) - defer client3.CloseWithBye() - require.NoError(client3.SendHello(testDefaultUserId + "3")) - hello3, err := client3.RunUntilHello(ctx) - require.NoError(err) - - roomMsg, err = client3.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - _, ignored, err := client3.RunUntilJoinedAndReturn(ctx, hello1.Hello, hello2.Hello, hello3.Hello) - require.NoError(err) - - var msg *ServerMessage - if len(ignored) == 0 { - msg, err = client3.RunUntilMessage(ctx) - require.NoError(err) - } else if len(ignored) == 1 { - msg = ignored[0] - } else { - require.Fail("Received too many messages: %+v", ignored) - } - - require.NoError(checkMessageTransientInitial(msg, map[string]interface{}{ - "abc": data, - })) - - time.Sleep(10 * time.Millisecond) - if msg, err = client3.RunUntilMessage(ctx); assert.NoError(err) { - require.NoError(checkMessageTransientRemove(msg, "abc", data)) - } -} diff --git a/virtualsession_test.go b/virtualsession_test.go deleted file mode 100644 index bc9ca10..0000000 --- a/virtualsession_test.go +++ /dev/null @@ -1,518 +0,0 @@ -/** - * Standalone signaling server for the Nextcloud Spreed app. - * Copyright (C) 2019 struktur AG - * - * @author Joachim Bauch - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signaling - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestVirtualSession(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - hub, _, _, server := CreateHubForTest(t) - - roomId := "the-room-id" - emptyProperties := json.RawMessage("{}") - backend := &Backend{ - id: "compat", - compat: true, - } - room, err := hub.createRoom(roomId, emptyProperties, backend) - require.NoError(err) - defer room.Close() - - clientInternal := NewTestClient(t, server, hub) - defer clientInternal.CloseWithBye() - require.NoError(clientInternal.SendHelloInternal()) - - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - require.NoError(client.SendHello(testDefaultUserId)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - if hello, err := clientInternal.RunUntilHello(ctx); assert.NoError(err) { - assert.Empty(hello.Hello.UserId) - assert.NotEmpty(hello.Hello.SessionId) - assert.NotEmpty(hello.Hello.ResumeId) - } - hello, err := client.RunUntilHello(ctx) - assert.NoError(err) - - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - // Ignore "join" events. - assert.NoError(client.DrainMessages(ctx)) - - internalSessionId := "session1" - userId := "user1" - msgAdd := &ClientMessage{ - Type: "internal", - Internal: &InternalClientMessage{ - Type: "addsession", - AddSession: &AddSessionInternalClientMessage{ - CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ - SessionId: internalSessionId, - RoomId: roomId, - }, - UserId: userId, - Flags: FLAG_MUTED_SPEAKING, - }, - }, - } - require.NoError(clientInternal.WriteJSON(msgAdd)) - - msg1, err := client.RunUntilMessage(ctx) - require.NoError(err) - // The public session id will be generated by the server, so don't check for it. - require.NoError(client.checkMessageJoinedSession(msg1, "", userId)) - sessionId := msg1.Event.Join[0].SessionId - session := hub.GetSessionByPublicId(sessionId) - if assert.NotNil(session, "Could not get virtual session %s", sessionId) { - assert.Equal(HelloClientTypeVirtual, session.ClientType()) - sid := session.(*VirtualSession).SessionId() - assert.Equal(internalSessionId, sid) - } - - // Also a participants update event will be triggered for the virtual user. - msg2, err := client.RunUntilMessage(ctx) - require.NoError(err) - if updateMsg, err := checkMessageParticipantsInCall(msg2); assert.NoError(err) { - assert.Equal(roomId, updateMsg.RoomId) - if assert.Len(updateMsg.Users, 1) { - assert.Equal(sessionId, updateMsg.Users[0]["sessionId"]) - assert.Equal(true, updateMsg.Users[0]["virtual"]) - assert.EqualValues((FlagInCall | FlagWithPhone), updateMsg.Users[0]["inCall"]) - } - } - - msg3, err := client.RunUntilMessage(ctx) - require.NoError(err) - - if flagsMsg, err := checkMessageParticipantFlags(msg3); assert.NoError(err) { - assert.Equal(roomId, flagsMsg.RoomId) - assert.Equal(sessionId, flagsMsg.SessionId) - assert.EqualValues(FLAG_MUTED_SPEAKING, flagsMsg.Flags) - } - - newFlags := uint32(FLAG_TALKING) - msgFlags := &ClientMessage{ - Type: "internal", - Internal: &InternalClientMessage{ - Type: "updatesession", - UpdateSession: &UpdateSessionInternalClientMessage{ - CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ - SessionId: internalSessionId, - RoomId: roomId, - }, - Flags: &newFlags, - }, - }, - } - require.NoError(clientInternal.WriteJSON(msgFlags)) - - msg4, err := client.RunUntilMessage(ctx) - require.NoError(err) - - if flagsMsg, err := checkMessageParticipantFlags(msg4); assert.NoError(err) { - assert.Equal(roomId, flagsMsg.RoomId) - assert.Equal(sessionId, flagsMsg.SessionId) - assert.EqualValues(newFlags, flagsMsg.Flags) - } - - // A new client will receive the initial flags of the virtual session. - client2 := NewTestClient(t, server, hub) - defer client2.CloseWithBye() - require.NoError(client2.SendHello(testDefaultUserId + "2")) - - _, err = client2.RunUntilHello(ctx) - require.NoError(err) - - roomMsg, err = client2.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - gotFlags := false - var receivedMessages []*ServerMessage - for !gotFlags { - messages, err := client2.GetPendingMessages(ctx) - if err != nil { - assert.NoError(err) - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - break - } - } - - receivedMessages = append(receivedMessages, messages...) - for _, msg := range messages { - if msg.Type != "event" || msg.Event.Target != "participants" || msg.Event.Type != "flags" { - continue - } - - if assert.Equal(roomId, msg.Event.Flags.RoomId) && - assert.Equal(sessionId, msg.Event.Flags.SessionId) && - assert.EqualValues(newFlags, msg.Event.Flags.Flags) { - gotFlags = true - break - } - } - } - assert.True(gotFlags, "Didn't receive initial flags in %+v", receivedMessages) - - // Ignore "join" messages from second client - assert.NoError(client.DrainMessages(ctx)) - - // When sending to a virtual session, the message is sent to the actual - // client and contains a "Recipient" block with the internal session id. - recipient := MessageClientMessageRecipient{ - Type: "session", - SessionId: sessionId, - } - - data := "from-client-to-virtual" - require.NoError(client.SendMessage(recipient, data)) - - msg2, err = clientInternal.RunUntilMessage(ctx) - require.NoError(err) - require.NoError(checkMessageType(msg2, "message")) - require.NoError(checkMessageSender(hub, msg2.Message.Sender, "session", hello.Hello)) - - if assert.NotNil(msg2.Message.Recipient) { - assert.Equal("session", msg2.Message.Recipient.Type) - assert.Equal(internalSessionId, msg2.Message.Recipient.SessionId) - } - - var payload string - if err := json.Unmarshal(msg2.Message.Data, &payload); assert.NoError(err) { - assert.Equal(data, payload) - } - - msgRemove := &ClientMessage{ - Type: "internal", - Internal: &InternalClientMessage{ - Type: "removesession", - RemoveSession: &RemoveSessionInternalClientMessage{ - CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ - SessionId: internalSessionId, - RoomId: roomId, - }, - }, - }, - } - require.NoError(clientInternal.WriteJSON(msgRemove)) - - if msg5, err := client.RunUntilMessage(ctx); assert.NoError(err) { - assert.NoError(client.checkMessageRoomLeaveSession(msg5, sessionId)) - } -} - -func checkHasEntryWithInCall(message *RoomEventServerMessage, sessionId string, entryType string, inCall int) error { - found := false - for _, entry := range message.Users { - if sid, ok := entry["sessionId"].(string); ok && sid == sessionId { - if value, ok := entry[entryType].(bool); !ok || !value { - return fmt.Errorf("Expected %s user, got %+v", entryType, entry) - } - - if value, ok := entry["inCall"].(float64); !ok || int(value) != inCall { - return fmt.Errorf("Expected in call %d, got %+v", inCall, entry) - } - found = true - break - } - } - - if !found { - return fmt.Errorf("No user with session id %s found, got %+v", sessionId, message) - } - - return nil -} - -func TestVirtualSessionCustomInCall(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - hub, _, _, server := CreateHubForTest(t) - - roomId := "the-room-id" - emptyProperties := json.RawMessage("{}") - backend := &Backend{ - id: "compat", - compat: true, - } - room, err := hub.createRoom(roomId, emptyProperties, backend) - require.NoError(err) - defer room.Close() - - clientInternal := NewTestClient(t, server, hub) - defer clientInternal.CloseWithBye() - features := []string{ - ClientFeatureInternalInCall, - } - require.NoError(clientInternal.SendHelloInternalWithFeatures(features)) - - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - require.NoError(client.SendHello(testDefaultUserId)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - helloInternal, err := clientInternal.RunUntilHello(ctx) - if assert.NoError(err) { - assert.Empty(helloInternal.Hello.UserId) - assert.NotEmpty(helloInternal.Hello.SessionId) - assert.NotEmpty(helloInternal.Hello.ResumeId) - } - roomMsg, err := clientInternal.JoinRoomWithRoomSession(ctx, roomId, "") - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - hello, err := client.RunUntilHello(ctx) - assert.NoError(err) - roomMsg, err = client.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - if _, additional, err := clientInternal.RunUntilJoinedAndReturn(ctx, helloInternal.Hello, hello.Hello); assert.NoError(err) { - if assert.Len(additional, 1) && assert.Equal("event", additional[0].Type) { - assert.Equal("participants", additional[0].Event.Target) - assert.Equal("update", additional[0].Event.Type) - assert.Equal(helloInternal.Hello.SessionId, additional[0].Event.Update.Users[0]["sessionId"]) - assert.EqualValues(0, additional[0].Event.Update.Users[0]["inCall"]) - } - } - assert.NoError(client.RunUntilJoined(ctx, helloInternal.Hello, hello.Hello)) - - internalSessionId := "session1" - userId := "user1" - msgAdd := &ClientMessage{ - Type: "internal", - Internal: &InternalClientMessage{ - Type: "addsession", - AddSession: &AddSessionInternalClientMessage{ - CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ - SessionId: internalSessionId, - RoomId: roomId, - }, - UserId: userId, - Flags: FLAG_MUTED_SPEAKING, - }, - }, - } - require.NoError(clientInternal.WriteJSON(msgAdd)) - - msg1, err := client.RunUntilMessage(ctx) - require.NoError(err) - // The public session id will be generated by the server, so don't check for it. - require.NoError(client.checkMessageJoinedSession(msg1, "", userId)) - sessionId := msg1.Event.Join[0].SessionId - session := hub.GetSessionByPublicId(sessionId) - if assert.NotNil(session) { - assert.Equal(HelloClientTypeVirtual, session.ClientType()) - sid := session.(*VirtualSession).SessionId() - assert.Equal(internalSessionId, sid) - } - - // Also a participants update event will be triggered for the virtual user. - msg2, err := client.RunUntilMessage(ctx) - require.NoError(err) - if updateMsg, err := checkMessageParticipantsInCall(msg2); assert.NoError(err) { - assert.Equal(roomId, updateMsg.RoomId) - assert.Len(updateMsg.Users, 2) - - assert.NoError(checkHasEntryWithInCall(updateMsg, sessionId, "virtual", 0)) - assert.NoError(checkHasEntryWithInCall(updateMsg, helloInternal.Hello.SessionId, "internal", 0)) - } - - msg3, err := client.RunUntilMessage(ctx) - require.NoError(err) - - if flagsMsg, err := checkMessageParticipantFlags(msg3); assert.NoError(err) { - assert.Equal(roomId, flagsMsg.RoomId) - assert.Equal(sessionId, flagsMsg.SessionId) - assert.EqualValues(FLAG_MUTED_SPEAKING, flagsMsg.Flags) - } - - // The internal session can change its "inCall" flags - msgInCall := &ClientMessage{ - Type: "internal", - Internal: &InternalClientMessage{ - Type: "incall", - InCall: &InCallInternalClientMessage{ - InCall: FlagInCall | FlagWithAudio, - }, - }, - } - require.NoError(clientInternal.WriteJSON(msgInCall)) - - msg4, err := client.RunUntilMessage(ctx) - require.NoError(err) - if updateMsg, err := checkMessageParticipantsInCall(msg4); assert.NoError(err) { - assert.Equal(roomId, updateMsg.RoomId) - assert.Len(updateMsg.Users, 2) - assert.NoError(checkHasEntryWithInCall(updateMsg, sessionId, "virtual", 0)) - assert.NoError(checkHasEntryWithInCall(updateMsg, helloInternal.Hello.SessionId, "internal", FlagInCall|FlagWithAudio)) - } - - // The internal session can change the "inCall" flags of a virtual session - newInCall := FlagInCall | FlagWithPhone - msgInCall2 := &ClientMessage{ - Type: "internal", - Internal: &InternalClientMessage{ - Type: "updatesession", - UpdateSession: &UpdateSessionInternalClientMessage{ - CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ - SessionId: internalSessionId, - RoomId: roomId, - }, - InCall: &newInCall, - }, - }, - } - require.NoError(clientInternal.WriteJSON(msgInCall2)) - - msg5, err := client.RunUntilMessage(ctx) - require.NoError(err) - if updateMsg, err := checkMessageParticipantsInCall(msg5); assert.NoError(err) { - assert.Equal(roomId, updateMsg.RoomId) - assert.Len(updateMsg.Users, 2) - assert.NoError(checkHasEntryWithInCall(updateMsg, sessionId, "virtual", newInCall)) - assert.NoError(checkHasEntryWithInCall(updateMsg, helloInternal.Hello.SessionId, "internal", FlagInCall|FlagWithAudio)) - } -} - -func TestVirtualSessionCleanup(t *testing.T) { - t.Parallel() - CatchLogForTest(t) - require := require.New(t) - assert := assert.New(t) - hub, _, _, server := CreateHubForTest(t) - - roomId := "the-room-id" - emptyProperties := json.RawMessage("{}") - backend := &Backend{ - id: "compat", - compat: true, - } - room, err := hub.createRoom(roomId, emptyProperties, backend) - require.NoError(err) - defer room.Close() - - clientInternal := NewTestClient(t, server, hub) - defer clientInternal.CloseWithBye() - require.NoError(clientInternal.SendHelloInternal()) - - client := NewTestClient(t, server, hub) - defer client.CloseWithBye() - require.NoError(client.SendHello(testDefaultUserId)) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - if hello, err := clientInternal.RunUntilHello(ctx); assert.NoError(err) { - assert.Empty(hello.Hello.UserId) - assert.NotEmpty(hello.Hello.SessionId) - assert.NotEmpty(hello.Hello.ResumeId) - } - _, err = client.RunUntilHello(ctx) - assert.NoError(err) - - roomMsg, err := client.JoinRoom(ctx, roomId) - require.NoError(err) - require.Equal(roomId, roomMsg.Room.RoomId) - - // Ignore "join" events. - assert.NoError(client.DrainMessages(ctx)) - - internalSessionId := "session1" - userId := "user1" - msgAdd := &ClientMessage{ - Type: "internal", - Internal: &InternalClientMessage{ - Type: "addsession", - AddSession: &AddSessionInternalClientMessage{ - CommonSessionInternalClientMessage: CommonSessionInternalClientMessage{ - SessionId: internalSessionId, - RoomId: roomId, - }, - UserId: userId, - Flags: FLAG_MUTED_SPEAKING, - }, - }, - } - require.NoError(clientInternal.WriteJSON(msgAdd)) - - msg1, err := client.RunUntilMessage(ctx) - require.NoError(err) - // The public session id will be generated by the server, so don't check for it. - require.NoError(client.checkMessageJoinedSession(msg1, "", userId)) - sessionId := msg1.Event.Join[0].SessionId - session := hub.GetSessionByPublicId(sessionId) - if assert.NotNil(session) { - assert.Equal(HelloClientTypeVirtual, session.ClientType()) - sid := session.(*VirtualSession).SessionId() - assert.Equal(internalSessionId, sid) - } - - // Also a participants update event will be triggered for the virtual user. - msg2, err := client.RunUntilMessage(ctx) - require.NoError(err) - if updateMsg, err := checkMessageParticipantsInCall(msg2); assert.NoError(err) { - assert.Equal(roomId, updateMsg.RoomId) - if assert.Len(updateMsg.Users, 1) { - assert.Equal(sessionId, updateMsg.Users[0]["sessionId"]) - assert.Equal(true, updateMsg.Users[0]["virtual"]) - assert.EqualValues((FlagInCall | FlagWithPhone), updateMsg.Users[0]["inCall"]) - } - } - - msg3, err := client.RunUntilMessage(ctx) - require.NoError(err) - - if flagsMsg, err := checkMessageParticipantFlags(msg3); assert.NoError(err) { - assert.Equal(roomId, flagsMsg.RoomId) - assert.Equal(sessionId, flagsMsg.SessionId) - assert.EqualValues(FLAG_MUTED_SPEAKING, flagsMsg.Flags) - } - - // The virtual sessions are closed when the parent session is deleted. - clientInternal.CloseWithBye() - - msg2, err = client.RunUntilMessage(ctx) - require.NoError(err) - assert.NoError(client.checkMessageRoomLeaveSession(msg2, sessionId)) -}