commit fcbc274d01ad2152d7d2c56f80bcb86ec40eed07 Author: upstream-mirror Date: Tue Sep 15 16:13:53 2026 +0500 snapshot of https://github.com/jitsi/jicofo.git @ 9ab59aa3d348e282da89da2c9f11ecbecd3f7b79 (2026-09-15) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1c7ee49 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +[*.{kt,kts}] +# Keep constructor parameters wrapped one per line, and kotest specs unindented. +ktlint_standard_class-signature = disabled +max_line_length=120 +ktlint_code_style = intellij_idea + +# I find trailing commas annoying +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..488f808 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.java text eol=lf diff=java diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..80cfdc6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,27 @@ +--- +name: Bug Report +about: Before posting, please make sure you check https://community.jitsi.org + +--- + +*This Issue tracker is only for reporting bugs and tracking code related issues.* + +Before posting, please make sure you check community.jitsi.org to see if the same or similar bugs have already been discussed. General questions, installation help, and feature requests can also be posted to community.jitsi.org. + +## Description +--- + +## Current behavior +--- + +## Expected Behavior +--- + +## Possible Solution +--- + +## Steps to reproduce +--- + +# Environment details +--- diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..2afcf6d --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,43 @@ +# This workflow will build a Java project with Maven +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven + +name: Java CI with Maven + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +env: + # Java version to use for the release + RELEASE_JAVA_VERSION: 17 + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + java: [ 17, 21 ] + + name: Java ${{ matrix.java }} + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + + - name: Build and test with Maven + run: mvn verify -B -Pcoverage + + - name: Upload coverage report + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d852d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.class +/classes/ +/dist/ +*.jar +/junit-reports/ +*.swp +/target/ +jicofo-common/target/ +jicofo-selector/target/ +jicofo/target/ +.classpath +.project +.settings/ +**/.idea/ +*.iml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..643bcf7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,159 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Jicofo (JItsi COnference FOcus) is a signaling server for Jitsi Meet conferences. It manages XMPP Multi-User Chat (MUC) rooms, initiates Jingle sessions with participants, and coordinates media routing through Jitsi Videobridge instances using the Colibri2 protocol. + +## Build and Test Commands + +### Building +```bash +mvn install # Build all modules and create distribution package +mvn clean install # Clean build +mvn install -DskipTests # Build without running tests +``` + +The distribution package is created in `jicofo/target/jicofo-1.1-SNAPSHOT-archive.zip`. + +### Testing +```bash +mvn test # Run all tests +mvn test -pl jicofo-selector # Run tests for specific module +mvn test -Dtest=BridgeSelectorTest # Run specific test class +``` + +### Code Quality +```bash +mvn exec:exec@ktlint-check # Run ktlint (must be run for individual modules e.g. in ./jicofo-selector/; sibling modules must + # be resolvable, so run "mvn install -DskipTests" from the root first if they are not) +mvn exec:exec@ktlint-format # Auto-format Kotlin code with ktlint (same restriction) +mvn checkstyle:check # Run checkstyle (config: checkstyle.xml) +``` + +## Architecture + +### Module Structure + +This is a multi-module Maven project with three modules: + +1. **jicofo-common** - Shared utilities and data structures + - Conference source management (`ConferenceSourceMap`, `EndpointSourceSet`, `Source`) + - XMPP/MUC abstractions (`ChatRoom`, `ChatRoomMember`, `BaseBrewery`) + - Codec management + - Metrics infrastructure + - No dependencies on other jicofo modules + +2. **jicofo-selector** - Bridge selection and Colibri session management + - Self-contained, reusable component for bridge selection logic + - Bridge management (`Bridge`, `BridgeSelector`, `BridgeMucDetector`) + - Pluggable selection strategies: + - `SingleBridgeSelectionStrategy` - Single bridge, no Octo + - `RegionBasedBridgeSelectionStrategy` - Geographic distribution with Octo + - `IntraRegionBridgeSelectionStrategy` - Load-balanced Octo within regions + - `VisitorSelectionStrategy` - Separate strategies for visitors vs participants + - Colibri2 session management (`ColibriV2SessionManager`, `Colibri2Session`) + - Depends only on jicofo-common + +3. **jicofo** - Main application module + - Entry point: `Main.java` + - Service orchestration: `JicofoServices.kt` (creates and manages all services) + - Conference management: `JitsiMeetConferenceImpl` (Java, ~2000+ lines, core conference logic) + - Focus management: `FocusManager` (conference lifecycle, room JID mapping) + - XMPP services: `XmppServices`, `XmppProvider`, modular IQ handlers + - Authentication: `AbstractAuthAuthority`, `XMPPDomainAuthAuthority`, `ExternalJWTAuthority` + - External service integration: Jibri (recording/streaming), Jigasi (SIP gateway) + - REST API: Ktor-based HTTP interface for health checks, stats, debug endpoints + - Depends on both jicofo-common and jicofo-selector + +### Key Components & Responsibilities + +**Conference Management** +- `JitsiMeetConferenceImpl` - Main conference implementation (manages Jingle sessions, coordinates with bridges, handles MUC membership, lifecycle management) +- `Participant` - Represents a conference participant with Jingle session, sources, and bridge allocation +- `ParticipantInviteRunnable` - Handles participant invitation flow (Jingle session establishment) +- `FocusManager` - Conference lifecycle management, maps room JIDs to conference instances + +**Bridge Selection** +- `BridgeSelector` - Core component for selecting optimal bridges based on health, stress, operational status, and configured strategy +- `Bridge` - Represents a single jitsi-videobridge instance with health and stress tracking +- `BridgeMucDetector` - Discovers bridges via XMPP MUC (brewery pattern) +- `JvbDoctor` - Health check management for bridges + +**Colibri Session Management** +- `ColibriV2SessionManager` - Manages Colibri2 sessions with bridges +- `Colibri2Session` - Represents a session with a single bridge +- Sends `ConferenceModifyIQ` to bridges for allocations and updates + +**XMPP Handling** +- `XmppProvider` - Manages multiple XMPP connections (client, service, visitor) +- Modular IQ handlers: `ConferenceIqHandler`, `JingleIqRequestHandler`, `JibriIqHandler`, etc. +- `VisitorsManager` - Manages visitor functionality across multiple XMPP nodes + +### Application Flow + +1. **Startup**: `Main.java` loads config, creates `JicofoServices`, sets up signal handlers +2. **Conference Creation**: Client requests conference → `FocusManager.conferenceRequest()` → `JitsiMeetConferenceImpl.start()` +3. **Participant Join**: Participant joins MUC → conference detects new member → `ParticipantInviteRunnable` selects bridge, allocates Colibri endpoint, sends Jingle session-initiate +4. **Bridge Selection**: Considers operational status, stress level, graceful shutdown status, version compatibility, and selection strategy +5. **Media Flow**: Participant accepts Jingle session, media flows through videobridge + +### Configuration + +Configuration uses HOCON format (lightbend/config). Main config file is typically `/etc/jitsi/jicofo/jicofo.conf`. + +Reference configuration with all available options: `jicofo-selector/src/main/resources/reference.conf` + +Configuration objects in code are typically Kotlin companion objects (e.g., `BridgeConfig.config`, `ConferenceConfig.config`). + +### Language & Conventions + +- **Kotlin**: New code, utilities, data structures, all test code +- **Java**: Legacy code, notably `JitsiMeetConferenceImpl` (complex conference management) +- **Interop**: Seamless with `@JvmOverloads`, `@JvmField`, companion objects + +### Testing Framework + +- **Primary**: Kotest (BDD-style specs with `ShouldSpec`) +- **Style**: `context("...") { should("...") { ... } }` for behavior-driven tests +- **Mocking**: MockK for Kotlin (`mockk { every { ... } returns ... }`) +- **Test Utilities**: Mock implementations in `jicofo/src/test/kotlin/org/jitsi/jicofo/mock/` + - `MockXmppConnection`, `MockXmppProvider` - XMPP testing + - `MockChatRoom` - MUC simulation + - `TestColibri2Server` - Colibri2 protocol testing +- **Configuration Testing**: Use `withNewConfig { ... }` helper for custom configs +- **Time Control**: `FakeClock` for time-dependent tests +- **Isolation**: Tests use `IsolationMode.InstancePerLeaf` for test isolation +- **Location**: Tests mirror source structure (`src/test/kotlin` matches `src/main/kotlin`) + +### Important Architectural Patterns + +**Separation of Concerns**: Clear module boundaries (common utilities, bridge selection logic, application orchestration) + +**Dependency Injection via Constructor**: Services are composed via constructor injection in `JicofoServices.kt` + +**Strategy Pattern**: Bridge selection strategies and topology strategies are pluggable, configured via HOCON + +**Observer Pattern**: Event-driven architecture with `AsyncEventEmitter` (e.g., `BridgeSelector.EventHandler`, `ColibriSessionManager.Listener`) + +**Thread Safety**: Extensive use of `synchronized` blocks, `ConferenceSourceMap` uses `ConcurrentHashMap`, careful lock ordering to prevent deadlocks + +**Metrics & Observability**: Centralized metrics via `JicofoMetricsContainer`, periodic metric updates, debug state endpoints (`debugState` properties) + +### Key Protocols + +**XMPP**: Communication backbone with multiple connections (Client for participants, Service for bridges, Visitor for visitor nodes) + +**Jingle**: Standard XMPP protocol for peer-to-peer session negotiation (jicofo terminates Jingle signaling, not media) + +**Colibri2**: Modern protocol for bridge communication (endpoint allocation, management, relay support for Octo) + +**Brewery Pattern**: Service discovery - bridges/Jibris/Jigasis join specific MUC rooms, jicofo monitors presence + +### Notable Design Decisions + +- **Graceful Degradation**: Bridges report stress levels, can enter graceful shutdown, health checks with timeout-based recovery +- **Scalability**: Multi-bridge support (Octo mesh), region-based selection, visitor architecture for large conferences, load redistribution +- **Singleton with Synchronization**: `JicofoServices` uses synchronized singleton pattern (required because services start threads that depend on the singleton) +- **Legacy Code Preservation**: `JitsiMeetConferenceImpl` remains in Java with extensive synchronization (noted as needing refactoring, but stable) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..68cf4f9 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Jicofo + +JItsi COnference FOcus is a signaling server, one of the backend components in the [Jitsi Meet] stack. + +[Jitsi Meet]: https://github.com/jitsi/jitsi-meet + +## Overview + +Jitsi Meet conferences are associated with an XMPP +[Multi-User Chat (MUC) room](https://xmpp.org/extensions/xep-0045.html). The MUC functionality is provided by the +XMPP server (prosody). + +Jicofo joins the conference MUC and is then responsible for initiating a +[Jingle](https://xmpp.org/extensions/xep-0166.html) session with each participant (in this sense it is the "focus" of the +conference, which is where its name comes from). While Jicofo manages and terminates Jingle sessions, it does not +process any of the media (audio/video). Instead, it uses one or more +[Jitsi Videobridge](github.com/jitsi/jitsi-videobridge/) instances. + +Jicofo is responsible for selecting a Jitsi Videobridge for each participant, and manages the set of videobridges for +the conference with the COLIBRI protocol (colibri version 2 is now used, the format in XEP-0340 is now deprecated). + +In general the conference participants and videobridge instances are accessed through different XMPP connections -- +the configured Client and Service connections, respectively, though they may coincide. + +![Connection between Jicofo and the other components in the Jitsi Meet stack.](https://github.com/jitsi/jicofo/blob/master/doc/diagram.png?raw=true) + +# Configuration +Jicofo takes its configuration from a [hocon](https://github.com/lightbend/config/blob/main/HOCON.md) config file, +usually installed in `/etc/jitsi/jicofo/jicofo.conf`. See the +[reference.conf](https://github.com/jitsi/jicofo/blob/master/jicofo-selector/src/main/resources/reference.conf) file +for the available options. + +# Installation +## Debian + +The recommended way to install Jicofo and Jitsi Meet is to follow the +[Quick Install Guide](https://github.com/jitsi/jitsi-meet/blob/master/doc/quick-install.md) for debian-based systems. + +### Binaries + +You can download Debian/Ubuntu binaries here: +* [stable](https://download.jitsi.org/stable/) ([instructions](https://jitsi.org/downloads/ubuntu-debian-installations-instructions/)) +* [testing](https://download.jitsi.org/testing/) ([instructions](https://jitsi.org/downloads/ubuntu-debian-installations-instructions-for-testing/)) +* [nightly](https://download.jitsi.org/unstable/) ([instructions](https://jitsi.org/downloads/ubuntu-debian-installations-instructions-nightly/)) + +## Docker +Jicofo is available as a docker image as part of [docker-jitsi-meet](https://github.com/jitsi/docker-jitsi-meet). + +## Manual +This section is only required for a manual setup, not necessary when using Quick Install or other methods. + +### Prosody configuration + +Jicofo needs privileges to create Multi-User Chat rooms. To grant these privileges we create an account for it and add +it to the global `admins` list. We create a new virtual host, because the one used by clients only supports anonymous +authentication. We add this to Prosody's config file (`/etc/prosody/prosody.cfg.lua` by default): +``` +admins = { focus@auth.jitsi.example.com } +VirtualHost "auth.jitsi.example.com" + authentication = "internal_hashed" +``` +Then restart Prosody and create the user account: +``` +sudo prosodyctl restart +sudo prosodyctl register focus auth.jitsi.example.com focuspassword +``` + +### Building Jicofo +Build using maven with: +```commandline +mvn install +``` + +This will create a package in `jicofo/target/jicofo-1.1-SNAPSHOT-archive.zip` +### Running Jicofo +Extract the distribution package and run with `jicofo.sh`. + +### Certificates +Jicofo uses an XMPP user connection (on port 5222 by default), and since the +upgrade to smack4 it verifies the server's certificate. In a default +installation the debian installation scripts take care of generating a +self-signed certificate and adding it to the keystore. + +For situations in which the certificate is not trusted you can add it to the +store by: + +#### On Linux +``` +sudo cp cert.pem /usr/local/share/ca-certificates/ +sudo update-ca-certificates +``` + +#### On MacOS X +On Mac java uses its own keystore, so adding the certificate to the system one +does not work. Add it to the java keystore with: +``` +sudo keytool -importcert -file cert.pem -keystore /Library/Java//JavaVirtualMachines/jdk1.8.0_25.jdk/Contents/Home/jre/lib/security/cacerts +``` + +Note that if the XMPP server you are connecting to is a prosody instance +configured with the jitsi-meet scripts, then you can find the certificate in: +``` +/var/lib/prosody/$JICOFO_AUTH_DOMAIN.crt +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cf2773d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security + +## Reporting security issues + +We take security very seriously and develop all Jitsi projects to be secure and safe. + +If you find (or simply suspect) a security issue in any of the Jitsi projects, please report it to us via [HackerOne](https://hackerone.com/8x8-bounty) or send us an email to security@jitsi.org. + +**We encourage responsible disclosure for the sake of our users, so please reach out before posting in a public space.** diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..9690b13 --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/debian/README.Debian b/debian/README.Debian new file mode 100644 index 0000000..14e5057 --- /dev/null +++ b/debian/README.Debian @@ -0,0 +1,5 @@ +jicofo for Debian +--------------------- + +If you want a WebRTC videoconferencing solution you have to install jitsi-meet +and jitsi-videobridge packages which uses Jitsi Videobridge as a SFU (Selective Forwarding Unit) diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..ddf95cb --- /dev/null +++ b/debian/changelog @@ -0,0 +1,6 @@ +jicofo (1.0-0-g9fc93ed-1) unstable; urgency=low + + * Initial release. + + -- Damian Minkov Fri, 10 Oct 2017 16:18:00 +0000 + diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..48082f7 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +12 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..262338a --- /dev/null +++ b/debian/control @@ -0,0 +1,14 @@ +Source: jicofo +Section: net +Priority: extra +Maintainer: Jitsi Team +Uploaders: Emil Ivov , Damian Minkov , Pawel Domas +Build-Depends: debhelper, openjdk-17-jdk | openjdk-21-jdk, maven +Standards-Version: 3.9.3 +Homepage: https://jitsi.org/meet + +Package: jicofo +Architecture: all +Depends: ${misc:Depends}, openjdk-17-jre-headless | openjdk-17-jre | openjdk-21-jre-headless | openjdk-21-jre, ruby-hocon, jq +Description: JItsi Meet COnference FOcus + Jicofo is a conference focus for Jitsi Meet application. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..8808ac4 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,13 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: Jitsi Conference Focus +Upstream-Contact: Emil Ivov +Source: https://github.com/jitsi/jicofo + +Files: * +Copyright: 2013-2018 Jitsi +License: Apache 2.0 + +License: Apache 2.0 +On Debian systems, the full text of the Apache licence +version 2.0 can be found in the file +'/usr/share/common-licenses/Apache-2.0'. diff --git a/debian/docs b/debian/docs new file mode 100644 index 0000000..e69de29 diff --git a/debian/init.d b/debian/init.d new file mode 100644 index 0000000..41387ad --- /dev/null +++ b/debian/init.d @@ -0,0 +1,101 @@ +#! /bin/sh +# +# INIT script for Jitsi Conference Focus +# Version: 1.0 4-Dec-2014 pawel.domas@jitsi.org +# +### BEGIN INIT INFO +# Provides: jicofo +# Required-Start: $local_fs $remote_fs +# Required-Stop: $local_fs $remote_fs +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Jitsi conference Focus +# Description: Conference focus for Jitsi Meet application. +### END INIT INFO + +. /lib/lsb/init-functions + +# Include jicofo defaults if available +if [ -f /etc/jitsi/jicofo/config ]; then + . /etc/jitsi/jicofo/config +fi + +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +DAEMON=/usr/share/jicofo/jicofo.sh +DAEMON_DIR=/usr/share/jicofo/ +NAME=jicofo +USER=jicofo +PIDFILE=/var/run/jicofo.pid +LOGFILE=/var/log/jitsi/jicofo.log +DESC=jicofo + + +if [ ! -x $DAEMON ] ;then + echo "Daemon not executable: $DAEMON" + exit 1 +fi + +set -e + +stop() { + if [ -f $PIDFILE ]; then + PID=$(cat $PIDFILE) + fi + echo -n "Stopping $NAME: " + if [ $PID ]; then + kill $PID || true + rm $PIDFILE || true + echo "$NAME stopped." + else + echo "$NAME doesn't seem to be running." + fi +} + +start() { + if [ -f $PIDFILE ]; then + echo "$NAME seems to be already running, we found pidfile $PIDFILE." + exit 1 + fi + echo -n "Starting $NAME: " + export JICOFO_AUTH_PASSWORD JICOFO_MAX_MEMORY + start-stop-daemon --start --quiet --background --chuid $USER --make-pidfile --pidfile $PIDFILE \ + --exec /bin/bash -- -c "cd $DAEMON_DIR; JAVA_SYS_PROPS=\"$JAVA_SYS_PROPS\" exec $DAEMON $JICOFO_OPTS < /dev/null >> $LOGFILE 2>&1" + echo "$NAME started." +} + +reload() { + echo 'Not yet implemented.' +} + +status() { + status_of_proc -p $PIDFILE java "$NAME" && exit 0 || exit $? +} + +case "$1" in + start) + start + ;; + stop) + stop + ;; + restart) + stop + start + ;; + reload) + reload + ;; + force-reload) + reload + ;; + status) + status + ;; + *) + N=/etc/init.d/$NAME + echo "Usage: $N {start|stop|restart|reload|status}" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/debian/install b/debian/install new file mode 100644 index 0000000..e6fd2ca --- /dev/null +++ b/debian/install @@ -0,0 +1,6 @@ +jicofo/target/dependency/* usr/share/jicofo/lib +lib/logging.properties etc/jitsi/jicofo +jicofo.jar usr/share/jicofo +resources/jicofo.sh usr/share/jicofo +resources/collect-dump-logs.sh usr/share/jicofo +jicofo/jicofo etc/logrotate.d/ diff --git a/debian/manpage.1 b/debian/manpage.1 new file mode 100644 index 0000000..a2cb1e6 --- /dev/null +++ b/debian/manpage.1 @@ -0,0 +1,38 @@ +.\" Hey, EMACS: -*- nroff -*- +.\" First parameter, NAME, should be all caps +.\" Second parameter, SECTION, should be 1-8, maybe w/ subsection +.\" other parameters are allowed: see man(7), man(1) +.TH JICOFO SECTION "November 28, 2014" +.\" Please adjust this date whenever revising the manpage. +.\" +.\" Some roff macros, for reference: +.\" .nh disable hyphenation +.\" .hy enable hyphenation +.\" .ad l left justify +.\" .ad b justify to both left and right margins +.\" .nf disable filling +.\" .fi enable filling +.\" .br insert line break +.\" .sp insert n+1 empty lines +.\" for manpage-specific macros, see man(7) +.SH NAME +jicofo \- conference focus for Jitsi Meet application. +.SH SYNOPSIS +.B /usr/share/jicofo/jicofo.sh +.RI [ options ] ... +.br +.B /etc/init.d/jicofo (start|stop|restart) +.SH DESCRIPTION +This manual page documents briefly the +.B jicofo +service. +.PP +.\" TeX users may be more comfortable with the \fB\fP and +.\" \fI\fP escape sequences to invode bold face and italics, +.\" respectively. +\fBjicofo\fP is a conference focus for Jitsi Meet application. +.SH AUTHOR +jicofo was written by Jitsi.org and Bluejimp.com. +.PP +This manual page was written by Pawel Domas , +for the Debian project (and may be used by others). diff --git a/debian/po/POTFILES.in b/debian/po/POTFILES.in new file mode 100644 index 0000000..98e8416 --- /dev/null +++ b/debian/po/POTFILES.in @@ -0,0 +1 @@ +[type: gettext/rfc822deb] templates \ No newline at end of file diff --git a/debian/po/templates.pot b/debian/po/templates.pot new file mode 100644 index 0000000..9aedf0b --- /dev/null +++ b/debian/po/templates.pot @@ -0,0 +1,54 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the jicofo package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: jicofo\n" +"Report-Msgid-Bugs-To: jicofo@packages.debian.org\n" +"POT-Creation-Date: 2016-11-21 19:43+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#. Type: string +#. Description +#: ../templates:1001 +msgid "Hostname:" +msgstr "" + +#. Type: string +#. Description +#: ../templates:1001 +msgid "The jicofo package needs the DNS hostname of your instance." +msgstr "" + +#. Type: string +#. Description +#: ../templates:2001 +msgid "Jicofo username:" +msgstr "" + +#. Type: string +#. Description +#: ../templates:2001 +msgid "The jicofo needs an authenticated admin user to connect to xmpp server." +msgstr "" + +#. Type: password +#. Description +#: ../templates:3001 +msgid "Jicofo user password:" +msgstr "" + +#. Type: password +#. Description +#: ../templates:3001 +msgid "The secret used to connect to xmpp server as jicofo user." +msgstr "" diff --git a/debian/postinst b/debian/postinst new file mode 100644 index 0000000..91c4801 --- /dev/null +++ b/debian/postinst @@ -0,0 +1,167 @@ +#!/bin/bash +# postinst script for jicofo + +set -e + +case "$1" in + configure) + + CONFIG="/etc/jitsi/jicofo/config" + + # we don't want to regenerate config on upgrade + OLDCONFIG="false" + if [ -f $CONFIG ]; then + . $CONFIG + if [ -n "$JICOFO_HOSTNAME" ] \ + && [ -n "$JICOFO_AUTH_DOMAIN" ] && [ -n "$JICOFO_AUTH_USER" ] && [ -n "$JICOFO_AUTH_PASSWORD" ]; then + OLDCONFIG="true" + fi + fi + + # debconf hostname question + . /usr/share/debconf/confmodule + + # try to get host from jitsi-videobridge + db_get jitsi-videobridge/jvb-hostname + if [ -z "$RET" ] ; then + # server hostname + db_set jitsi-videobridge/jvb-hostname "localhost" + db_input critical jitsi-videobridge/jvb-hostname || true + db_go + fi + JVB_HOSTNAME=$(echo "$RET" | xargs echo -n) + + # generate config on new install + if [ "$OLDCONFIG" = "false" ] || [ "$JVB_HOSTNAME" != "$JICOFO_HOSTNAME" ]; then + + JICOFO_HOSTNAME="$JVB_HOSTNAME" + + JICOFO_AUTH_DOMAIN="auth.$JICOFO_HOSTNAME" + JICOFO_AUTH_USER="focus" + db_get jicofo/jicofo-authpassword + if [ -z "$RET" ] ; then + RET=`cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 16` + fi + JICOFO_AUTH_PASSWORD="$RET" + + # This re-creates $CONFIG. If previous values existed, they will be set in jicofo.conf below. + echo '# adds java system props that are passed to jicofo (default are for home and logging config file)' > $CONFIG + echo "JAVA_SYS_PROPS=\"-Dnet.java.sip.communicator.SC_HOME_DIR_LOCATION=/etc/jitsi\ + -Dnet.java.sip.communicator.SC_HOME_DIR_NAME=jicofo\ + -Dnet.java.sip.communicator.SC_LOG_DIR_LOCATION=/var/log/jitsi\ + -Djava.util.logging.config.file=/etc/jitsi/jicofo/logging.properties\"" >> $CONFIG + + fi + + # Store values, so we can use it if we are configuring other packages on the same machine + # we are currently doing this on every upgrade in order to be able to successfully upgrade + # from old packages, otherwise we can do it only on new configuration or reconfiguration + db_set jicofo/jicofo-authpassword "$JICOFO_AUTH_PASSWORD" + + # and we're done with debconf + db_stop + + OLD_JITSI_CONFIG="/etc/jitsi/jicofo/sip-communicator.properties" + HOCON_CONFIG="/etc/jitsi/jicofo/jicofo.conf" + + if [ ! -f $HOCON_CONFIG ]; then + echo "Generating an empty jicofo.conf file" + echo "# Jicofo HOCON configuration. See reference.conf in /usr/share/jicofo/jicofo.jar for +#available options, syntax, and default values. +jicofo { +}" >> $HOCON_CONFIG + fi + + # Make sure the client-proxy is set correctly. + hocon -f $HOCON_CONFIG set jicofo.xmpp.client.client-proxy "\"focus.$JVB_HOSTNAME\"" + + # Set the client XMPP config in jicofo.conf. The values were either read from an existing + # /etc/jitsi/jicofo/config or generated above. + if ! hocon -f $HOCON_CONFIG get "jicofo.xmpp.client.xmpp-domain" > /dev/null 2>&1 ;then + hocon -f $HOCON_CONFIG set "jicofo.xmpp.client.xmpp-domain" "\"$JICOFO_HOSTNAME\"" + fi + if ! hocon -f $HOCON_CONFIG get "jicofo.xmpp.client.domain" > /dev/null 2>&1 ;then + hocon -f $HOCON_CONFIG set "jicofo.xmpp.client.domain" "\"$JICOFO_AUTH_DOMAIN\"" + fi + if ! hocon -f $HOCON_CONFIG get "jicofo.xmpp.client.username" > /dev/null 2>&1 ;then + hocon -f $HOCON_CONFIG set "jicofo.xmpp.client.username" "\"$JICOFO_AUTH_USER\"" + fi + if ! hocon -f $HOCON_CONFIG get "jicofo.xmpp.client.password" > /dev/null 2>&1 ;then + hocon -f $HOCON_CONFIG set "jicofo.xmpp.client.password" "\"$JICOFO_AUTH_PASSWORD\"" + fi + + # Add the JvbBrewery config if missing + if ! grep -sq "org.jitsi.jicofo.BRIDGE_MUC" "$OLD_JITSI_CONFIG" ;then + if ! hocon -f $HOCON_CONFIG get "jicofo.bridge.brewery-jid" > /dev/null 2>&1 ; then + hocon -f $HOCON_CONFIG set "jicofo.bridge.brewery-jid" \"JvbBrewery@internal.auth.${JVB_HOSTNAME}\" + fi + fi + + # Mark the jibri domain as trusted unless the trusted-domains have been set. + if [[ $(hocon -j -f $HOCON_CONFIG get "jicofo.xmpp.trusted-domains" 2> /dev/null | jq -r "type") != "array" ]] ;then + hocon -f $HOCON_CONFIG set "jicofo.xmpp.trusted-domains" "[ \"recorder.$JVB_HOSTNAME\" ]" + fi + + # we don't want to start the daemon as root + if ! getent group jitsi > /dev/null ; then + groupadd jitsi + fi + if ! getent passwd jicofo > /dev/null ; then + useradd -r -g jitsi --shell /bin/bash --create-home -d /usr/share/jicofo jicofo + fi + if ! groups jicofo | grep '\bjitsi\b' > /dev/null ; then + usermod -g jitsi jicofo + fi + + mkdir -p /usr/share/jicofo + + # we claim the home folder of jicofo in case it is owned by someone else + OWNER=$(stat -c '%U' /usr/share/jicofo) + GROUP=$(stat -c '%G' /usr/share/jicofo) + if ! dpkg-statoverride --list /usr/share/jicofo/* >/dev/null && [ "$OWNER:$GROUP" != "jicofo:jitsi" ]; then + chown -R jicofo:jitsi /usr/share/jicofo + OWNER=jicofo + GROUP=jitsi + fi + + CONFIG_DIR=$(dirname $CONFIG) + if ! dpkg-statoverride --list "$CONFIG_DIR" >/dev/null; then + chown -R jicofo:jitsi "$CONFIG_DIR" + chmod 750 "$CONFIG_DIR" + fi + + # die logz + if [ ! -d /var/log/jitsi ]; then + mkdir -p /var/log/jitsi + chown $OWNER:$GROUP /var/log/jitsi + chmod 770 /var/log/jitsi + fi + ls /var/log/jitsi/jicofo* 1>/dev/null 2>&1 && chown -f -R $OWNER:$GROUP /var/log/jitsi/jicofo* + ls /var/log/jitsi/jicofo* 1>/dev/null 2>&1 && chmod -f -R 640 /var/log/jitsi/jicofo* + + # ensure focus is not running - it will be started at the end + if invoke-rc.d jicofo status >/dev/null 2>&1 ; then + invoke-rc.d jicofo stop || true + fi + + # clean up old jicofo group + if getent group jicofo > /dev/null; then + groupdel jicofo + fi + ;; + + abort-upgrade|abort-remove|abort-deconfigure) + ;; + + *) + echo "postinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +# dh_installdeb will replace this with shell code automatically +# generated by other debhelper scripts. + +#DEBHELPER# + +exit 0 diff --git a/debian/postrm b/debian/postrm new file mode 100644 index 0000000..0f63fb6 --- /dev/null +++ b/debian/postrm @@ -0,0 +1,57 @@ +#!/bin/bash +# postrm script for jicofo + +set -e + +# Load debconf +. /usr/share/debconf/confmodule + + +case "$1" in + purge) + + # Clear the debconf variable + db_purge + + # clear user and group + # ensure nothing is running as jicofo user + if ps -u jicofo h; then + `ps -u jicofo -o pid h | xargs kill` + fi + # and then remove the user + if getent passwd jicofo > /dev/null ; then + deluser jicofo + fi + + # clear logs + if [ -f "/var/log/jitsi/jicofo.log" ]; then + rm -f "/var/log/jitsi/jicofo.log" + fi + + rm -rf /usr/share/jicofo/ + rm -f /etc/jitsi/jicofo/config + rm -f /etc/jitsi/jicofo/sip-communicator.properties + rm -f /etc/jitsi/jicofo/logging.properties + rm -f /etc/jitsi/jicofo/jicofo.conf + ;; + + remove) + ;; + + upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) + ;; + + *) + echo "postrm called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +# dh_installdeb will replace this with shell code automatically +# generated by other debhelper scripts. + +#DEBHELPER# + +db_stop + +exit 0 diff --git a/debian/preinst b/debian/preinst new file mode 100644 index 0000000..4074d51 --- /dev/null +++ b/debian/preinst @@ -0,0 +1,14 @@ +#!/bin/bash +# preinst script for jicofo + +set -e +echo "Running preinst $@" + +if [ "$1" == "upgrade" ] ;then + if [ -d /etc/logrotate.d/jicofo ] ;then + # An earlier version of the package incorrectly installed a directory in logrotate.d. If that's the case + # remove it, so the file can be properly installed. + echo "/etc/logrotate.d/jicofo is a directory, removing." + rm -rf /etc/logrotate.d/jicofo + fi +fi diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..f328a46 --- /dev/null +++ b/debian/rules @@ -0,0 +1,30 @@ +#!/usr/bin/make -f + +# Uncomment this to turn on verbose mode. +#export DH_VERBOSE=1 +#export DEB_VERBOSE_ALL=true + +%: + dh $@ + +override_dh_auto_clean: + mvn -Dmaven.repo.local=${HOME}/.m2/repository clean + +override_dh_auto_configure: + # do nothing + +override_dh_auto_test: + # do nothing + +override_dh_auto_build: + mvn -Dmaven.repo.local=${HOME}/.m2/repository -DskipTests -Dassembly.skipAssembly=true package + mvn -Dmaven.repo.local=${HOME}/.m2/repository dependency:copy-dependencies -DincludeScope=runtime + +override_dh_install: + mkdir -p debian/tmp/jicofo + cp resources/config/jicofo-logrotate.d debian/tmp/jicofo/jicofo + cp jicofo/target/jicofo*.jar debian/tmp/jicofo.jar + dh_install + +override_dh_auto_install: + # do nothing diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..c3d9f24 --- /dev/null +++ b/debian/source/format @@ -0,0 +1,2 @@ +3.0 (quilt) + diff --git a/debian/templates b/debian/templates new file mode 100644 index 0000000..dc46006 --- /dev/null +++ b/debian/templates @@ -0,0 +1,9 @@ +Template: jitsi-videobridge/jvb-hostname +Type: string +_Description: Hostname: + The jicofo package needs the DNS hostname of your instance. + +Template: jicofo/jicofo-authpassword +Type: password +_Description: Jicofo user password: + The secret used to connect to xmpp server as jicofo user. diff --git a/doc/conference-request.md b/doc/conference-request.md new file mode 100644 index 0000000..83bc756 --- /dev/null +++ b/doc/conference-request.md @@ -0,0 +1,61 @@ +# +Conference-request refers to an initial exchange between a client and jicofo, which happens before the client joins +the conference MUC. Its purposes are to: +1. Notify jicofo that it should join a certain MUC. +2. Have the client block until jicofo has created/joined the MUC (in case the client is not allowed to create a MUC). +3. Allow the first client in a conference to specify certain conference-wide options to jicofo. +4. Allow jicofo to redirect the client to a visitor node. +5. Allow external authentication with an XMPP domain or [Shibboleth](./shibboleth.md) + +# Format +The request and response share a similar format. + +## HTTP format +For HTTP, the conference request is encoded as JSON and POSTed to /conference-request/v1. Note that authentication is +not supported over HTTP due to the lack of an associated JID. + +The reason HTTP support was added is to prevent unnecessary connections to prosody when visitor redirection is used +(i.e. a visitor sends an HTTP request, then logs into the XMPP server specified in the response) + +### Example request +```js +{ + "room": "conferenceName@example.com", // The JID of the conference + "properties": { + "rtcstatsEnabled": true + } +} +``` +### Example response +```js +{ + "ready": true, // Jicofo has joined the room and the client can proceed. + "focusJid": "jicofo@v1.example.com", // Indicates jicofo's JID, which can be trusted by the client + "vnode": "v1", // Redirect to a visitor node with id "v1" + "properties": { + "sipGatewayEnabled": true // Signal jigasi support + } +} +``` + +## XMPP format +For XMPP, the conference request is encoded in a +[ConferenceIq](https://github.com/jitsi/jitsi-xmpp-extensions/blob/master/src/main/java/org/jitsi/xmpp/extensions/jitsimeet/ConferenceIq.java). + +### Example request +```xml + + + + + +``` + +### Example response +```xml + + + " + + +``` diff --git a/doc/diagram.key b/doc/diagram.key new file mode 100755 index 0000000..6e0192b Binary files /dev/null and b/doc/diagram.key differ diff --git a/doc/diagram.png b/doc/diagram.png new file mode 100644 index 0000000..d1b7790 Binary files /dev/null and b/doc/diagram.png differ diff --git a/doc/health-checks.md b/doc/health-checks.md new file mode 100644 index 0000000..917180d --- /dev/null +++ b/doc/health-checks.md @@ -0,0 +1,29 @@ +The REST API allows querying Jicofo whether it deems itself in a healthy state (i.e. the application is operational and the functionality it provides should perform as expected) at the time of the query or not. Videobridge will run an internal test in response to the request to determine its current health status. + +The default port for the REST API is `8888` to have Videobridge and Jicofo running on the same machine with their defaults without them clashing. + + + + + + + + + + + + +
HTTP MethodResourceResponse
GET/about/health +
    +
  • 200 OK if Jicofo ran its internal test to determine its current health status and the test completed successfully. The response is with Content-Type: application/json but no JSON value is provided i.e. Content-Length: 0.
  • +
  • 503 Service Unavailable if Jicofo is currently unable to run its health check. The reasons for the failure include (but are not limited to): +
      +
    • The REST API server has been brought up but the core conference-related functionality of Jicofo is still initializing. Videbridge could be considered healthy if the client chooses to interpret the HTTP status in such a fashion since the application may pass though the described transitional state under normal operation.
    • +
    • The core conference-related functionality of Jicofo has commenced a shutdown procedure but the REST API server has not shut down yet. Videbridge could be considered healthy if the client chooses to interpret the HTTP status in such a fashion since the application may pass though the described transitional state under normal operation.
    • +
    • The core conference-related functionality of Jicofo is in an unhealthy state and the REST API server is operating as expected.
    • +
    +
  • +
  • 500 Internal Server Error or any other 5xx status code if Jicofo ran its health check and determined that it is in an unhealthy state.
  • +
  • No response (within a client-defined time frame) if Jicofo has entered a deadlock, an infinite loop, or a similar condition which prevents the completion of the health check. The server is to be considered in an unhealthy state by the client.
  • +
+
diff --git a/doc/reservation.md b/doc/reservation.md new file mode 100644 index 0000000..9171bd2 --- /dev/null +++ b/doc/reservation.md @@ -0,0 +1 @@ +This document has been moved [here](https://jitsi.github.io/handbook/docs/devops-guide/reservation). diff --git a/doc/shibboleth.md b/doc/shibboleth.md new file mode 100644 index 0000000..0f523bf --- /dev/null +++ b/doc/shibboleth.md @@ -0,0 +1,5 @@ +# Shibboleth authentication + +Shibboleth authentication is no longer supported. + +If you'd still like to use it you can use this project: https://github.com/Renater/Jitsi-SAML2JWT diff --git a/jicofo-common/pom.xml b/jicofo-common/pom.xml new file mode 100644 index 0000000..cca7cc2 --- /dev/null +++ b/jicofo-common/pom.xml @@ -0,0 +1,412 @@ + + + 4.0.0 + + org.jitsi + jicofo-parent + 1.1-SNAPSHOT + + jicofo-common + 1.1-SNAPSHOT + jar + jicofo-common + JItsi COnference FOcus is a server side focus component used in Jitsi Meet conferences. + + + org.igniterealtime.smack + smack-tcp + + + + org.jetbrains + annotations + + + org.jetbrains.kotlin + kotlin-stdlib + compile + + + + ${project.groupId} + jicoco-config + + + ${project.groupId} + jicoco-metrics + + + ${project.groupId} + jicoco-tracing + + + ${project.groupId} + jitsi-metaconfig + + + ${project.groupId} + jitsi-utils + + + ${project.groupId} + jitsi-xmpp-extensions + + + org.slf4j + slf4j-jdk14 + + + org.slf4j + slf4j-api + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + + + com.github.spotbugs + spotbugs-annotations + + + + io.sentry + sentry + runtime + + + + org.xmlunit + xmlunit-core + test + + + org.mockito + mockito-core + test + + + io.kotest + kotest-runner-junit5-jvm + test + + + io.kotest + kotest-assertions-core-jvm + test + + + io.kotest + kotest-property-jvm + test + + + org.junit.platform + junit-platform-launcher + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter + test + + + ${project.groupId} + jicoco-test-kotlin + test + + + io.mockk + mockk-jvm + test + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + + + test-compile + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + + 17 + + -Xallow-result-return-type + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + + default-compile + none + + + + default-testCompile + none + + + java-compile + compile + + compile + + + + java-test-compile + test-compile + + testCompile + + + + + 17 + 17 + + -Xlint:all,-serial + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + ${project.parent.basedir}/lib/logging.properties + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + + true + + + + + + + test-jar + + test-jar + + + + **/*Test.class + **/*Test$*.class + **/JicofoCommonKotestProjectConfig.class + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.2 + + checkstyle.xml + + + + com.puppycrawl.tools + checkstyle + 9.1 + + + + + + check + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + spotbugs-exclude.xml + true + + + + verify + + check + + + + + + com.github.spotbugs + spotbugs + ${spotbugs.version} + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + + com.pinterest.ktlint + ktlint-cli + ${ktlint.version} + all + + + + + ktlint-check + verify + + exec + + + java + true + + -classpath + + com.pinterest.ktlint:ktlint-cli + + com.pinterest.ktlint.Main + --relative + src/main/kotlin/**/*.kt + src/test/kotlin/**/*.kt + + + + + ktlint-format + + exec + + + java + true + + -classpath + + com.pinterest.ktlint:ktlint-cli + + com.pinterest.ktlint.Main + --format + --relative + src/main/kotlin/**/*.kt + src/test/kotlin/**/*.kt + + + + + + + + + + jitsi-maven-repository-releases + default + Jitsi Maven Repository (Releases) + + true + + + false + + https://github.com/jitsi/jitsi-maven-repository/raw/master/releases/ + + + jitsi-maven-repository-snapshots + default + Jitsi Maven Repository (Snapshots) + + false + + + true + + https://github.com/jitsi/jitsi-maven-repository/raw/master/snapshots/ + + + + + coverage + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + + report + test + + report + + + + + + + + + diff --git a/jicofo-common/spotbugs-exclude.xml b/jicofo-common/spotbugs-exclude.xml new file mode 100644 index 0000000..d427a7b --- /dev/null +++ b/jicofo-common/spotbugs-exclude.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/ExcludeXmppPackets.java b/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/ExcludeXmppPackets.java new file mode 100644 index 0000000..852070a --- /dev/null +++ b/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/ExcludeXmppPackets.java @@ -0,0 +1,33 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.impl.protocol.xmpp.log; + +import java.util.logging.*; + +/** + * Excludes logs coming from {@link PacketDebugger}. + */ +public class ExcludeXmppPackets implements Filter +{ + /** + * {@inheritDoc} + */ + @Override + public boolean isLoggable(LogRecord record) + { + return !record.getLoggerName().equals(PacketDebugger.class.getName()); + } +} diff --git a/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/IncludeXmppPackets.java b/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/IncludeXmppPackets.java new file mode 100644 index 0000000..931d8fb --- /dev/null +++ b/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/IncludeXmppPackets.java @@ -0,0 +1,33 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.impl.protocol.xmpp.log; + +import java.util.logging.*; + +/** + * Allows only logs coming from {@link PacketDebugger}. + */ +public class IncludeXmppPackets implements Filter +{ + /** + * {@inheritDoc} + */ + @Override + public boolean isLoggable(LogRecord record) + { + return record.getLoggerName().equals(PacketDebugger.class.getName()); + } +} diff --git a/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/PacketDebugger.java b/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/PacketDebugger.java new file mode 100644 index 0000000..84386f3 --- /dev/null +++ b/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/PacketDebugger.java @@ -0,0 +1,104 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.impl.protocol.xmpp.log; + +import edu.umd.cs.findbugs.annotations.*; +import org.jitsi.utils.logging2.*; +import org.jitsi.xmpp.util.RedactColibri; +import org.jivesoftware.smack.*; +import org.jivesoftware.smack.debugger.*; +import org.jivesoftware.smack.packet.*; + +import java.lang.*; + +/** + * Implements {@link SmackDebugger} in order to get info about XMPP traffic. + */ +public class PacketDebugger + extends AbstractDebugger +{ + /** + * The logger used by this class. + */ + private final static Logger logger = new LoggerImpl(PacketDebugger.class.getName()); + + /** + * Whether XMPP logging is enabled. We don't want to insert a debugger into Smack when it's not going to actually + * log anything. + */ + public static boolean isEnabled() + { + return logger.isDebugEnabled(); + } + + /** + * An ID to log to identify the connection. + */ + @NonNull + private final String id; + + /** + * Creates new {@link PacketDebugger} + * {@inheritDoc} + */ + @SuppressFBWarnings("ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") + public PacketDebugger(XMPPConnection connection, @NonNull String id) + { + super(connection); + this.id = id; + + // Change the static value only if an instance is created. + AbstractDebugger.printInterpreted = true; + } + + @Override + public void onIncomingStreamElement(TopLevelStreamElement streamElement) + { + logger.debug(() -> "RCV PKT (" + id + "): " + redact(streamElement)); + } + + @Override + public void onOutgoingStreamElement(TopLevelStreamElement streamElement) + { + logger.debug(() -> "SENT PKT (" + id + "): " + redact(streamElement)); + } + + private static String redact(TopLevelStreamElement streamElement) + { + return RedactColibri.Companion.redactToken(streamElement.toXML().toString()); + } + + /** + * {@inheritDoc} + */ + @Override + protected void log(String logMessage) + { + if (logger.isDebugEnabled() && !logMessage.startsWith("RECV (") && !logMessage.startsWith("SENT (")) + { + logger.debug(logMessage); + } + } + + /** + * {@inheritDoc} + */ + @Override + protected void log(String logMessage, Throwable throwable) + { + logger.warn("Smack: " + logMessage, throwable); + } +} diff --git a/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/XmppPacketsFileHandler.java b/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/XmppPacketsFileHandler.java new file mode 100644 index 0000000..313bea5 --- /dev/null +++ b/jicofo-common/src/main/java/org/jitsi/impl/protocol/xmpp/log/XmppPacketsFileHandler.java @@ -0,0 +1,56 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.impl.protocol.xmpp.log; + +import java.io.*; +import java.text.*; +import java.time.*; +import java.util.*; +import java.util.logging.*; + +/** + * Logs XMPP traffic to a file if configured in the logging.properties file. + */ +public class XmppPacketsFileHandler extends FileHandler +{ + /** + * A constructor. + */ + public XmppPacketsFileHandler() + throws IOException, SecurityException + { + setFilter(new IncludeXmppPackets()); + setFormatter(new Formatter()); + setLevel(Level.ALL); + } + + /** + * The formatter used by {@link XmppPacketsFileHandler}. + */ + static class Formatter extends java.util.logging.Formatter + { + final SimpleDateFormat stampFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + + @Override + public String format(LogRecord record) + { + Date when = Date.from(Instant.ofEpochMilli(record.getMillis())); + String msg = record.getMessage(); + + return String.format("%s: %s%n", stampFormat.format(when), msg); + } + } +} diff --git a/jicofo-common/src/main/java/org/jitsi/jicofo/xmpp/BaseBrewery.java b/jicofo-common/src/main/java/org/jitsi/jicofo/xmpp/BaseBrewery.java new file mode 100644 index 0000000..3ae769c --- /dev/null +++ b/jicofo-common/src/main/java/org/jitsi/jicofo/xmpp/BaseBrewery.java @@ -0,0 +1,425 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp; + +import org.jetbrains.annotations.*; +import org.jitsi.jicofo.*; +import org.jitsi.jicofo.xmpp.muc.*; +import org.jitsi.utils.logging2.*; +import org.jivesoftware.smack.*; +import org.jivesoftware.smack.packet.*; +import org.jivesoftware.smack.tcp.*; +import org.jxmpp.jid.*; + +import java.lang.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.*; + +/** + * BaseBrewery manages the pool of service instances which + * exist in the current session. Does that by joining "brewery" room where + * instances connect to and publish their status in MUC presence. + * PacketExtension is the packet extension that will be used + * from that service to publish its presence. + * + * @author Pawel Domas + * @author Damian Minkov + */ +public abstract class BaseBrewery + implements XmppProvider.Listener +{ + /** + * The logger + */ + private final Logger logger; + + private final ChatRoomListener chatRoomListener = new ChatRoomListenerImpl(); + /** + * The MUC JID of the room which this detector will join. + */ + private final EntityBareJid breweryJid; + + private final XmppProvider xmppProvider; + + /** + * The ChatRoom instance for the brewery. + */ + private ChatRoom chatRoom; + + /** + * The list of all currently known instances. + */ + protected final List instances = new CopyOnWriteArrayList<>(); + + /** + * The presence extension element name. + */ + private final String extensionElementName; + + /** + * The presence extension namespace. + */ + private final String extensionNamespace; + + /** + * Reconnect timer. Used to stop the conference if XMPP connection is not restored in a given time. + */ + private Future reconnectTimeout; + + /** + * Creates new instance of BaseBrewery + * @param breweryJid the MUC JID of the room which this detector will join, + * e.g. {@code roomName@muc-servicename.jabserver.com}. + * @param presenceExtensionElementName the element name of the extension + * which this brewery will look for. + * @param presenceExtensionNamespace the namespace of the extension + * which this brewery will look for. + */ + public BaseBrewery( + @NotNull XmppProvider xmppProvider, + @NotNull EntityBareJid breweryJid, + String presenceExtensionElementName, + String presenceExtensionNamespace, + Logger parentLogger) + { + this.logger = parentLogger.createChildLogger(getClass().getName()); + this.xmppProvider = xmppProvider; + this.breweryJid = breweryJid; + logger.addContext("brewery", breweryJid.getLocalpartOrThrow().toString()); + this.extensionElementName = presenceExtensionElementName; + this.extensionNamespace = presenceExtensionNamespace; + logger.info("Initialized with JID=" + breweryJid); + } + + /** + * Checks whether or not there are any service instances connected. + * @return true if there are any instances currently + * connected to the brewery room or false otherwise. + */ + public boolean isAnyInstanceConnected() + { + return instances.size() > 0; + } + + /** + * Initializes this instance. + */ + public void init() + { + xmppProvider.addListener(this); + + maybeStart(); + } + + /** + * Starts the whole thing if we have XMPP connection up and running. + */ + private void maybeStart() + { + if (chatRoom == null && xmppProvider.getRegistered()) + { + start(); + } + } + + /** + * Stops and releases allocated resources. + */ + public void shutdown() + { + xmppProvider.removeListener(this); + + stop(); + } + + /** + * {@inheritDoc} + */ + @Override + synchronized public void registrationChanged(boolean registered) + { + if (registered) + { + if (this.reconnectTimeout != null) + { + this.reconnectTimeout.cancel(true); + this.reconnectTimeout = null; + } + + AbstractXMPPConnection connection = xmppProvider.getXmppConnection(); + if (connection instanceof XMPPTCPConnection && !((XMPPTCPConnection) connection).streamWasResumed()) + { + // We are not resuming the stream, so we need to stop and start clean + stop(); + } + + maybeStart(); + } + else + { + reconnectTimeout = TaskPools.getScheduledPool().schedule( + this::stop, + XmppConfig.service.getReplyTimeout().toMillis(), + TimeUnit.MILLISECONDS); + } + } + + @Override + public void componentsChanged(@NotNull Set components) + { + } + + /** + * Starts the detector by joining it to the brewery room. + */ + private void start() + { + try + { + chatRoom = xmppProvider.createRoom(breweryJid); + chatRoom.addListener(chatRoomListener); + chatRoom.join(); + + logger.info("Joined the room."); + } + catch (InterruptedException | SmackException | XMPPException | XmppProvider.RoomExistsException e) + { + logger.error("Failed to create room.", e); + + // cleanup on failure so we can retry + if (chatRoom != null) + { + chatRoom.removeListener(chatRoomListener); + chatRoom.leave(); + + chatRoom = null; + } + + // The XMPP connection is already authenticated at this point, so registrationChanged() won't + // fire again. Schedule an explicit retry so a transient failure (e.g. the server not responding + // to a stale MUC leave during reconnect) doesn't permanently break the brewery. + TaskPools.getScheduledPool().schedule( + this::maybeStart, + XmppConfig.service.getReplyTimeout().toMillis(), + TimeUnit.MILLISECONDS); + } + } + + /** + * Stops detector by removing listeners and leaving brewery room. + */ + private void stop() + { + try + { + if (chatRoom != null) + { + chatRoom.removeListener(chatRoomListener); + chatRoom.leave(); + + logger.info("Left the room."); + } + } + finally + { + // even if leave fails we want to cleanup, so we can retry + chatRoom = null; + + // Clean up the list of service instances + instances.forEach(this::removeInstance); + } + } + + private class ChatRoomListenerImpl extends DefaultChatRoomListener + { + @Override + public void memberJoined(@NotNull ChatRoomMember member) + { + // Process idle or busy + processMemberPresence(member); + } + + @Override + public void memberLeft(@NotNull ChatRoomMember member) + { + removeInstanceForMember(member); + } + + @Override + public void memberKicked(@NotNull ChatRoomMember member) + { + removeInstanceForMember(member); + } + + @Override + public void memberPresenceChanged(@NotNull ChatRoomMember member) + { + // Process idle or busy + processMemberPresence(member); + } + + private void removeInstanceForMember(@NotNull ChatRoomMember member) + { + // Process offline status + BrewInstance instance = find(member.getOccupantJid()); + + if (instance != null) + { + removeInstance(instance); + } + } + } + + /** + * Process chat room member status changed. Extract the appropriate + * presence extension and use it to process it further. + * @param member the chat member to process + * + * Temporarily exposed for testing. + */ + public void processMemberPresence(@NotNull ChatRoomMember member) + { + Presence presence = member.getPresence(); + + if (presence == null) + { + return; + } + + @SuppressWarnings("unchecked") + T ext = (T) presence.getExtensionElement(extensionElementName, extensionNamespace); + + // if the extension is missing skip processing + if (ext == null) + { + return; + } + + processInstanceStatusChanged(member.getOccupantJid(), ext); + } + + /** + * Process a MUC member status presence changed. Use the presence extension + * to notify implementors for the change. Stores instance if we do not + * have it cached locally, otherwise just update the new status. + * @param jid the occupant (MUC) JID of the member. + * @param extension the presence extension representing this brewing + * instance status. + */ + protected void processInstanceStatusChanged(@NotNull EntityFullJid jid, @NotNull T extension) + { + BrewInstance instance = find(jid); + + if (instance == null) + { + instance = new BrewInstance(jid, extension); + addInstance(instance); + } + else + { + instance.status = extension; + } + + logger.debug(() -> "New presence from " + jid + ": " + extension.toXML()); + onInstanceStatusChanged(jid, extension); + } + + public int getInstanceCount() + { + return instances.size(); + } + + public int getInstanceCount(@NotNull Predicate filter) + { + return (int) instances.stream().filter(filter) .count(); + } + + /** + * Notified for MUC service status update by providing its presence + * extension. + * + * @param jid the brewing instance muc address + * @param status the updated status for that instance + */ + abstract protected void onInstanceStatusChanged(@NotNull EntityFullJid jid, @NotNull T status); + + /** + * Finds instance by muc address. + * + * @param jid the address to use while searching for muc instance. + * @return the brewing instance or null if not found. + */ + private BrewInstance find(Jid jid) + { + return instances.stream() + .filter(i -> i.jid.equals(jid)) + .findFirst() + .orElse(null); + } + + private void addInstance(BrewInstance i) + { + instances.add(i); + logger.info("Added brewery instance: " + i.jid); + } + + /** + * Removes an instance from the cache and notifies that instance is going + * offline. + * + * @param i the brewing instance + */ + private void removeInstance(@NotNull BrewInstance i) + { + instances.remove(i); + logger.info("Removed brewery instance: " + i.jid); + notifyInstanceOffline(i.jid); + } + + /** + * Notifies that a brewing instance is going offline. + * @param jid the instance muc address + */ + abstract protected void notifyInstanceOffline(@NotNull Jid jid); + + /** + * Internal structure for storing information about brewing instances. + */ + @edu.umd.cs.findbugs.annotations.SuppressFBWarnings("URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD") + public class BrewInstance + { + /** + * Eg. "room@muc.server.net/nick" + */ + @NotNull + public final EntityFullJid jid; + + /** + * One of {@link ExtensionElement} + */ + @NotNull + public T status; + + BrewInstance(@NotNull EntityFullJid jid, @NotNull T status) + { + this.jid = jid; + this.status = status; + } + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/JicofoConfig.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/JicofoConfig.kt new file mode 100644 index 0000000..a98c73c --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/JicofoConfig.kt @@ -0,0 +1,52 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import org.jitsi.config.JitsiConfig.Companion.legacyConfig +import org.jitsi.config.JitsiConfig.Companion.newConfig +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.optionalconfig +import java.time.Duration + +class JicofoConfig private constructor() { + val localRegion: String? by optionalconfig { + "org.jitsi.jicofo.BridgeSelector.LOCAL_REGION".from(legacyConfig) + "$BASE.local-region".from(newConfig) + } + + val enableSctp: Boolean by config { + "$BASE.sctp.enabled".from(newConfig) + } + + fun enableSctp() = enableSctp + + fun localRegion() = localRegion + + // TODO this logically should be in VisitorsConfig, but that's in jicofo and this is needed by ChatRoom + // in jicofo-common + val vnodeJoinLatencyInterval: Duration by config { + "jicofo.visitors.vnode-join-latency-interval".from(newConfig) + } + + companion object { + const val BASE = "jicofo" + + @JvmField + val config = JicofoConfig() + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/MediaType.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/MediaType.kt new file mode 100644 index 0000000..1303caf --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/MediaType.kt @@ -0,0 +1,24 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2025-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +enum class MediaType { + AUDIO, + VIDEO, + DESKTOP +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/OctoConfig.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/OctoConfig.kt new file mode 100644 index 0000000..0046a9f --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/OctoConfig.kt @@ -0,0 +1,40 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import org.jitsi.config.JitsiConfig.Companion.newConfig +import org.jitsi.metaconfig.config + +class OctoConfig { + val enabled: Boolean by config { + "jicofo.octo.enabled".from(newConfig) + } + + val allowMixedVersions: Boolean by config { + "jicofo.octo.allow-mixed-versions".from(newConfig) + } + + val sctpDatachannels: Boolean by config { + "jicofo.octo.sctp-datachannels".from(newConfig) + } + + companion object { + @JvmField + val config = OctoConfig() + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/TaskPools.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/TaskPools.kt new file mode 100644 index 0000000..b59e60f --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/TaskPools.kt @@ -0,0 +1,57 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import org.jitsi.utils.concurrent.CustomizableThreadFactory +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService + +@SuppressFBWarnings("NP_NONNULL_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR") +class TaskPools { + companion object { + private val defaultIoPool: ExecutorService = + Executors.newCachedThreadPool(CustomizableThreadFactory("Jicofo Global IO Pool", false)) + + @JvmStatic + var ioPool: ExecutorService = defaultIoPool + + fun resetIoPool() { + ioPool = defaultIoPool + } + + private val defaultScheduledPool: ScheduledExecutorService = Executors.newScheduledThreadPool( + 3, + CustomizableThreadFactory("Jicofo Global Scheduled Pool", true) + ) + + @JvmStatic + var scheduledPool: ScheduledExecutorService = defaultScheduledPool + + fun resetScheduledPool() { + scheduledPool = defaultScheduledPool + } + + @JvmStatic + fun shutdown() { + ioPool.shutdown() + scheduledPool.shutdown() + } + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/codec/CodecConfig.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/codec/CodecConfig.kt new file mode 100644 index 0000000..314fdb1 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/codec/CodecConfig.kt @@ -0,0 +1,183 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jicofo.codec + +import org.jitsi.config.JitsiConfig +import org.jitsi.metaconfig.config + +open class CodecConfig(protected val base: String, protected val name: String) { + protected open val enabled: Boolean by config { + "$base.enabled".from(JitsiConfig.newConfig) + } + + protected open val pt: Int by config { + "$base.pt".from(JitsiConfig.newConfig) + } + + fun enabled() = enabled && pt > 0 + + @Throws(IllegalStateException::class) + fun pt(): Int = if (enabled()) pt else throw IllegalStateException("$name is not enabled.") + + open val enableRemb = false +} + +open class RtxCodecConfig(base: String, name: String) : CodecConfig(base, name) { + protected open val rtxPt: Int by config { + "$base.rtx-pt".from(JitsiConfig.newConfig) + } + + fun rtxEnabled(): Boolean = enabled() && rtxPt > 0 + + @Throws(IllegalStateException::class) + fun rtxPt(): Int = if (rtxEnabled()) rtxPt else throw IllegalStateException("RTX is not enabled for $name") +} + +private class RtxCodecConfigWithLegacy( + legacyBase: String, + newBase: String, + name: String +) : RtxCodecConfig(newBase, name) { + override val enabled: Boolean by config { + "$legacyBase.ENABLE_$name".from(JitsiConfig.legacyConfig) + "$newBase.enabled".from(JitsiConfig.newConfig) + } + + override val pt: Int by config { + "$legacyBase.${name}_PT".from(JitsiConfig.legacyConfig) + "$newBase.pt".from(JitsiConfig.newConfig) + } + + override val rtxPt: Int by config { + "$legacyBase.${name}_RTX_PT".from(JitsiConfig.legacyConfig) + "$newBase.rtx-pt".from(JitsiConfig.newConfig) + } + + override val enableRemb: Boolean by config { + "$newBase.enable-remb".from(JitsiConfig.newConfig) + } +} + +class OpusConfig : CodecConfig("jicofo.codec.audio.opus", "opus") { + private val minptime: Int by config { + "$base.minptime".from(JitsiConfig.newConfig) + } + + fun minptime() = minptime + + private val useInbandFec: Boolean by config { + "$base.use-inband-fec".from(JitsiConfig.newConfig) + } + + fun useInbandFec() = useInbandFec + + @JvmField + val red = CodecConfig("$base.red", "red") +} + +open class RtpExtensionConfig(base: String) { + open val enabled: Boolean by config { + "$base.enabled".from(JitsiConfig.newConfig) + } + fun enabled() = enabled + + val id: Int by config { + "$base.id".from(JitsiConfig.newConfig) + } + fun id() = id +} + +private class RtpExtensionConfigWithLegacy( + legacyEnabledName: String, + base: String +) : RtpExtensionConfig(base) { + override val enabled: Boolean by config { + legacyEnabledName.from(JitsiConfig.legacyConfig) + "$base.enabled".from(JitsiConfig.newConfig) + } +} + +class Config { + @JvmField + val av1: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.av1", "AV1") + + @JvmField + val vp8: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.vp8", "VP8") + + @JvmField + val vp9: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.vp9", "VP9") + + @JvmField + val h264: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.h264", "H264") + + @JvmField + val opus = OpusConfig() + + @JvmField + val telephoneEvent = CodecConfig("jicofo.codec.audio.telephone-event", "telephone-event") + + @JvmField + val audioLevel = RtpExtensionConfig("jicofo.codec.rtp-extensions.audio-level") + + @JvmField + val av1DependencyDescriptor: RtpExtensionConfig = + RtpExtensionConfig("jicofo.codec.rtp-extensions.av1-dependency-descriptor") + + val videoLayersAllocation: RtpExtensionConfig = + RtpExtensionConfig("jicofo.codec.rtp-extensions.video-layers-allocation") + + @JvmField + val tof: RtpExtensionConfig = + RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_TOF", "jicofo.codec.rtp-extensions.tof") + + @JvmField + val absSendTime: RtpExtensionConfig = + RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_AST", "jicofo.codec.rtp-extensions.abs-send-time") + + @JvmField + val rid: RtpExtensionConfig = + RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_RID", "jicofo.codec.rtp-extensions.rid") + + @JvmField + val tcc = RtpExtensionConfig("jicofo.codec.rtp-extensions.tcc") + + @JvmField + val mid = RtpExtensionConfig("jicofo.codec.rtp-extensions.mid") + + @JvmField + val videoContentType: RtpExtensionConfig = + RtpExtensionConfigWithLegacy( + "$LEGACY_BASE.ENABLE_VIDEO_CONTENT_TYPE", + "jicofo.codec.rtp-extensions.video-content-type" + ) + + @JvmField + val framemarking: RtpExtensionConfig = + RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_FRAMEMARKING", "jicofo.codec.rtp-extensions.framemarking") + val extmapAllowMixed: Boolean by config { + "jicofo.codec.rtp-extensions.extmap-allow-mixed".from(JitsiConfig.newConfig) + } + + companion object { + const val LEGACY_BASE = "org.jitsi.jicofo" + + @JvmField + val config = Config() + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/codec/CodecUtil.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/codec/CodecUtil.kt new file mode 100644 index 0000000..c9c98e6 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/codec/CodecUtil.kt @@ -0,0 +1,326 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jicofo.codec + +import org.jitsi.jicofo.codec.Config.Companion.config +import org.jitsi.xmpp.extensions.jingle.ParameterPacketExtension +import org.jitsi.xmpp.extensions.jingle.PayloadTypePacketExtension +import org.jitsi.xmpp.extensions.jingle.RTPHdrExtPacketExtension +import org.jitsi.xmpp.extensions.jingle.RtcpFbPacketExtension +import java.net.URI + +class CodecUtil { + companion object { + fun createVideoPayloadTypeExtensions( + options: OfferOptions = OfferOptions() + ): Collection = buildList { + if (config.av1.enabled()) { + // a:rtpmap:XXX AV1/90000 + val av1 = createPayloadTypeExtension(config.av1.pt(), "AV1", 90000) + av1.addVideoExtensions(options, config.av1) + add(av1) + } + + if (config.vp8.enabled()) { + // a=rtpmap:XXX VP8/90000 + val vp8 = createPayloadTypeExtension(config.vp8.pt(), "VP8", 90000) + vp8.addVideoExtensions(options, config.vp8) + add(vp8) + } + + if (config.h264.enabled()) { + // a=rtpmap:XXX H264/90000 + // XXX(gp): older Chrome versions (users have reported 53/55/61) + // fail to enable h264, if the encoding name is in lower case. + val h264 = createPayloadTypeExtension(config.h264.pt(), "H264", 90000) + h264.addVideoExtensions(options, config.h264) + h264.addParameterExtension("profile-level-id", "42e01f;level-asymmetry-allowed=1;packetization-mode=1;") + + add(h264) + } + + if (config.vp9.enabled()) { + // a=rtpmap:XXX VP9/90000 + val vp9 = createPayloadTypeExtension(config.vp9.pt(), "VP9", 90000) + vp9.addVideoExtensions(options, config.vp9) + add(vp9) + } + + if (options.rtx) { + if (config.av1.rtxEnabled()) { + // a=rtpmap:XXX rtx/90000 + val rtx = createPayloadTypeExtension(config.av1.rtxPt(), "rtx", 90000) + + // a=fmtp:XXX apt=YYY (XXX = av1.rtxPt(), YYY = config.av1.pt()) + rtx.addParameterExtension("apt", config.av1.pt().toString()) + + // a=rtcp-fb:XXX ccm fir + rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir")) + + // a=rtcp-fb:XXX nack + rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null)) + + // a=rtcp-fb:XXX nack pli + rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli")) + + add(rtx) + } + if (config.vp8.rtxEnabled()) { + // a=rtpmap:96 rtx/90000 + val rtx = createPayloadTypeExtension(config.vp8.rtxPt(), "rtx", 90000) + + // a=fmtp:96 apt=100 + rtx.addParameterExtension("apt", config.vp8.pt().toString()) + + // Chrome doesn't have these when it creates an offer, but they were observed in a hangouts + // conference. Not sure whether they have any effect. + // a=rtcp-fb:96 ccm fir + rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir")) + + // a=rtcp-fb:96 nack + rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null)) + + // a=rtcp-fb:96 nack pli + rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli")) + + add(rtx) + } + if (config.vp9.rtxEnabled()) { + // a=rtpmap:97 rtx/90000 + val rtxVP9 = createPayloadTypeExtension(config.vp9.rtxPt(), "rtx", 90000) + + // a=fmtp:97 apt=101 + rtxVP9.addParameterExtension("apt", config.vp9.pt().toString()) + + // Chrome doesn't have these when it creates an offer, but they were observed in a hangouts + // conference. Not sure whether they have any effect. + // a=rtcp-fb:96 ccm fir + rtxVP9.addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir")) + + // a=rtcp-fb:96 nack + rtxVP9.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null)) + + // a=rtcp-fb:96 nack pli + rtxVP9.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli")) + + add(rtxVP9) + } + if (config.h264.rtxEnabled()) { + // a=rtpmap:99 rtx/90000 + val rtxH264 = createPayloadTypeExtension(config.h264.rtxPt(), "rtx", 90000) + + // a=fmtp:99 apt=107 + rtxH264.addParameterExtension("apt", config.h264.pt().toString()) + + add(rtxH264) + } + } + } + + fun createAudioPayloadTypeExtensions( + options: OfferOptions = OfferOptions() + ): Collection = buildList { + if (config.opus.enabled()) { + // Though RED has a payload type of its own and can be used to encode multiple other payload types, we need + // it to be advertised with the same clock rate as opus, so it's defined here. + // Add the RED payload type before Opus, so that it is the selected codec. + if (config.opus.red.enabled() && options.opusRed) { + val red = createPayloadTypeExtension(config.opus.red.pt(), "red", 48000) + red.channels = 2 + + // RFC 2198 fmtp line + // Indicates Opus payload type (111) as primary/secondary encoding for RED (112) + // fmtp:112 111/111 + red.addParameterExtension(null, config.opus.pt().toString() + "/" + config.opus.pt()) + + add(red) + } + + // a=rtpmap:111 opus/48000/2 + val opus = createPayloadTypeExtension(config.opus.pt(), "opus", 48000) + add(opus) + // Opus is always signaled with 2 channels, regardless of 'stereo' + opus.channels = 2 + + // fmtp:111 minptime=10 + opus.addParameterExtension("minptime", config.opus.minptime().toString()) + // Avoid double FEC if RED is offered already. + if (config.opus.useInbandFec() && !config.opus.red.enabled()) { + // fmtp:111 useinbandfec=1 + opus.addParameterExtension("useinbandfec", "1") + } + if (config.tcc.enabled() && options.tcc) { + // a=rtcp-fb:111 transport-cc + opus.addRtcpFeedbackType(createRtcpFbPacketExtension("transport-cc", null)) + } + } + if (config.telephoneEvent.enabled()) { + // rtpmap:126 telephone-event/8000 + add(createPayloadTypeExtension(config.telephoneEvent.pt(), "telephone-event", 8000)) + } + } + + /** + * Create [RTPHdrExtPacketExtension]s for audio based on jicofo's configuration and the given `options`. + */ + fun createAudioRtpHdrExtExtensions( + options: OfferOptions = OfferOptions() + ): Collection = buildList { + if (config.audioLevel.enabled()) { + val ssrcAudioLevel = RTPHdrExtPacketExtension() + ssrcAudioLevel.id = config.audioLevel.id().toString() + ssrcAudioLevel.uri = URI.create("urn:ietf:params:rtp-hdrext:ssrc-audio-level") + add(ssrcAudioLevel) + } + if (config.mid.enabled()) { + // a=extmap:10 rn:ietf:params:rtp-hdrext:sdes:mid + val mid = RTPHdrExtPacketExtension() + mid.id = config.mid.id().toString() + mid.uri = URI.create("urn:ietf:params:rtp-hdrext:sdes:mid") + add(mid) + } + if (config.tcc.enabled() && options.tcc) { + // a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 + val tcc = RTPHdrExtPacketExtension() + tcc.id = config.tcc.id().toString() + tcc.uri = URI.create("http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01") + add(tcc) + } + } + fun createVideoRtpHdrExtExtensions( + options: OfferOptions = OfferOptions() + ): Collection = buildList { + if (config.videoLayersAllocation.enabled) { + // a=extmap:12 http://www.webrtc.org/experiments/rtp-hdrext/video-layers-allocation00 + add( + RTPHdrExtPacketExtension().apply { + id = config.videoLayersAllocation.id.toString() + uri = URI.create("http://www.webrtc.org/experiments/rtp-hdrext/video-layers-allocation00") + } + ) + } + if (config.av1DependencyDescriptor.enabled()) { + // https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension + val dependencyDescriptorExt = RTPHdrExtPacketExtension() + dependencyDescriptorExt.id = config.av1DependencyDescriptor.id().toString() + dependencyDescriptorExt.uri = URI.create( + "https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension" + ) + add(dependencyDescriptorExt) + } + + if (config.tof.enabled()) { + // a=extmap:2 urn:ietf:params:rtp-hdrext:toffset + val toOffset = RTPHdrExtPacketExtension() + toOffset.id = config.tof.id().toString() + toOffset.uri = URI.create("urn:ietf:params:rtp-hdrext:toffset") + add(toOffset) + } + + if (config.mid.enabled()) { + // a=extmap:10 rn:ietf:params:rtp-hdrext:sdes:mid + val mid = RTPHdrExtPacketExtension() + mid.id = config.mid.id().toString() + mid.uri = URI.create("urn:ietf:params:rtp-hdrext:sdes:mid") + add(mid) + } + + if (config.absSendTime.enabled()) { + // a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time + val absSendTime = RTPHdrExtPacketExtension() + absSendTime.id = config.absSendTime.id().toString() + absSendTime.uri = URI.create("http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time") + add(absSendTime) + } + + if (config.framemarking.enabled()) { + // a=extmap:XXX urn:ietf:params:rtp-hdrext:framemarking + val framemarking = RTPHdrExtPacketExtension() + framemarking.id = config.framemarking.id().toString() + framemarking.uri = URI.create("http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07") + add(framemarking) + } + + if (config.videoContentType.enabled()) { + // http://www.webrtc.org/experiments/rtp-hdrext/video-content-type + val videoContentType = RTPHdrExtPacketExtension() + videoContentType.id = config.videoContentType.id().toString() + videoContentType.uri = URI.create("http://www.webrtc.org/experiments/rtp-hdrext/video-content-type") + add(videoContentType) + } + + if (config.rid.enabled()) { + val rtpStreamId = RTPHdrExtPacketExtension() + rtpStreamId.id = config.rid.id.toString() + rtpStreamId.uri = URI.create("urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id") + add(rtpStreamId) + } + + if (config.tcc.enabled() && options.tcc) { + // a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 + val tcc = RTPHdrExtPacketExtension() + tcc.id = config.tcc.id().toString() + tcc.uri = URI.create("http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01") + add(tcc) + } + } + + private fun createPayloadTypeExtension(id: Int, name: String, clockRate: Int) = + PayloadTypePacketExtension().apply { + setId(id) + this.name = name + clockrate = clockRate + } + + fun PayloadTypePacketExtension.addVideoExtensions(options: OfferOptions, codecConfig: CodecConfig) { + // a=rtcp-fb:XXX ccm fir + addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir")) + + // a=rtcp-fb:XXX nack + addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null)) + + // a=rtcp-fb:XXX nack pli + addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli")) + if (codecConfig.enableRemb && options.remb) { + // a=rtcp-fb:XXX goog-remb + addRtcpFeedbackType(createRtcpFbPacketExtension("goog-remb", null)) + } + if (config.tcc.enabled() && options.tcc) { + // a=rtcp-fb:XXX transport-cc + addRtcpFeedbackType(createRtcpFbPacketExtension("transport-cc", null)) + } + } + } +} + +/** + * Adds a [ParameterPacketExtension] with a given name and value to this [PayloadTypePacketExtension]. + * @return the added extension. + */ +private fun PayloadTypePacketExtension.addParameterExtension(name: String?, value: String) = + ParameterPacketExtension().apply { + this.name = name + this.value = value + addParameter(this) + } + +private fun createRtcpFbPacketExtension(type: String, subtype: String?) = RtcpFbPacketExtension().apply { + feedbackType = type + subtype?.let { this.feedbackSubtype = subtype } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/codec/OfferOptions.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/codec/OfferOptions.kt new file mode 100644 index 0000000..8427424 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/codec/OfferOptions.kt @@ -0,0 +1,31 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.codec + +/** + * Options for an offer that jicofo generates for a specific participant (or for an Octo link). + */ +data class OfferOptions( + var audio: Boolean = true, + var video: Boolean = true, + var sctp: Boolean = true, + var tcc: Boolean = true, + var remb: Boolean = false, + var rtx: Boolean = true, + var opusRed: Boolean = true +) diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/ConferenceSourceMap.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/ConferenceSourceMap.kt new file mode 100644 index 0000000..c692277 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/ConferenceSourceMap.kt @@ -0,0 +1,222 @@ +/* + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.utils.MediaType +import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension +import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension +import java.lang.UnsupportedOperationException +import java.util.concurrent.ConcurrentHashMap + +/** + * A container for sources from multiple endpoints, mapped by the ID of the endpoint. This could contain sources for + * an entire conference, or a subset. + * + * This map is thread safe. Reading and iteration are safe because the underlying map is a [ConcurrentHashMap]. The + * only mutating operations that are exposed require a lock on [syncRoot]. + * + * Note that the [java.util.Map] mutating operations (e.g. [java.util.Map.put]) are still visible in Java, but they + * are not meant to be used and will result in an exception. + */ +open class ConferenceSourceMap( + /** + * The sources mapped by endpoint ID. + * Note that this primary constructor uses the provided [ConcurrentHashMap], and not a copy, as the underlying map. + */ + private val endpointSourceSets: ConcurrentHashMap = ConcurrentHashMap() +) : Map by endpointSourceSets { + + /** Constructs a new [ConferenceSourceMap] from the entries in [map] ([map] itself is not reused). */ + constructor(map: Map) : this(ConcurrentHashMap(map)) + constructor(vararg entries: Pair) : this(entries.toMap()) + constructor(owner: String, endpointSourceSet: EndpointSourceSet) : this(owner to endpointSourceSet) + constructor(owner: String, source: Source) : this(owner, EndpointSourceSet(source)) + constructor(owner: String, contents: List) : + this(owner, EndpointSourceSet.fromJingle(contents)) + + constructor(owner: String, sources: Set, groups: Set) : + this(owner, EndpointSourceSet(sources, groups)) + + /** The lock used for write operations to the map. Can and should be used by extending classes. */ + protected val syncRoot = Any() + + /** Remove the entry associated with [owner]. */ + open fun remove(owner: String) = synchronized(syncRoot) { + endpointSourceSets.remove(owner) + } + + /** An unmodifiable view of this [ConferenceSourceMap]. */ + val unmodifiable by lazy { UnmodifiableConferenceSourceMap(endpointSourceSets) } + fun unmodifiable() = unmodifiable + + /** Create a new [ConferenceSourceMap] instance with the same entries as this one. */ + fun copy(): ConferenceSourceMap = ConferenceSourceMap(ConcurrentHashMap(endpointSourceSets)) + + /** Adds the sources of another [ConferenceSourceMap] to this one. */ + open fun add(other: ConferenceSourceMap) = synchronized(syncRoot) { + other.endpointSourceSets.forEach { (owner, endpointSourceSet) -> + endpointSourceSets[owner] += endpointSourceSet + } + } + + /** Adds [endpointSourceSet] as sources owned by [owner]. */ + open fun add(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) { + endpointSourceSets[owner] += endpointSourceSet + } + + /** + * Create a compact JSON representation of this [ConferenceSourceMap]. The JSON is a map of an ID of the owner + * to the compact JSON of its [EndpointSourceSet] (see [EndpointSourceSet.compactJson]). + */ + fun compactJson(): String = synchronized(syncRoot) { + buildString { + append("{") + endpointSourceSets.entries.forEachIndexed { i, entry -> + if (i > 0) append(",") + append(""""${entry.key}":${entry.value.compactJson}""") + } + append("}") + } + } + + /** Removes the sources of another [ConferenceSourceMap] from this one. */ + open fun remove(other: ConferenceSourceMap) = synchronized(syncRoot) { + other.endpointSourceSets.forEach { (owner, endpointSourceSet) -> + val existing = endpointSourceSets[owner] + if (existing != null) { + val result = existing - endpointSourceSet + // TODO: do we want to allow lingering SsrcGroups? Should we actually remove SsrcGroups when their + // sources are removed? + if (result.isEmpty()) { + endpointSourceSets.remove(owner) + } else { + endpointSourceSets[owner] = result + } + } + } + } + + operator fun plus(other: ConferenceSourceMap) = copy().apply { add(other) } + operator fun minus(other: ConferenceSourceMap) = copy().apply { remove(other) } + + /** + * Creates a list of [ContentPacketExtension]s that describe the sources in this [ConferenceSourceMap]. + */ + fun toJingle(): List { + val contents = mutableMapOf() + forEach { (owner, sourceSet) -> sourceSet.toJingle(contents, owner) } + return contents.values.toList() + } + + fun createSourcePacketExtensions(mediaType: MediaType): List { + val extensions = mutableListOf() + forEach { (owner, endpointSourceSet) -> + endpointSourceSet.sources.filter { it.mediaType == mediaType }.forEach { source -> + extensions.add(source.toPacketExtension(owner)) + } + } + return extensions + } + + fun createSourceGroupPacketExtensions(mediaType: MediaType): List { + val extensions = mutableListOf() + forEach { (_, endpointSourceSet) -> + endpointSourceSet.ssrcGroups.filter { it.mediaType == mediaType }.forEach { ssrcGroup -> + extensions.add(ssrcGroup.toPacketExtension()) + } + } + return extensions + } + + /** Use a kotlin map for easy pretty printing. Inefficient. */ + override fun toString(): String = endpointSourceSets.toMap().toString() + + /** + * Strip simulcast SSRCs from each entry in the map. Modifies the map in place. + */ + fun stripSimulcast() = map { it.stripSimulcast } + + open fun map(transform: (EndpointSourceSet) -> EndpointSourceSet) = synchronized(syncRoot) { + endpointSourceSets.forEach { (owner, sources) -> + val transformed = transform(sources) + if (transformed.isEmpty()) { + endpointSourceSets.remove(owner) + } else { + endpointSourceSets[owner] = transformed + } + } + + this + } + + /** + * Remove all sources from this [ConferenceSourceMap] unless their media type is in [retain]. + */ + fun stripByMediaType( + /** The set of media types to retain, all other media types will be removed */ + retain: Set + ) = synchronized(syncRoot) { + if (retain.contains(MediaType.AUDIO) && retain.contains(MediaType.VIDEO)) { + // Nothing to strip. + return this + } + + map { sources -> + val strippedSources = sources.sources.filter { retain.contains(it.mediaType) }.toSet() + if (strippedSources.isEmpty()) { + EndpointSourceSet.EMPTY + } else { + val strippedSsrcGroups = sources.ssrcGroups.filter { retain.contains(it.mediaType) }.toSet() + EndpointSourceSet(strippedSources, strippedSsrcGroups) + } + } + } + + /** Expanded JSON format used for debugging */ + fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + synchronized(syncRoot) { + endpointSourceSets.forEach { (owner, sourceSet) -> set(owner, sourceSet.toJson()) } + } + } +} + +/** + * A read-only version of [ConferenceSourceMap]. Attempts to modify the map will via [add], [remove] or any of the + * standard [java.util.Map] mutating methods will result in an exception. + */ +class UnmodifiableConferenceSourceMap( + endpointSourceSets: ConcurrentHashMap +) : ConferenceSourceMap(endpointSourceSets) { + constructor(map: Map) : this(ConcurrentHashMap(map)) + + override fun add(other: ConferenceSourceMap) = + throw UnsupportedOperationException("add() not supported in unmodifiable view") + + override fun add(owner: String, endpointSourceSet: EndpointSourceSet) = + throw UnsupportedOperationException("add() not supported in unmodifiable view") + + override fun remove(other: ConferenceSourceMap) = + throw UnsupportedOperationException("remove() not supported in unmodifiable view") + + override fun remove(owner: String) = + throw UnsupportedOperationException("remove() not supported in unmodifiable view") + + override fun map(transform: (EndpointSourceSet) -> EndpointSourceSet) = + throw UnsupportedOperationException("map() not supported in unmodifiable view") +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/EndpointSourceSet.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/EndpointSourceSet.kt new file mode 100644 index 0000000..7f0c0ab --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/EndpointSourceSet.kt @@ -0,0 +1,283 @@ +/* + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.utils.MediaType +import org.jitsi.utils.MediaType.AUDIO +import org.jitsi.utils.MediaType.VIDEO +import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension +import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension +import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension +import java.lang.IllegalArgumentException +import kotlin.jvm.Throws + +/** A set of [Source]s and [SsrcGroup]s, usually associated with an endpoint. */ +data class EndpointSourceSet( + val sources: Set = emptySet(), + val ssrcGroups: Set = emptySet(), +) { + constructor(source: Source) : this(setOf(source)) + constructor(ssrcGroup: SsrcGroup) : this(ssrcGroups = setOf(ssrcGroup)) + + /** + * Whether this set of sources is empty. + * Note: it is not clear whether it makes sense to describe any [SsrcGroup]s without corresponding sources, but + * for this representation we allow it. + */ + fun isEmpty() = sources.isEmpty() && ssrcGroups.isEmpty() + + /** + * Whether there are any audio sources in this set. + */ + val hasAudio: Boolean by lazy { sources.any { it.mediaType == AUDIO } } + + /** + * Whether there are any video sources in this set. + */ + val hasVideo: Boolean by lazy { sources.any { it.mediaType == VIDEO } } + + /** + * Creates a list of Jingle [ContentPacketExtension]s that describe the sources in this [EndpointSourceSet]. + */ + fun toJingle(owner: String? = null): List = toJingle(mutableMapOf(), owner) + + val audioSsrcs: Set by lazy { getSsrcs(AUDIO) } + val videoSsrcs: Set by lazy { getSsrcs(VIDEO) } + + private fun getSsrcs(mediaType: MediaType) = sources.filter { it.mediaType == mediaType }.map { it.ssrc }.toSet() + + /** + * A concise string more suitable for logging. This may be slow for large sets. + */ + override fun toString() = "[audio=$audioSsrcs, video=$videoSsrcs, groups=$ssrcGroups]" + + /** + * Get a new [EndpointSourceSet] by stripping all simulcast-related SSRCs and groups except for the first SSRC in + * a simulcast group. This assumes that the first SSRC in the simulcast group and its associated RTX SSRC are the only + * SSRCs that receivers of simulcast streams need to know about, i.e. that jitsi-videobridge uses that SSRC as the + * target SSRC when rewriting streams. + */ + val stripSimulcast: EndpointSourceSet by lazy { + doStripSimulcast() + } + + /** + * A compact JSON representation of this [EndpointSourceSet] (optimized for size). This is done ad-hoc instead of + * using e.g. jackson because the format is substantially different than the natural representation of + * [EndpointSourceSet], we only need serialization support (no parsing) and to keep it separated from the main code. + * + * The JSON is an array of up to four elements, with index 0 encoding the video sources, index 1 encoding the video + * source groups, index 2 encoding the audio sources, and index 3 encoding the audio source groups. Trailing empty + * elements may be skipped, e.g. if there are no audio source groups the list will only have 3 elements. + * + * Each element is itself a JSON array of the `compactJson` representation of the sources or SSRC groups. + * + * For example, an [EndpointSourceSet] with video sources 1, 2 grouped in a Fid group and an audio source 3 may be + * encoded as: + * [ + * // Array of video sources + * [ + * {"s": 1}, + * {"s": 2} + * ], + * // Array of video SSRC groups + * [ + * ["f", 1, 2] + * ], + * // Array of audio sources + * [ + * {"s": 3} + * ] + * // Empty array of audio SSRC groups not present. + * ] + */ + val compactJson: String by lazy { + buildString { + append("[[") + sources.filter { it.mediaType == VIDEO }.forEachIndexed { i, source -> + if (i > 0) append(",") + append(source.compactJson) + } + append("],[") + ssrcGroups.filter { it.mediaType == VIDEO }.forEachIndexed { i, ssrcGroup -> + if (i > 0) append(",") + append(ssrcGroup.compactJson) + } + append("],[") + sources.filter { it.mediaType == AUDIO }.forEachIndexed { i, source -> + if (i > 0) append(",") + append(source.compactJson) + } + append("]") + if (ssrcGroups.any { it.mediaType == AUDIO }) { + append(",[") + ssrcGroups.filter { it.mediaType == AUDIO }.forEachIndexed { i, ssrcGroup -> + if (i > 0) append(",") + append(ssrcGroup.compactJson) + } + append("]") + } + append("]") + } + } + + /** Expanded JSON format used for debugging. */ + fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + set( + "sources", + JsonNodeFactory.instance.arrayNode().apply { + sources.forEach { add(it.toJson()) } + } + ) + set( + "groups", + JsonNodeFactory.instance.arrayNode().apply { + ssrcGroups.forEach { add(it.toJson()) } + } + ) + } + + companion object { + /** An [EndpointSourceSet] instance with no sources or source groups */ + @JvmField + val EMPTY = EndpointSourceSet() + + /** + * Parses a list of Jingle [ContentPacketExtension]s into an [EndpointSourceSet]. + * + * @throws IllegalArgumentException if the media type of any of the contents is invalid, or the semantics of + * any of the SSRC groups is invalid. + */ + @JvmStatic + @Throws(IllegalArgumentException::class) + fun fromJingle(contents: List): EndpointSourceSet { + val sources = mutableSetOf() + val ssrcGroups = mutableSetOf() + + contents.forEach { content -> + val rtpDesc: RtpDescriptionPacketExtension? = + content.getFirstChildOfType(RtpDescriptionPacketExtension::class.java) + + val mediaTypeString = if (rtpDesc != null) rtpDesc.media else content.name + val mediaType = MediaType.parseString(mediaTypeString) + + // The previous code looked for [SourcePacketExtension] as children of both the "content" and + // "description" elements, so this is reproduced here. I don't know which one is correct and/or used. + rtpDesc?.let { + rtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java).forEach { spe -> + sources.add(Source(mediaType, spe)) + } + rtpDesc.getChildExtensionsOfType(SourceGroupPacketExtension::class.java).forEach { sgpe -> + ssrcGroups.add(SsrcGroup.fromPacketExtension(sgpe, mediaType)) + } + } + content.getChildExtensionsOfType(SourcePacketExtension::class.java).forEach { spe -> + sources.add(Source(mediaType, spe)) + } + } + + return EndpointSourceSet(sources, ssrcGroups) + } + } +} + +/** + * Populates a list of Jingle [ContentPacketExtension]s with extensions that describe the sources in this + * [EndpointSourceSet]. Creates new [ContentPacketExtension] if necessary, returns the [ContentPacketExtension]s + * as a list. + */ +fun EndpointSourceSet.toJingle( + contentMap: MutableMap, + owner: String? +): List { + sources.forEach { source -> + val content = contentMap.computeIfAbsent(source.mediaType) { + ContentPacketExtension().apply { name = source.mediaType.toString() } + } + val rtpDesc = content.getOrCreateRtpDescription() + rtpDesc.addChildExtension(source.toPacketExtension(owner)) + } + + ssrcGroups.forEach { ssrcGroup -> + val content = contentMap.computeIfAbsent(ssrcGroup.mediaType) { + ContentPacketExtension().apply { name = ssrcGroup.mediaType.toString() } + } + val rtpDesc = content.getOrCreateRtpDescription() + rtpDesc.addChildExtension(ssrcGroup.toPacketExtension()) + } + + return contentMap.values.toList() +} + +/** + * Returns the [RtpDescriptionPacketExtension] child of this [ContentPacketExtension], creating it if it doesn't + * exist. + */ +private fun ContentPacketExtension.getOrCreateRtpDescription() = + getChildExtension(RtpDescriptionPacketExtension::class.java) ?: RtpDescriptionPacketExtension().apply { + media = name + this@getOrCreateRtpDescription.addChildExtension(this) + } + +operator fun EndpointSourceSet?.plus(other: EndpointSourceSet?): EndpointSourceSet = when { + this == null && other == null -> EndpointSourceSet.EMPTY + + this == null -> other!! + + other == null -> this + + else -> EndpointSourceSet( + sources + other.sources, + ssrcGroups + other.ssrcGroups + ) +} + +operator fun EndpointSourceSet.minus(other: EndpointSourceSet): EndpointSourceSet = + EndpointSourceSet(sources - other.sources, ssrcGroups - other.ssrcGroups) + +operator fun EndpointSourceSet.plus(sources: Set) = EndpointSourceSet(this.sources + sources, this.ssrcGroups) + +operator fun EndpointSourceSet.plus(source: Source) = EndpointSourceSet(this.sources + source, this.ssrcGroups) + +operator fun EndpointSourceSet.plus(ssrcGroup: SsrcGroup) = EndpointSourceSet(this.sources, this.ssrcGroups + ssrcGroup) + +private fun EndpointSourceSet.doStripSimulcast(): EndpointSourceSet { + val groupsToRemove = mutableSetOf() + val ssrcsToRemove = mutableSetOf() + + ssrcGroups.filter { it.semantics == SsrcGroupSemantics.Sim }.forEach { simGroup -> + groupsToRemove.add(simGroup) + simGroup.ssrcs.forEachIndexed { index: Int, ssrc: Long -> + if (index > 0) ssrcsToRemove.add(ssrc) + } + } + ssrcGroups.filter { it.semantics == SsrcGroupSemantics.Fid }.forEach { fidGroup -> + if (fidGroup.ssrcs.size != 2) { + throw IllegalArgumentException("Invalid FID group, has ${fidGroup.ssrcs.size} ssrcs.") + } + if (ssrcsToRemove.contains(fidGroup.ssrcs[0])) { + ssrcsToRemove.add(fidGroup.ssrcs[1]) + groupsToRemove.add(fidGroup) + } + } + return EndpointSourceSet( + sources.filter { !ssrcsToRemove.contains(it.ssrc) }.toSet(), + ssrcGroups - groupsToRemove + ) +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/Source.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/Source.kt new file mode 100644 index 0000000..9025248 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/Source.kt @@ -0,0 +1,120 @@ +/* + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import org.jitsi.utils.MediaType +import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension +import org.jitsi.xmpp.extensions.jingle.ParameterPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.SSRCInfoPacketExtension +import org.jxmpp.jid.impl.JidCreate + +/** + * The description of a single source (i.e. an SSRC and its properties from "a=ssrc" lines in SDP). + */ +data class Source( + val ssrc: Long, + /** Audio or Video **/ + val mediaType: MediaType, + /** Optional name */ + val name: String? = null, + /** Optional msid */ + val msid: String? = null, + /** Optional video type (camera or desktop) */ + val videoType: VideoType? = null, + /** + * Whether this source is synthetic, i.e. injected by the server (e.g. a translated audio stream). Synthetic + * sources are created by jicofo (not parsed from client Jingle); the flag is carried to the bridge on the + * Colibri2 media-source element (see [org.jitsi.jicofo.bridge.colibri.toColibriMediaSources]). + */ + val synthetic: Boolean = false +) { + /** Create a [Source] from an XML extension. */ + constructor(mediaType: MediaType, sourcePacketExtension: SourcePacketExtension) : this( + sourcePacketExtension.ssrc, + mediaType, + sourcePacketExtension.name, + sourcePacketExtension.getChildExtensionsOfType(ParameterPacketExtension::class.java) + .filter { it.name == "msid" }.map { it.value }.firstOrNull(), + if (sourcePacketExtension.videoType == null) null else VideoType.parseString(sourcePacketExtension.videoType) + ) + + /** Serializes this [Source] to XML */ + @JvmOverloads + fun toPacketExtension( + /** An optional JID for the owner of this source to encode in the XML extension. */ + owner: String? = null, + encodeMsid: Boolean = true + ) = SourcePacketExtension().apply { + ssrc = this@Source.ssrc + name = this@Source.name + if (owner != null) { + addChildExtension(SSRCInfoPacketExtension().apply { this.owner = JidCreate.from(owner) }) + } + + if (encodeMsid && msid != null) { + addChildExtension(ParameterPacketExtension("msid", msid)) + } + + this@Source.videoType?.let { + videoType = it.toString() + } + } + + /** + * A compact JSON representation of this [Source] (optimized for size). This is done ad-hoc instead of using e.g. + * jackson because we only need serialization support (no parsing) and to keep it separated from the main code. + * Note that we don't encode the media type. + */ + val compactJson: String by lazy { + buildString { + append("""{"s":$ssrc""") + name?.let { + append(""","n":"$it"""") + } + msid?.let { + append(""","m":"$it"""") + } + if (videoType == VideoType.Desktop) { + append(""","v":"d"""") + } + append("}") + } + } + + /** Expanded JSON format used for debugging. */ + fun toJson() = JsonNodeFactory.instance.objectNode().apply { + put("ssrc", ssrc) + put("media_type", mediaType.toString()) + put("name", name ?: "null") + put("msid", msid ?: "null") + put("videoType", videoType.toString()) + if (synthetic) { + put("synthetic", true) + } + } + + companion object { + /** + * Generates a source name in deterministic format used in the jitsi-meet client. First part is the endpoint id, + * followed by "-", then the first letter of the media type and the index of the source (zero based). + * For example "endpointA-a0" is the first audio source of "endpointA" or "endpointA-v0" for the first video + * source. + */ + fun nameForIdAndMediaType(endpointId: String, mediaType: MediaType, idx: Int): String = + "$endpointId-${(mediaType.toString()[0])}$idx" + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/SsrcGroup.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/SsrcGroup.kt new file mode 100644 index 0000000..4ac39a9 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/SsrcGroup.kt @@ -0,0 +1,89 @@ +/* + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.utils.MediaType +import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension +import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension +import java.lang.IllegalArgumentException +import kotlin.jvm.Throws + +/** The description of an SSRC grouping (i.e. an ssrc-group line in SDP) */ +data class SsrcGroup( + val semantics: SsrcGroupSemantics, + val ssrcs: List, + val mediaType: MediaType = MediaType.VIDEO +) { + + /** Serializes this [SsrcGroup] to XML */ + fun toPacketExtension(): SourceGroupPacketExtension = SourceGroupPacketExtension().apply { + semantics = this@SsrcGroup.semantics.toString() + addSources( + ssrcs.map { SourcePacketExtension().apply { ssrc = it } }.toList() + ) + } + + /** A concise string more suitable for logging. */ + override fun toString(): String = "${semantics.toString().uppercase()}$ssrcs" + + companion object { + /** + * Creates an [SsrcGroup] from an XML extension. The semantics is encoded as a string, so needs to be parsed + * into a [SsrcGroupSemantics] (which may fail). + * + * @throws IllegalArgumentException if the XML extension does not have a valid "semantics" field. + */ + @Throws(IllegalArgumentException::class) + fun fromPacketExtension(sgpe: SourceGroupPacketExtension, mediaType: MediaType = MediaType.VIDEO): SsrcGroup { + val semantics = try { + SsrcGroupSemantics.fromString(sgpe.semantics) + } catch (e: NoSuchElementException) { + throw IllegalArgumentException("Invalid ssrc-group semantics: ${sgpe.semantics}") + } + + return SsrcGroup(semantics, sgpe.sources.map { it.ssrc }.toList(), mediaType) + } + } + + /** + * A compact JSON representation of this [SsrcGroup] (optimized for size). This is done ad-hoc instead of using e.g. + * jackson because we only need serialization support (no parsing) and to keep it separated from the main code. + * + * The JSON contains a list with the first element encoding the semantics ("s" for Sim, "f" for Fid), and the rest + * of the elements encoding the SSRCs. For example a simulcast group for SSRCs 1, 2, 3 is encoded as: + * ["s", 1, 2, 3] + */ + val compactJson: String by lazy { + buildString { + append("[") + when (semantics) { + SsrcGroupSemantics.Sim -> append("\"s\"") + SsrcGroupSemantics.Fid -> append("\"f\"") + } + ssrcs.forEach { append(",$it") } + append("]") + } + } + + /** Expanded JSON format used for debugging. */ + fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + put("semantics", semantics.toString()) + put("media_type", mediaType.toString()) + set("ssrcs", JsonNodeFactory.instance.arrayNode().apply { ssrcs.forEach { add(it) } }) + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/SsrcGroupSemantics.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/SsrcGroupSemantics.kt new file mode 100644 index 0000000..30a309f --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/SsrcGroupSemantics.kt @@ -0,0 +1,36 @@ +/* + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import kotlin.jvm.Throws + +/** The supported semantics for [SsrcGroup] */ +enum class SsrcGroupSemantics { + /** Simulcast. */ + Sim, + + /** Flow ID, used for RTX. */ + Fid; + + override fun toString() = super.toString().uppercase() + + companion object { + /** Parse a string case-insensitively. */ + @Throws(NoSuchElementException::class) + fun fromString(str: String): SsrcGroupSemantics = + values().first { it.toString().lowercase() == str.lowercase() } + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/ValidatingConferenceSourceMap.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/ValidatingConferenceSourceMap.kt new file mode 100644 index 0000000..c71c96b --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/ValidatingConferenceSourceMap.kt @@ -0,0 +1,358 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import org.jitsi.utils.MediaType +import org.jitsi.utils.logging2.createLogger +import java.lang.IllegalStateException + +/** + * An extension of [ConferenceSourceMap] with an API for addition ([tryToAdd]) and removal ([tryToRemove]) of sources + * with validation. Note that the standard [add] and [remove] APIs are still available and do NOT perform validation. + * + * Validation means that the addition of sources does not lead to a conflict of SSRC or MSID with another endpoint + * in the conference, and that the addition/removal operations leave the endpoint's resulting set of sources valid. + * + * A set of sources is valid if there are no conflicts in SSRC or MSID, all sources within a group have the same MSID, + * all SSRCs within a group have a corresponding source, and other similar checks pass. See [validateEndpointSourceSet]. + * + * This implementation inherits the thread safety characteristics of [ConferenceSourceMap]. + */ +class ValidatingConferenceSourceMap( + private val maxSsrcsPerUser: Int, + private val maxSsrcGroupsPerUser: Int +) : ConferenceSourceMap() { + val logger = createLogger() + + /** + * Maps an SSRC to the JID of the endpoint that owns it. Used to detect cross-endpoint conflicts efficiently. + * + * Note that since the value is nullable [Map.containsKey] must be used when checking for the existence of an entry. + */ + private val ssrcToOwnerMap = mutableMapOf() + + /** + * Maps an MSID to the JID of the endpoint that owns it. Used to detect cross-endpoint conflicts efficiently. + * + * Note that since the value is nullable [Map.containsKey] must be used when checking for the existence of an entry. + */ + private val msidToOwnerMap = mutableMapOf() + + /** + * Attempts to add [sourcesToAdd] as sources owned by [owner]. The attempt is successful if the addition does not + * introduce any conflicts in SSRC/MSID and the resulting set of sources for [owner] is valid (see + * [validateEndpointSourceSet]). + * + * If the attempt is successful, the sources are added to [owner]'s sources and the accepted sources are returned. + * The accepted sources might differ from [sourcesToAdd] in their SSRC groups, because empty and duplicate groups + * are silently ignored. + * + * If the attempt is unsuccessful, a [ValidationFailedException] is thrown and the state of the map remains + * unchanged. + * + * @throws ValidationFailedException if the attempt to add sources is unsuccessful. The state of the map remains + * unchanged. + * @return The sources that have been accepted and added. + */ + @Throws(ValidationFailedException::class) + fun tryToAdd(owner: String, sourcesToAdd: EndpointSourceSet): EndpointSourceSet = synchronized(syncRoot) { + val existingSourceSet = this[owner] ?: EndpointSourceSet.EMPTY + + // Check for validity of the new SSRCs, and conflicts with other endpoints. + sourcesToAdd.sources.forEach { source -> + if (source.ssrc <= 0 || source.ssrc >= 0x1_0000_0000) { + throw InvalidSsrcException(source.ssrc) + } + if (ssrcToOwnerMap.containsKey(source.ssrc)) { + // Adding the same source for the same endpoint is also invalid. + throw SsrcAlreadyUsedException(source.ssrc) + } + source.msid?.let { msid -> + if (msidToOwnerMap.containsKey(msid) && msidToOwnerMap[msid] != owner) { + throw MsidConflictException(msid) + } + } + if (existingSourceSet.sources.size + sourcesToAdd.sources.size > maxSsrcsPerUser) { + throw SsrcLimitExceededException(maxSsrcsPerUser) + } + } + + // If we rejected any of the SSRCs we'd throw above. We accepted them all. + val acceptedSources = sourcesToAdd.sources + val resultingSources = existingSourceSet.sources + acceptedSources + val resultingSsrcs = resultingSources.map { it.ssrc }.toSet() + + val acceptedGroups = mutableSetOf() + sourcesToAdd.ssrcGroups.forEach { + // We just ignore empty groups and groups which have been signaled multiple times. + when { + it.ssrcs.isEmpty() -> logger.info("Empty group signaled, ignoring.") + existingSourceSet.ssrcGroups.contains(it) -> logger.info("Duplicate group signaled, ignoring.") + !resultingSsrcs.containsAll(it.ssrcs) -> throw GroupContainsUnknownSourceException(it.ssrcs) + else -> acceptedGroups.add(it) + } + } + if (existingSourceSet.ssrcGroups.size + acceptedGroups.size > maxSsrcGroupsPerUser) { + throw SsrcGroupLimitExceededException(maxSsrcGroupsPerUser) + } + + val resultingSourceSet = EndpointSourceSet(resultingSources, existingSourceSet.ssrcGroups + acceptedGroups) + validateEndpointSourceSet(resultingSourceSet) + + val acceptedSourceSet = EndpointSourceSet(acceptedSources, acceptedGroups) + add(ConferenceSourceMap(owner to acceptedSourceSet)) + return acceptedSourceSet + } + + /** + * Attempts to remove [sourcesToRemove] as sources owned by [owner]. The attempt is successful if all referenced + * sources and groups are indeed owned by [owner], and the resulting set of sources for [owner] is valid (see + * [validateEndpointSourceSet]). + * + * If the attempt is successful, the sources are removed from [owner]'s sources and the set of removed sources is + * returned. The set of removed sources might differ from [sourcesToRemove] because: + * 1. We only match a source's SSRC, so a source added with parameters (MSID, etc) can be removed by simply + * referencing the SSRC. + * 2. We automatically remove any groups that contain any of the removed sources. + * + * If the attempt is unsuccessful, a [ValidationFailedException] is thrown and the state of the map remains + * unchanged. + * + * @throws ValidationFailedException if the attempt to remove sources is unsuccessful. The state of the map remains + * unchanged. + * @return The sources that have been removed. + */ + @Throws(ValidationFailedException::class) + fun tryToRemove(owner: String, sourcesToRemove: EndpointSourceSet): EndpointSourceSet = synchronized(syncRoot) { + if (sourcesToRemove.isEmpty()) return EndpointSourceSet.EMPTY + + val existingSources = this[owner] + if (existingSources == null || existingSources.isEmpty()) { + throw SourceDoesNotExistException() + } + + val sourcesAcceptedToBeRemoved = mutableSetOf() + sourcesToRemove.sources.forEach { source -> + // Be lenient and allow sources to be removed without matching the original parameters (media type, msid). + val existingSource = existingSources.sources.find { it.ssrc == source.ssrc } + ?: throw SourceDoesNotExistException(source.ssrc) + sourcesAcceptedToBeRemoved.add(existingSource) + } + val ssrcsAcceptedToRemove = sourcesAcceptedToBeRemoved.map { it.ssrc } + + if (!existingSources.ssrcGroups.containsAll(sourcesToRemove.ssrcGroups)) { + throw SourceGroupDoesNotExistException() + } + val groupsAcceptedToBeRemoved = mutableSetOf(*sourcesToRemove.ssrcGroups.toTypedArray()) + + // Also automatically remove groups some of whose sources are removed. + existingSources.ssrcGroups.forEach { existingGroup -> + if (existingGroup.ssrcs.any { ssrcsAcceptedToRemove.contains(it) }) { + groupsAcceptedToBeRemoved.add(existingGroup) + } + } + + val resultingEndpointSourceSet = EndpointSourceSet( + existingSources.sources - sourcesAcceptedToBeRemoved, + existingSources.ssrcGroups - groupsAcceptedToBeRemoved + ) + validateEndpointSourceSet(resultingEndpointSourceSet) + + val acceptedSourceSet = EndpointSourceSet(sourcesAcceptedToBeRemoved, groupsAcceptedToBeRemoved) + remove(ConferenceSourceMap(owner to acceptedSourceSet)) + return acceptedSourceSet + } + + /** Override [add] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */ + override fun add(other: ConferenceSourceMap) = synchronized(syncRoot) { + super.add(other).also { + other.forEach { (owner, endpointSourceSet) -> sourceSetAdded(owner, endpointSourceSet) } + } + } + + /** Override [add] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */ + override fun add(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) { + super.add(owner, endpointSourceSet).also { + sourceSetAdded(owner, endpointSourceSet) + } + } + + private fun sourceSetAdded(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) { + endpointSourceSet.sources.forEach { source -> + ssrcToOwnerMap[source.ssrc] = owner + source.msid?.let { + msidToOwnerMap[it] = owner + } + } + } + + /** Override [remove] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */ + override fun remove(other: ConferenceSourceMap) = synchronized(syncRoot) { + super.remove(other).also { + other.forEach { (owner, ownerRemovedSourceSet) -> sourceSetRemoved(owner, ownerRemovedSourceSet) } + } + } + + /** Override [remove] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */ + override fun remove(owner: String): EndpointSourceSet? = synchronized(syncRoot) { + val ownerRemovedSourceSet = super.remove(owner) + ownerRemovedSourceSet?.let { + sourceSetRemoved(owner, it) + } + return ownerRemovedSourceSet + } + + /** + * Update the local maps ([ssrcToOwnerMap] and [msidToOwnerMap] after the removal of a source set. + * @param owner the owner of the removed source set. + * @param endpointSourceSet the source set which has already been removed. + */ + private fun sourceSetRemoved(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) { + val ownerRemainingSourceSet = this[owner] + endpointSourceSet.sources.forEach { source -> + ssrcToOwnerMap.remove(source.ssrc) + source.msid?.let { sourceMsid -> + if (ownerRemainingSourceSet == null || + ownerRemainingSourceSet.sources.none { it.msid == sourceMsid } + ) { + msidToOwnerMap.remove(sourceMsid) + } + } + } + } + + companion object { + + /** + * Checks if a given [EndpointSourceSet] is internally valid. The checks include: + * -- FID groups have exactly 2 SSRCs + * -- Grouped sources have an MSID + * -- Sources within a group have the same MSID + * -- There are no MSID conflicts + */ + @Throws(ValidationFailedException::class) + private fun validateEndpointSourceSet(endpointSourceSet: EndpointSourceSet) { + endpointSourceSet.ssrcGroups.forEach { group -> + if (group.ssrcs.isEmpty()) { + throw IllegalStateException("Empty group should have been filtered out.") + } + var groupMsid: String? = null + group.ssrcs.forEach { ssrc -> + // We are doing a linear search in a list, but the list only has sources for one endpoint, so + // its size is limited to [maxSsrcsPerUser]. + val source = endpointSourceSet.sources.find { it.ssrc == ssrc } + ?: throw IllegalStateException( + "Groups with SSRCs that have no corresponding source should have been filtered out." + ) + + if (group.semantics == SsrcGroupSemantics.Fid && group.ssrcs.size != 2) { + throw InvalidFidGroupException(group.ssrcs) + } + + if (source.msid == null) { + throw RequiredParameterMissingException("msid") + } + if (groupMsid == null) { + groupMsid = source.msid + } + + if (source.msid != groupMsid) { + throw GroupMsidMismatchException(group.ssrcs) + } + } + } + + // Audio and video sources can have the same MSID. + listOf(MediaType.AUDIO, MediaType.VIDEO).forEach { mediaType -> + // This groups all sources according to the extended group that they belong to, i.e. a SIM group gets + // associated with all of its sources plus the sources in FID groups associated with the SIM group's + // sources. For example given these groups: + // SIM(1, 2, 3) + // FID(1, 4) + // FID(2, 5) + // FID(3, 6) + // FID(111, 222) + // And non-grouped source 333 this will associate the SIM group with sources 1..6, the lonely FID group + // with sources 111 and 222, and a dummy single-source group with source 333. + // + // This is technically O(s*g^2) where s is the number of sources and g is the number of groups. But in + // practice both s and g are very small (at most 20 and 4 respectively). + val grouped: Map> = + endpointSourceSet.sources + .filter { it.mediaType == mediaType && it.msid != null } + .groupBy { groupBySimulcastGroup(it.ssrc, endpointSourceSet.ssrcGroups) } + + // Here we check for MSID conflicts, i.e. we make sure that each group has a unique MSID. + val msidsSeen = mutableSetOf() + grouped.values.map { it[0] }.forEach { source -> + if (msidsSeen.contains(source.msid)) throw MsidConflictException(source.msid ?: "NONE") + msidsSeen.add(source.msid) + } + } + } + + /** Returns the extended group to which an SSRC belongs. See [validateEndpointSourceSet]. */ + private fun groupBySimulcastGroup(ssrc: Long, ssrcGroups: Set): SsrcGroup { + val simGroup = ssrcGroups.find { + it.semantics == SsrcGroupSemantics.Sim && it.ssrcs.contains(ssrc) + } + if (simGroup != null) { + return simGroup + } + + val fidGroup = ssrcGroups.find { + it.semantics == SsrcGroupSemantics.Fid && it.ssrcs.contains(ssrc) + } + if (fidGroup != null) { + if (ssrc == fidGroup.ssrcs[1]) { + val simGroup2 = ssrcGroups.find { + it.semantics == SsrcGroupSemantics.Sim && it.ssrcs.contains(fidGroup.ssrcs[0]) + } + if (simGroup2 != null) { + return simGroup2 + } + } + return fidGroup + } + + // Dummy group just for this source. + return SsrcGroup(SsrcGroupSemantics.Sim, listOf(ssrc)) + } + } +} + +sealed class ValidationFailedException(message: String?) : Exception(message) +class InvalidSsrcException(ssrc: Long) : ValidationFailedException("Invalid SSRC: $ssrc.") +class SsrcLimitExceededException(limit: Int) : ValidationFailedException("SSRC limit ($limit) exceeded.") +class SsrcGroupLimitExceededException(limit: Int) : ValidationFailedException("ssrc-group limit ($limit) exceeded.") +class SsrcAlreadyUsedException(ssrc: Long) : ValidationFailedException("SSRC is already used: $ssrc.") +class RequiredParameterMissingException(name: String) : ValidationFailedException( + "Required source parameter '$name' is not present." +) +class GroupMsidMismatchException(ssrcs: List) : ValidationFailedException( + "SsrcGroup contains sources with different MSIDs: $ssrcs." +) +class MsidConflictException(msid: String) : ValidationFailedException("MSID is already used: $msid.") +class GroupContainsUnknownSourceException(groupSsrcs: List) : ValidationFailedException( + "An SSRC group contains an SSRC, which hasn't been signaled as a source: $groupSsrcs." +) +class InvalidFidGroupException(groupSsrcs: List) : ValidationFailedException("Invalid FID group: $groupSsrcs.") +class SourceDoesNotExistException(ssrc: Long? = null) : ValidationFailedException( + "Source does not exist or is not owned by endpoint ${ssrc?.let { "(ssrc=$it)" }}." +) +class SourceGroupDoesNotExistException : ValidationFailedException("Source group does not exist") diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/VideoType.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/VideoType.kt new file mode 100644 index 0000000..c8a3330 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/conference/source/VideoType.kt @@ -0,0 +1,27 @@ +/* + * Copyright @ 2022 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +enum class VideoType { + Camera, + Desktop; + + override fun toString() = super.toString().lowercase() + companion object { + fun parseString(s: String?): VideoType = VideoType.values().find { it.name.equals(s, ignoreCase = true) } + ?: throw IllegalArgumentException("Invalid VideoType: $s") + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/metrics/GlobalMetrics.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/metrics/GlobalMetrics.kt new file mode 100644 index 0000000..edbd52b --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/metrics/GlobalMetrics.kt @@ -0,0 +1,40 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2023 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.metrics + +import java.lang.management.ManagementFactory +import org.jitsi.jicofo.metrics.JicofoMetricsContainer.Companion.instance as metricsContainer + +class GlobalMetrics { + companion object { + @JvmField + val threadCount = metricsContainer.registerLongGauge( + "threads", + "The current number of JVM threads" + ) + + val xmppDisconnects = metricsContainer.registerCounter( + "xmpp_disconnects", + "The number of times one of the XMPP connections has disconnected." + ) + + fun update() { + threadCount.set(ManagementFactory.getThreadMXBean().threadCount.toLong()) + } + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/metrics/IceRestartMetrics.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/metrics/IceRestartMetrics.kt new file mode 100644 index 0000000..06980eb --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/metrics/IceRestartMetrics.kt @@ -0,0 +1,67 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.metrics + +import org.jitsi.jicofo.metrics.JicofoMetricsContainer.Companion.instance as metricsContainer + +/** + * Metrics for the in-place ICE restart flow. They live here rather than next to either end of the flow because it + * spans modules: the request comes in in the `jicofo` module (Participant/JitsiMeetConferenceImpl) while the colibri2 + * exchange with the bridge happens in `jicofo-selector`. + * + * The naming matches jitsi-videobridge's `ice_restarts_*` metrics, which cover the other half of the same flow. + */ +class IceRestartMetrics { + companion object { + /** A participant asked for an in-place ICE restart via session-info. */ + @JvmField + val requested = metricsContainer.registerCounter( + "ice_restarts_requested", + "Number of in-place ICE restarts requested by a participant." + ) + + /** The bridge's rotated transport was signaled back to the participant in a Jingle transport-info. */ + @JvmField + val relayed = metricsContainer.registerCounter( + "ice_restarts_relayed", + "Number of in-place ICE restarts for which the bridge's rotated transport was signaled to the participant." + ) + + /** + * The bridge answered with the transport it already had, because it did not need to restart (its own + * transport has not connected yet). Nothing was relayed and the participant keeps the session it has, so + * this is neither a completed restart nor a failed one. + */ + @JvmField + val notNeeded = metricsContainer.registerCounter( + "ice_restarts_not_needed", + "Number of in-place ICE restarts for which the bridge kept its existing transport." + ) + + /** + * An ICE restart did not complete: the request was rejected (disabled, rate-limited, stale bridge-session ID) + * or the bridge's response could not be relayed (no transport in the response, stale generation, participant + * or Jingle session gone). + */ + @JvmField + val failed = metricsContainer.registerCounter( + "ice_restarts_failed", + "Number of in-place ICE restarts that were rejected or could not be completed." + ) + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/metrics/JicofoMetricsContainer.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/metrics/JicofoMetricsContainer.kt new file mode 100644 index 0000000..fd773e7 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/metrics/JicofoMetricsContainer.kt @@ -0,0 +1,38 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.metrics + +import org.jitsi.config.JitsiConfig +import org.jitsi.jicofo.TaskPools +import org.jitsi.metaconfig.config +import org.jitsi.metrics.MetricsContainer +import org.jitsi.metrics.MetricsUpdater +import java.time.Duration + +class JicofoMetricsContainer private constructor() : MetricsContainer(namespace = "jitsi_jicofo") { + val metricsUpdater = MetricsUpdater(TaskPools.scheduledPool, updateInterval) + + companion object { + private val updateInterval: Duration by config { + "jicofo.metrics.update-interval".from(JitsiConfig.newConfig) + } + + @JvmStatic + val instance = JicofoMetricsContainer() + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/PendingCount.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/PendingCount.kt new file mode 100644 index 0000000..443daac --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/PendingCount.kt @@ -0,0 +1,53 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import org.jitsi.utils.ms +import org.jitsi.utils.stats.RateTracker +import java.time.Clock +import java.time.Duration + +/** Keep a count of some events which have been triggered to happen within some interval, but which + * may or may not happen, and thus should expire from the count after some time if they have not. + * The intended use case is visitors who have been invited to a visitor ChatRoom. + */ +class PendingCount( + timeout: Duration, + clock: Clock = Clock.systemUTC() +) { + private var occurredEvents = 0L + + private var tracker = object : RateTracker(timeout, 100.ms, clock) { + override fun bucketExpired(count: Long) { + occurredEvents = (occurredEvents - count).coerceAtLeast(0) + } + } + + @Synchronized + fun eventPending() { + tracker.update(1) + } + + @Synchronized + fun eventOccurred() { + occurredEvents = (occurredEvents + 1).coerceAtMost(tracker.getAccumulatedCount()) + } + + @Synchronized + fun getCount(): Long = tracker.getAccumulatedCount() - occurredEvents +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/SynchronizedDelegate.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/SynchronizedDelegate.kt new file mode 100644 index 0000000..6a9b71d --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/SynchronizedDelegate.kt @@ -0,0 +1,33 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class SynchronizedDelegate(defaultValue: T, private val syncRoot: Any) : ReadWriteProperty { + private var backingField = defaultValue + + override fun getValue(thisRef: Any, property: KProperty<*>): T = synchronized(syncRoot) { + backingField + } + + override fun setValue(thisRef: Any, property: KProperty<*>, value: T) = synchronized(syncRoot) { + backingField = value + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/TracingUtil.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/TracingUtil.kt new file mode 100644 index 0000000..96a1327 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/TracingUtil.kt @@ -0,0 +1,25 @@ +package org.jitsi.jicofo.util + +import io.opentelemetry.api.common.Attributes +import org.jitsi.jicofo.xmpp.muc.ChatRoom +import org.jitsi.jicofo.xmpp.muc.ChatRoomMember +import java.util.Objects + +class TracingUtil { + companion object { + @JvmStatic + fun memberAttributes(member: ChatRoomMember): Attributes = Attributes.builder() + .put("member.name", member.name) + .put("member.id", Objects.toString(member.jid)) + .put("member.role", member.role.toString()) + .put("member.region", Objects.toString(member.region)) + .build() + + @JvmStatic + fun roomAttributes(room: ChatRoom): Attributes = Attributes.builder() + .put("room.id", Objects.toString(room.roomJid)) + .put("room.members", room.memberCount.toString()) + .put("room.visitors", room.visitorCount.toString()) + .build() + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/WeakValueMap.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/WeakValueMap.kt new file mode 100644 index 0000000..378f7af --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/util/WeakValueMap.kt @@ -0,0 +1,51 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap + +/** + * Self-cleaning map with weak values. + * TODO: maybe move to jitsi-utils + */ +class WeakValueMap( + private val cleanInterval: Int = 100 +) { + private val map: MutableMap> = ConcurrentHashMap>() + private var i = 0 + + fun get(key: K) = map[key]?.get().also { + maybeClean() + } + fun put(key: K, value: V) { + map[key] = WeakReference(value) + maybeClean() + } + fun containsKey(key: K): Boolean = (map[key]?.get() != null).also { + maybeClean() + } + fun remove(key: K) = map.remove(key)?.get() + fun values(): List { + clean() + return map.values.mapNotNull { it.get() }.toList() + } + + private fun maybeClean() = ((i++ % cleanInterval == 0)) && clean() + private fun clean() = map.values.removeIf { it.get() == null } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/AbstractIqHandler.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/AbstractIqHandler.kt new file mode 100644 index 0000000..271bcf2 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/AbstractIqHandler.kt @@ -0,0 +1,137 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import org.jitsi.jicofo.xmpp.IqProcessingResult.AcceptedWithResponse +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.XMPPConnection +import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler +import org.jivesoftware.smack.iqrequest.IQRequestHandler +import org.jivesoftware.smack.packet.ErrorIQ +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import java.lang.IllegalArgumentException + +/** + * Implements an IQ handler registered with a specific set of [XMPPConnection]s. Hides the Smack-level API and exposes + * just [handleRequest], which uses the specific [IQ] subtype ([T]) and provides the connection on which it was received + * as a parameter. + */ +abstract class AbstractIqHandler( + /** + * The set of connections to which to register. + */ + connections: Set, + /** + * The name of the IQ child element for this handler. It must match the element name returned by [T] instances. + */ + elementName: String, + /** + * The namespace of the IQ child element for this handler. It must match the namespace returned by [T] instances. + */ + elementNamespace: String, + iqTypes: Set = setOf(IQ.Type.set, IQ.Type.get), + mode: IQRequestHandler.Mode = IQRequestHandler.Mode.sync +) { + /** + * One handler for each (connection, iqType) pair. These are the actual instances registered with the + * [XMPPConnection]s. + */ + private val handlers: List = connections.flatMap { connection -> + iqTypes.map { iqType -> + IQRequestHandlerImpl(connection, elementName, elementNamespace, iqType, mode).also { + connection.registerIQRequestHandler(it) + } + } + } + + fun shutdown() = handlers.forEach { it.connection.unregisterIQRequestHandler(it) } + + /** + * Handle a request that was received on one of the [XMPPConnection]s. + * + * @return A non-null value to be sent as a response (should be either an IQ of type `result`, or an Error), or + * `null` if no response is to be sent immediately. If this returns `null`, the code handling the IQ takes the + * responsibility of (eventually) sending a response. The response, whether returned by the method or sent in + * another way, should have the correct properties for an XMPP response (`from` and `to` swapped, matching `id`, + * and type `result` if the response is an IQ). + */ + abstract fun handleRequest(request: IqRequest): IqProcessingResult + + private inner class IQRequestHandlerImpl( + val connection: AbstractXMPPConnection, + elementName: String, + elementNamespace: String, + iqType: IQ.Type, + mode: IQRequestHandler.Mode + ) : AbstractIqRequestHandler(elementName, elementNamespace, iqType, mode) { + + /** + * Handle a request. The cast can not be checked because the type is erased, but we perform the cast here, so + * any exceptions will occur early and not cause further problems. + */ + @Suppress("UNCHECKED_CAST") + override fun handleIQRequest(iq: IQ): IQ? { + val result = handleRequest( + IqRequest( + iq as? T ?: throw IllegalArgumentException("Unexpected IQ type: ${iq::class}"), + connection + ) + ) + return when (result) { + is AcceptedWithResponse -> result.response + + is IqProcessingResult.AcceptedWithNoResponse -> null + + is IqProcessingResult.RejectedWithError -> result.response + + is IqProcessingResult.NotProcessed -> + IQ.createErrorResponse(iq, StanzaError.Condition.feature_not_implemented) + } + } + } +} + +/** An IQ received on a specific [XMPPConnection] */ +data class IqRequest(val iq: T, val connection: AbstractXMPPConnection) + +sealed class IqProcessingResult { + /** The IQ was accepted/handled. The given `response` should be sent as a response. */ + class AcceptedWithResponse(val response: IQ) : IqProcessingResult() + + /** + * The IQ was accepted/handled, but no response is available (yet). The handler is responsible for eventually + * sending a response by other means. + * */ + class AcceptedWithNoResponse : IqProcessingResult() + + /** The IQ was handled, but it resulted in an error. The given error `response` should be sent as a response. */ + class RejectedWithError(val response: ErrorIQ) : IqProcessingResult() { + constructor( + request: IqRequest<*>, + condition: StanzaError.Condition + ) : this(request.iq, condition) + constructor( + iq: IQ, + condition: StanzaError.Condition + ) : this(IQ.createErrorResponse(iq, StanzaError.getBuilder(condition).build())) + } + + /** The IQ was not handled. */ + class NotProcessed : IqProcessingResult() +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/Features.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/Features.kt new file mode 100644 index 0000000..d7eb38f --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/Features.kt @@ -0,0 +1,94 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +enum class Features(val value: String) { + AUDIO("urn:xmpp:jingle:apps:rtp:audio"), + VIDEO("urn:xmpp:jingle:apps:rtp:video"), + + /** DTLS/SCTP feature name. */ + SCTP("urn:xmpp:jingle:transports:dtls-sctp:1"), + + /** RTX (RFC4588) support. */ + RTX("urn:ietf:rfc:4588"), + + /** RTCP REMB. */ + REMB("http://jitsi.org/remb"), + + /** Transport-wide congestion control. */ + TCC("http://jitsi.org/tcc"), + + /** Source name signalling. */ + SOURCE_NAMES("http://jitsi.org/source-name"), + SSRC_REWRITING_V1("http://jitsi.org/ssrc-rewriting-1"), + + /** The endpoint can demux forwarded media by the RTP sdes:mid header extension (requires SSRC rewriting). */ + RTP_MID_DEMUX("http://jitsi.org/rtp-mid-demux"), + RECEIVE_MULTIPLE_STREAMS("http://jitsi.org/receive-multiple-video-streams"), + + /** Jingle sources encoded as JSON. */ + JSON_SOURCES("http://jitsi.org/json-encoded-sources"), + OPUS_RED("http://jitsi.org/opus-red"), + AUDIO_MUTE("http://jitsi.org/protocol/audio-mute"), + AUDIO_TRANSLATION("http://jitsi.org/protocol/audio-translation"), + + // The ones below are not used in jicofo, but are defined here to improve the xmpp-caps statistics and avoid logs + // about unknown features. + JIBRI("http://jitsi.org/protocol/jibri"), + JIGASI("http://jitsi.org/protocol/jigasi"), + LIPSYNC("http://jitsi.org/meet/lipsync"), + E2EE("https://jitsi.org/meet/e2ee"), + XMPP_CAPS("http://jabber.org/protocol/caps"), + JINGLE("urn:xmpp:jingle:1"), + JINGLE_RTP("urn:xmpp:jingle:apps:rtp:1"), + JINGLE_ICE("urn:xmpp:jingle:transports:ice-udp:1"), + DTLS("urn:xmpp:jingle:apps:dtls:0"), + RTCPMUX("urn:ietf:rfc:5761"), + BUNDLE("urn:ietf:rfc:5888"), + RAYO("urn:xmpp:rayo:client:1"), + REMOTECONTROL("http://jitsi.org/meet/remotecontrol"), + + // Supports handling "start muted" via room metadata (instead of via jingle). + START_MUTED_RMD("http://jitsi.org/start-muted-room-metadata"), + VISITORS_V1("http://jitsi.org/visitors-1"), + + /** The endpoint understands the "jitsi:client-requirements" IQ, used to signal missing capabilities. */ + CLIENT_REQUIREMENTS_1("http://jitsi.org/client-requirements-1"); + + companion object { + val defaultFeatures = setOf(AUDIO, VIDEO, SCTP) + + /** Parse a string and silently return null if not recognized. */ + fun parseString(s: String): Features? = values().find { it.value == s } + } +} + +/** + * The result of a feature discovery (disco#info) for an endpoint. + */ +data class FeatureDiscoveryResult( + val features: Set, + /** + * Whether the features were actually discovered. When this is false [features] is just an assumed default set, + * and the absence of a feature from it does not mean that the endpoint does not support the feature. + */ + val discovered: Boolean +) + +/** Used when the features of an endpoint could not be discovered. */ +val featuresNotDiscovered = FeatureDiscoveryResult(Features.defaultFeatures, false) diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/JingleUtils.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/JingleUtils.kt new file mode 100644 index 0000000..cf08d55 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/JingleUtils.kt @@ -0,0 +1,43 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import org.jitsi.jicofo.xmpp.jingle.JingleSession +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension +import org.jitsi.xmpp.extensions.jingle.JingleAction +import org.jitsi.xmpp.extensions.jingle.JingleIQ +import org.jivesoftware.smack.packet.IQ +import org.jxmpp.jid.Jid + +fun createSessionInitiate(from: Jid, to: Jid, sid: String, contents: List) = + JingleIQ(JingleAction.SESSION_INITIATE, sid).apply { + this.from = from + this.to = to + initiator = from + type = IQ.Type.set + contents.forEach { addContent(it) } + } + +fun createTransportReplace(from: Jid, session: JingleSession, contents: List) = + JingleIQ(JingleAction.TRANSPORT_REPLACE, session.sid).apply { + this.from = from + this.to = session.remoteJid + initiator = from + type = IQ.Type.set + contents.forEach { addContent(it) } + } diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/JsonMessage.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/JsonMessage.kt new file mode 100644 index 0000000..48f1730 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/JsonMessage.kt @@ -0,0 +1,132 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2025-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.MapperFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.jitsi.jicofo.MediaType + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes( + JsonSubTypes.Type(value = AvModerationMessage::class, name = AvModerationMessage.TYPE), + JsonSubTypes.Type(value = RoomMetadata::class, name = RoomMetadata.TYPE) +) +@JsonIgnoreProperties(ignoreUnknown = true) +sealed class JsonMessage(val type: String) { + companion object { + private val mapper = jacksonObjectMapper().apply { + enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + } + + @JvmStatic + @Throws(JsonProcessingException::class, JsonMappingException::class) + fun parse(string: String): JsonMessage = mapper.readValue(string) + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AvModerationMessage( + val room: String?, + val enabled: Boolean? = null, + val mediaType: MediaType? = null, + val actor: String? = null, + val whitelists: Map>? = null +) : JsonMessage(TYPE) { + + companion object { + const val TYPE = "av_moderation" + } +} + +/** + * The JSON structure included in the MUC config form from the room_metadata prosody module in jitsi-meet. Includes + * only the fields that we need here in jicofo. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RoomMetadata(val metadata: Metadata?) : JsonMessage(TYPE) { + @JsonIgnoreProperties(ignoreUnknown = true) + data class Metadata( + val visitors: Visitors?, + val startMuted: StartMuted?, + val moderators: List?, + val participants: List?, + val recording: Recording?, + val asyncTranscription: Boolean? = null, + val participantsSoftLimit: Int? = null, + val visitorsEnabled: Boolean? = null, + val lobbyEnabled: Boolean? = null, + val transcription: Transcription? = null, + /** + * Per-room live-translation connect config (e.g. a per-customer usage token as an HTTP header, + * delivered to jicofo on the admin-only metadata path). Mirrors [transcription]. + */ + val translation: Translation? = null, + /** Aggregated live-translation requests: sender endpoint id -> set of requested language codes. */ + val audioTranslationRequests: Map>? = null + ) { + @JsonIgnoreProperties(ignoreUnknown = true) + data class Visitors( + val live: Boolean?, + /** + * The languages the visitors want transcriptions translated into, as a comma-separated list, or an empty + * string when they want none. + * + * Visitors are in a MUC on a visitor node, so their presence is not visible here. Instead each visitor + * node's mod_fmuc collects their requested languages and sends them to the main prosody, which aggregates + * across nodes into this field (mod_visitors_component). This is the same field jigasi reads. + */ + val transcribingLanguages: String? = null, + /** How many visitors are requesting transcription, across all visitor nodes. */ + val transcribingCount: Long? = null + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class StartMuted(val audio: Boolean?, val video: Boolean?) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class Recording(val isTranscribingEnabled: Boolean?) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class Transcription( + val urlParams: Map? = null, + val httpHeaders: Map? = null + ) { + override fun toString(): String = "Transcription(urlParams=${urlParams?.mapValues { "***" }}, " + + "httpHeaders=${httpHeaders?.mapValues { "***" }})" + } + + @JsonIgnoreProperties(ignoreUnknown = true) + data class Translation( + val httpHeaders: Map? = null + ) { + override fun toString(): String = "Translation(httpHeaders=${httpHeaders?.mapValues { "***" }})" + } + } + + companion object { + const val TYPE = "room_metadata" + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/Util.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/Util.kt new file mode 100644 index 0000000..5f96448 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/Util.kt @@ -0,0 +1,76 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import org.jitsi.utils.logging2.LoggerImpl +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.SmackException +import org.jivesoftware.smack.XMPPConnection +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.Stanza +import org.jxmpp.jid.DomainBareJid +import org.jxmpp.jid.Jid +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.stringprep.XmppStringprepException + +private val logger = LoggerImpl("org.jitsi.jicofo.xmpp.Util") + +/** + * Reads the original jid as encoded in the resource part by mod_client_proxy, returns the original jid if it format + * is not as expected. + */ +fun parseJidFromClientProxyJid( + /** + * The JID of the client_proxy component. + */ + clientProxy: DomainBareJid?, + /** + * The JID to parse. + */ + jid: Jid +): Jid { + clientProxy ?: return jid + + if (clientProxy == jid.asDomainBareJid()) { + jid.resourceOrNull?.let { resource -> + return try { + JidCreate.from(resource.toString()) + } catch (e: XmppStringprepException) { + jid + } + } + } + return jid +} + +fun XMPPConnection.tryToSendStanza(stanza: Stanza) = try { + sendStanza(stanza) +} catch (e: SmackException.NotConnectedException) { + logger.error("No connection - unable to send packet: ${stanza.toXML()}", e) +} catch (e: InterruptedException) { + logger.error("Failed to send packet: ${stanza.toXML()}", e) +} + +@Throws(SmackException.NotConnectedException::class) +fun AbstractXMPPConnection.sendIqAndGetResponse(iq: IQ): IQ? = createStanzaCollectorAndSend(iq).let { + try { + it.nextResult() + } finally { + it.cancel() + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppCapsStats.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppCapsStats.kt new file mode 100644 index 0000000..6832c50 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppCapsStats.kt @@ -0,0 +1,69 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.jitsi.utils.logging2.createLogger + +private val jsonMapper = jacksonObjectMapper() + +class XmppCapsStats { + companion object { + /** + * Maps a nodeVer string (the "node" and "ver" attributes from a caps extension (XEP-0115) joined by "#") to + * the associated set of features and a counter for the number of participants with that nodeVer. + */ + private val map: MutableMap = mutableMapOf() + private const val MAX_ENTRIES = 1000 + private val logger = createLogger() + + @JvmStatic + val stats: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + synchronized(map) { + map.forEach { (nodeVer, e) -> + set(nodeVer, e.json()) + } + } + } + + fun update(nodeVer: String, features: Set) { + synchronized(map) { + if (map.size < MAX_ENTRIES) { + map.computeIfAbsent(nodeVer) { FeaturesAndCount(features) }.count++ + } else { + map[nodeVer]?.let { + it.count++ + } ?: logger.warn("Too many entries. Ignoring nodeVer=$nodeVer, features=$features") + } + } + } + } + + private class FeaturesAndCount(val features: Set) { + /** The number of participants seen with this set of features. */ + var count = 0 + fun json(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + put("count", count) + set("features", jsonMapper.valueToTree(features.map { it.name })) + } + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppConfig.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppConfig.kt new file mode 100644 index 0000000..02a21c9 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppConfig.kt @@ -0,0 +1,298 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import com.typesafe.config.ConfigObject +import com.typesafe.config.ConfigValue +import org.jitsi.config.JitsiConfig.Companion.legacyConfig +import org.jitsi.config.JitsiConfig.Companion.newConfig +import org.jitsi.metaconfig.ConfigException +import org.jitsi.metaconfig.ConfigException.UnableToRetrieve.NotFound +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.optionalconfig +import org.jitsi.utils.secs +import org.jxmpp.jid.DomainBareJid +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.jid.parts.Resourcepart +import java.time.Duration + +class XmppConfig private constructor() { + val trustedDomains: List by config { + "jicofo.xmpp.trusted-domains".from(newConfig) + .convertFrom> { l -> l.map { JidCreate.domainBareFrom(it) } } + } + + val useJitsiJidValidation: Boolean by config { + "jicofo.xmpp.use-jitsi-jid-validation".from(newConfig) + } + + companion object { + @JvmField + val service = XmppServiceConnectionConfig() + + @JvmField + val client = XmppClientConnectionConfig() + + @JvmStatic + val visitors: Map by config { + "jicofo.xmpp.visitors".from(newConfig) + .convertFrom { cfg -> + cfg.entries.associate { + val xmppConfig = it.toXmppVisitorConnectionConfig() + Pair(xmppConfig.name, xmppConfig) + } + } + } + + @JvmField + val config = XmppConfig() + } +} + +interface XmppConnectionConfig { + val enabled: Boolean + val hostname: String + val port: Int + val domain: DomainBareJid + val username: Resourcepart + val resource: Resourcepart + val password: String? + val replyTimeout: Duration + val disableCertificateVerification: Boolean + val useTls: Boolean + val name: String + val xmppDomain: DomainBareJid? + val jid + get() = "$username@$domain" +} + +class XmppVisitorConnectionConfig( + override val enabled: Boolean, + override val hostname: String, + override val port: Int, + override val domain: DomainBareJid, + override val username: Resourcepart, + override val resource: Resourcepart, + override val password: String?, + override val replyTimeout: Duration, + override val disableCertificateVerification: Boolean, + override val useTls: Boolean, + override val name: String, + override val xmppDomain: DomainBareJid?, + val conferenceService: DomainBareJid +) : XmppConnectionConfig + +private fun MutableMap.MutableEntry.toXmppVisitorConnectionConfig(): XmppVisitorConnectionConfig { + val name = this.key + val c = value as? ConfigObject ?: throw ConfigException.UnsupportedType("visitors config must be an object") + val hostname = c["hostname"]?.unwrapped()?.toString() + ?: throw NotFound("hostname required for visitors config $name") + val domain = c["domain"]?.unwrapped()?.toString() ?: hostname + val xmppDomain = c["xmpp-domain"]?.unwrapped()?.toString() + val username = c["username"]?.unwrapped()?.toString() ?: "focus" + val conferenceService = c["conference-service"]?.unwrapped()?.toString() + ?: throw NotFound("conference-service required for visitors config $name") + + return XmppVisitorConnectionConfig( + enabled = c["enabled"]?.let { it.unwrapped().toString().toBoolean() } ?: true, + hostname = c["hostname"]?.unwrapped()?.toString() + ?: throw NotFound("hostname required for visitors config $name"), + port = c["port"]?.unwrapped()?.toString()?.toInt() ?: 5222, + domain = JidCreate.domainBareFrom(domain), + username = Resourcepart.from(username), + resource = Resourcepart.from(c["resource"]?.unwrapped()?.toString() ?: username), + password = c["password"]?.unwrapped()?.toString(), + replyTimeout = c["reply-timeout"]?.unwrapped() as? Duration ?: 15.secs, + disableCertificateVerification = c["disable-certificate-verification"]?.unwrapped()?.toString()?.toBoolean() + ?: false, + useTls = c["use-tls"]?.unwrapped()?.toString()?.toBoolean() ?: true, + name = name, + conferenceService = JidCreate.domainBareFrom(conferenceService), + xmppDomain = if (xmppDomain != null) JidCreate.domainBareFrom(xmppDomain) else null + ) +} + +class XmppServiceConnectionConfig : XmppConnectionConfig { + override val enabled: Boolean by config { + // If the legacy host is set to anything, the connection is enabled. + "org.jitsi.jicofo.BRIDGE_MUC_XMPP_HOST".from(legacyConfig).convertFrom { true } + "jicofo.xmpp.service.enabled".from(newConfig) + } + + override val hostname: String by config { + "org.jitsi.jicofo.BRIDGE_MUC_XMPP_HOST".from(legacyConfig) + "jicofo.xmpp.service.hostname".from(newConfig) + } + + override val port: Int by config { + "org.jitsi.jicofo.BRIDGE_MUC_XMPP_PORT".from(legacyConfig) + "jicofo.xmpp.service.port".from(newConfig) + } + + override val domain: DomainBareJid by config { + "org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER_DOMAIN".from(legacyConfig).convertFrom { + JidCreate.domainBareFrom(it) + } + "jicofo.xmpp.service.domain".from(newConfig).convertFrom { + JidCreate.domainBareFrom(it) + } + } + + override val xmppDomain: DomainBareJid? by optionalconfig { + "jicofo.xmpp.service.xmpp-domain".from(newConfig) + } + + override val username: Resourcepart by config { + "org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER".from(legacyConfig).convertFrom { + Resourcepart.from(it) + } + "jicofo.xmpp.service.username".from(newConfig).convertFrom { + Resourcepart.from(it) + } + } + + override val resource: Resourcepart by config { + "jicofo.xmpp.service.resource".from(newConfig).convertFrom { + Resourcepart.from(it) + } + } + + override val password: String? by optionalconfig { + "org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER_PASS".from(legacyConfig) + "jicofo.xmpp.service.password".from(newConfig) + } + + override val replyTimeout: Duration by config { + "jicofo.xmpp.service.reply-timeout".from(newConfig) + } + + override val disableCertificateVerification: Boolean by config { + "org.jitsi.jicofo.ALWAYS_TRUST_MODE_ENABLED".from(legacyConfig) + "jicofo.xmpp.service.disable-certificate-verification".from(newConfig) + } + + override val useTls: Boolean by config { + "jicofo.xmpp.service.use-tls".from(newConfig) + } + + override fun toString(): String = "XmppServiceConnectionConfig[hostname=$hostname, port=$port, username=$username]" + + override val name = "service" +} + +class XmppClientConnectionConfig : XmppConnectionConfig { + override val enabled: Boolean by config { + LEGACY_HOSTNAME_PROPERTY_NAME.from(legacyConfig).convertFrom { true } + "jicofo.xmpp.client.enabled".from(newConfig) + } + + override val hostname: String by config { + LEGACY_HOSTNAME_PROPERTY_NAME.from(legacyConfig) + "jicofo.xmpp.client.hostname".from(newConfig) + } + + override val port: Int by config { + "org.jitsi.jicofo.XMPP_PORT".from(legacyConfig) + "jicofo.xmpp.client.port".from(newConfig) + } + + /** + * This is the domain used for login. Not necessarily the root XMPP domain. + */ + override val domain: DomainBareJid by config { + LEGACY_DOMAIN_PROPERTY_NAME.from(legacyConfig).convertFrom { + JidCreate.domainBareFrom(it) + } + "jicofo.xmpp.client.domain".from(newConfig).convertFrom { + JidCreate.domainBareFrom(it) + } + } + + override val username: Resourcepart by config { + LEGACY_USERNAME_PROPERTY_NAME.from(legacyConfig).convertFrom { + Resourcepart.from(it) + } + "jicofo.xmpp.client.username".from(newConfig).convertFrom { + Resourcepart.from(it) + } + } + + override val resource: Resourcepart by config { + "jicofo.xmpp.client.resource".from(newConfig).convertFrom { + Resourcepart.from(it) + } + } + + override val password: String? by optionalconfig { + LEGACY_PASSWORD_PROPERTY_NAME.from(legacyConfig) + "jicofo.xmpp.client.password".from(newConfig) + } + + /** + * This is the top-level domain hosted by the XMPP server (not necessarily the one used for login). + */ + override val xmppDomain: DomainBareJid by config { + LEGACY_XMPP_DOMAIN_PROPERTY_NAME.from(legacyConfig).convertFrom { + JidCreate.domainBareFrom(it) + } + "jicofo.xmpp.client.xmpp-domain".from(newConfig).convertFrom { + JidCreate.domainBareFrom(it) + } + } + + val conferenceMucJid: DomainBareJid by config { + "org.jitsi.jicofo.XMPP_MUC_COMPONENT_PREFIX".from(legacyConfig).convertFrom { + JidCreate.domainBareFrom("$it.$xmppDomain") + } + "jicofo.xmpp.client.conference-muc-jid".from(newConfig).convertFrom { + JidCreate.domainBareFrom(it) + } + "default" { JidCreate.domainBareFrom("conference.$xmppDomain") } + } + + override val replyTimeout: Duration by config { + "jicofo.xmpp.client.reply-timeout".from(newConfig) + } + + override val disableCertificateVerification: Boolean by config { + "org.jitsi.jicofo.ALWAYS_TRUST_MODE_ENABLED".from(legacyConfig) + "jicofo.xmpp.client.disable-certificate-verification".from(newConfig) + } + + override val useTls: Boolean by config { + "jicofo.xmpp.client.use-tls".from(newConfig) + } + + val clientProxy: DomainBareJid? by optionalconfig { + "jicofo.xmpp.client.client-proxy".from(newConfig).convertFrom { + JidCreate.domainBareFrom(it) + } + } + + override fun toString(): String = "XmppClientConnectionConfig[hostname=$hostname, port=$port, username=$username]" + + override val name = "client" + + companion object { + const val LEGACY_HOSTNAME_PROPERTY_NAME = "org.jitsi.jicofo.HOSTNAME" + const val LEGACY_DOMAIN_PROPERTY_NAME = "org.jitsi.jicofo.FOCUS_USER_DOMAIN" + const val LEGACY_USERNAME_PROPERTY_NAME = "org.jitsi.jicofo.FOCUS_USER_NAME" + const val LEGACY_PASSWORD_PROPERTY_NAME = "org.jitsi.jicofo.FOCUS_USER_PASSWORD" + const val LEGACY_XMPP_DOMAIN_PROPERTY_NAME = "org.jitsi.jicofo.XMPP_DOMAIN" + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppConnectionEnum.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppConnectionEnum.kt new file mode 100644 index 0000000..e1c4dcd --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppConnectionEnum.kt @@ -0,0 +1,18 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +enum class XmppConnectionEnum { Client, Service } diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppProvider.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppProvider.kt new file mode 100644 index 0000000..7f9d87c --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppProvider.kt @@ -0,0 +1,390 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import org.jitsi.impl.protocol.xmpp.log.PacketDebugger +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.metrics.GlobalMetrics +import org.jitsi.jicofo.xmpp.XmppProvider.RoomExistsException +import org.jitsi.jicofo.xmpp.muc.ChatRoom +import org.jitsi.jicofo.xmpp.muc.ChatRoomImpl +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.ConnectionConfiguration +import org.jivesoftware.smack.ConnectionListener +import org.jivesoftware.smack.ReconnectionListener +import org.jivesoftware.smack.ReconnectionManager +import org.jivesoftware.smack.SmackException +import org.jivesoftware.smack.XMPPConnection +import org.jivesoftware.smack.tcp.XMPPTCPConnection +import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration +import org.jivesoftware.smackx.caps.EntityCapsManager +import org.jivesoftware.smackx.disco.ServiceDiscoveryManager +import org.jxmpp.jid.DomainBareJid +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.EntityFullJid +import java.security.cert.X509Certificate +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Level +import javax.net.ssl.X509TrustManager + +/** Wraps a Smack [XMPPConnection]. */ +class XmppProvider(val config: XmppConnectionConfig, parentLogger: Logger) { + private val logger: Logger = createChildLogger(parentLogger).apply { + addContext("xmpp_connection", config.name) + } + + /** A task which will keep trying to connect to the XMPP server at a fixed 1-second delay. Smack's + * ReconnectionManager only kicks in once a connection suceeds. */ + private var connectTask: ScheduledFuture<*>? = null + private val connectSyncRoot = Any() + + /** A list of all listeners registered with this instance. */ + private val listeners = CopyOnWriteArraySet() + + /** The Smack [XMPPConnection] used by this instance. */ + val xmppConnection = createXmppConnection(config, logger) + + private val started = AtomicBoolean(false) + var registered = false + private set(value) { + if (value != field) { + field = value + fireRegistrationStateChanged(value) + } + } + + var components: Set = emptySet() + private set(value) { + if (value != field) { + field = value + logger.warn("Discovered components: $field") + fireComponentsChanged(value) + } + } + + private val muc = Muc(this) + + /** Listens to re-connection status updates. */ + private val reconnectionListener = object : ReconnectionListener { + override fun reconnectingIn(seconds: Int) { + logger.info("XMPP reconnecting in: $seconds seconds.") + } + + override fun reconnectionFailed(e: Exception) { + logger.error("XMPP reconnection failed: ${e.message}", e) + + if (xmppConnection.isConnected) { + xmppConnection.disconnect() + + // In some after cases with Stream Management Smack's ReconnectionManager gives up. Make sure we keep + // trying. + scheduleConnectTask() + } + } + } + + /** Start/restart the connect task. */ + private fun scheduleConnectTask() = synchronized(connectSyncRoot) { + val delay = 1L + connectTask?.cancel(true) + connectTask = TaskPools.scheduledPool.scheduleAtFixedRate({ + try { + if (doConnect()) { + logger.warn("Failed to connect, will re-try after $delay second") + } else { + logger.info("Connected.") + synchronized(connectSyncRoot) { + connectTask?.cancel(false) + connectTask = null + } + } + } catch (e: Exception) { + logger.error("Failed to connect: ${e.message}, will re-try after $delay second", e) + } + }, 0, delay, TimeUnit.SECONDS) + } + + private val connectionListener = object : ConnectionListener { + override fun authenticated(connection: XMPPConnection?, resumed: Boolean) { + registered = true + logger.info( + "Registered (resumed=$resumed)." + if (connection is XMPPTCPConnection) { + " isSmEnabled:" + connection.isSmEnabled + + " isSmAvailable:" + connection.isSmAvailable + + " isSmResumptionPossible:" + connection.isSmResumptionPossible + } else { + "" + } + ) + + config.xmppDomain?.let { + logger.info("Will discover components for $it") + TaskPools.ioPool.submit { discoverComponents(it) } + } ?: run { + logger.info("No xmpp-domain configured, will not discover components.") + } + } + + override fun connectionClosed() { + logger.info("XMPP connection closed") + GlobalMetrics.xmppDisconnects.inc() + registered = false + } + + override fun connectionClosedOnError(e: Exception) { + logger.error("XMPP connection closed on error: ${e.message}", e) + GlobalMetrics.xmppDisconnects.inc() + registered = false + } + } + + override fun toString(): String = "XmppProvider[$config]" + + fun start() { + if (!started.compareAndSet(false, true)) { + logger.info("Already started.") + } else { + scheduleConnectTask() + } + } + + fun shutdown() { + if (!started.compareAndSet(true, false)) { + logger.info("Already stopped or not started.") + } else { + synchronized(this) { + xmppConnection.disconnect() + logger.info("Disconnected.") + xmppConnection.removeConnectionListener(connectionListener) + } + registered = false + } + } + + /** + * Registers the specified listener with this provider so that it would receive notifications on changes of its + * state. + */ + fun addListener(listener: Listener) = listeners.add(listener) + + /** Removes the specified listener. */ + fun removeListener(listener: Listener) = listeners.remove(listener) + + /** + * Tries to establish the XMPP connection. + * + * @return `true` if another attempt should be performed, and `false` otherwise. Returns `false` (i.e. success) if + * either the connection was successful or the provider hasn't been started. + */ + private fun doConnect(): Boolean { + if (!started.get()) { + return false + } + synchronized(this) { + return try { + xmppConnection.connect() + logger.info("Connected, JID=${xmppConnection.user}") + + // XXX Is there a reason we add listeners *after* we call connect()? + xmppConnection.addConnectionListener(connectionListener) + ReconnectionManager.getInstanceFor(xmppConnection)?.addReconnectionListener(reconnectionListener) + if (config.password != null) { + val login = config.username.toString() + val pass = config.password + val resource = config.resource + xmppConnection.login(login, pass, resource) + } + false + } catch (e: java.lang.Exception) { + logger.error("Failed to connect/login: ${e.message}", e) + // If the connect part succeeded, but login failed we don't want to + // rely on Smack's built-in retries, as it will be handled by + // the RetryStrategy + xmppConnection.removeConnectionListener(connectionListener) + ReconnectionManager.getInstanceFor(xmppConnection)?.removeReconnectionListener(reconnectionListener) + if (xmppConnection.isConnected) { + xmppConnection.disconnect() + } + true + } + } + } + + private fun fireComponentsChanged(components: Set) = listeners.forEach { + TaskPools.ioPool.submit { + try { + it.componentsChanged(components) + } catch (throwable: Throwable) { + logger.error("An error occurred while executing componentsChanged() on $it", throwable) + } + } + } + + private fun fireRegistrationStateChanged(registered: Boolean) { + listeners.forEach { + TaskPools.ioPool.submit { + try { + it.registrationChanged(registered) + } catch (throwable: Throwable) { + logger.error("An error occurred while executing registrationStateChanged() on $it", throwable) + } + } + } + + if (registered) { + xmppConnection.replyTimeout = config.replyTimeout.toMillis() + logger.info("Set replyTimeout=${config.replyTimeout}") + } + } + + @Throws(RoomExistsException::class) + fun createRoom(name: EntityBareJid): ChatRoom = muc.createChatRoom(name, null) + fun findOrCreateRoom(name: EntityBareJid, logLevel: Level): ChatRoom = muc.findOrCreateRoom(name, logLevel) + + fun discoverFeatures(jid: EntityFullJid): FeatureDiscoveryResult { + if (!xmppConnection.isConnected) { + logger.error("Can not discover features, not connected.") + return featuresNotDiscovered + } + val discoveryManager = ServiceDiscoveryManager.getInstanceFor(xmppConnection) + if (discoveryManager == null) { + logger.error("Can not discover features, no ServiceDiscoveryManager") + return featuresNotDiscovered + } + + val start = System.currentTimeMillis() + val featureStrings: List = try { + discoveryManager.discoverInfo(jid)?.features?.map { it.`var` }?.toList() ?: emptyList() + } catch (e: SmackException.NoResponseException) { + logger.info("No response for disco#info, assuming default features.") + return featuresNotDiscovered + } catch (e: Exception) { + logger.warn("Failed to discover features for $jid: ${e.message}, assuming default feature set.", e) + return featuresNotDiscovered + } + + logger.info("Discovered features for $jid in ${System.currentTimeMillis() - start} ms.") + val features = featureStrings.mapNotNull { Features.parseString(it) }.toSet() + if (features.size != featureStrings.size) { + val unrecognizedFeatures = featureStrings - features.map { it.value }.toSet() + logger.info("Unrecognized features for $jid: $unrecognizedFeatures") + } + return FeatureDiscoveryResult(features, true) + } + + private fun discoverComponents(domain: DomainBareJid) { + val discoveryManager = ServiceDiscoveryManager.getInstanceFor(xmppConnection) + + val components: Set = if (discoveryManager == null) { + logger.info("Can not discover components, no ServiceDiscoveryManager") + emptySet() + } else { + try { + discoveryManager.discoverInfo(domain)?.identities + ?.filter { it.category == "component" } + ?.map { Component(it.type, it.name) }?.toSet() ?: emptySet() + } catch (e: Exception) { + logger.warn("Failed to discover info", e) + emptySet() + } + } + + this.components = components + } + + class RoomExistsException(message: String) : Exception(message) + interface Listener { + fun registrationChanged(registered: Boolean) { } + fun componentsChanged(components: Set) { } + } + data class Component(val type: String, val address: String) +} + +/** Create the Smack [AbstractXMPPConnection] based on the specified config. */ +private fun createXmppConnection(config: XmppConnectionConfig, logger: Logger): AbstractXMPPConnection { + val connConfig = XMPPTCPConnectionConfiguration.builder().apply { + setHost(config.hostname) + setPort(config.port) + setXmppDomain(config.domain) + if (PacketDebugger.isEnabled()) { + // If XMPP debug logging is enabled, insert our debugger. + setDebuggerFactory { PacketDebugger(it, config.name) } + } + if (!config.useTls) { + setSecurityMode(ConnectionConfiguration.SecurityMode.disabled) + } else { + /* TODO - make this required except on localhost. */ + setSecurityMode(ConnectionConfiguration.SecurityMode.ifpossible) + } + if (config.password == null) { + performSaslAnonymousAuthentication() + } + if (config.disableCertificateVerification) { + logger.warn("Disabling TLS certificate verification!") + setCustomX509TrustManager(object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = emptyArray() + }) + setHostnameVerifier { _, _ -> true } + } + } + + val connection = XMPPTCPConnection(connConfig.build()) + + // This can be removed once all clients are updated reading this from the presence conference property + ServiceDiscoveryManager.getInstanceFor(connection).addFeature("https://jitsi.org/meet/jicofo/terminate-restart") + EntityCapsManager.getInstanceFor(connection).enableEntityCaps() + return connection +} + +private class Muc(val xmppProvider: XmppProvider) { + /** The active chat rooms mapped by their name. */ + private val rooms: MutableMap = HashMap() + + @Throws(RoomExistsException::class) + fun createChatRoom(roomJid: EntityBareJid, logLevel: Level? = Level.ALL): ChatRoom { + synchronized(rooms) { + if (rooms.containsKey(roomJid)) { + throw RoomExistsException("Room '$roomJid' exists") + } + return ChatRoomImpl(xmppProvider, roomJid, logLevel) { removeRoom(it) }.also { + rooms[roomJid] = it + } + } + } + + fun findOrCreateRoom(roomJid: EntityBareJid, logLevel: Level = Level.ALL): ChatRoom { + synchronized(rooms) { + try { + return rooms[roomJid] ?: createChatRoom(roomJid, logLevel) + } catch (e: RoomExistsException) { + throw RuntimeException("Unexpected RoomExistsException.") + } + } + } + + fun removeRoom(chatRoom: ChatRoomImpl) = synchronized(rooms) { + rooms.remove(chatRoom.roomJid) + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleIqRequestHandler.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleIqRequestHandler.kt new file mode 100644 index 0000000..d6baaaf --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleIqRequestHandler.kt @@ -0,0 +1,69 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.jingle + +import org.jitsi.jicofo.util.WeakValueMap +import org.jitsi.jicofo.xmpp.AbstractIqHandler +import org.jitsi.jicofo.xmpp.IqProcessingResult +import org.jitsi.jicofo.xmpp.IqProcessingResult.RejectedWithError +import org.jitsi.jicofo.xmpp.IqRequest +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.jingle.JingleIQ +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError + +/** + * Maintain a weak map of [JingleSession]s and route incoming Jingle IQs to the associated session. + * @author Pawel Domas + */ +class JingleIqRequestHandler( + connections: Set +) : AbstractIqHandler(connections, JingleIQ.ELEMENT, JingleIQ.NAMESPACE, setOf(IQ.Type.set)) { + private val logger = createLogger() + + /** The list of active Jingle sessions. */ + private val sessions = WeakValueMap() + + override fun handleRequest(request: IqRequest): IqProcessingResult { + val session = sessions.get(request.iq.sid) + if (session == null) { + logger.warn("No session found for SID ${request.iq.sid}") + return RejectedWithError( + IQ.createErrorResponse( + request.iq, + StanzaError.getBuilder(StanzaError.Condition.bad_request).build() + ) + ) + } + + return session.processIq(request.iq) + } + + fun registerSession(session: JingleSession) { + val existingSession = sessions.get(session.sid) + if (existingSession != null) { + logger.warn("Replacing existing session with SID ${session.sid}") + } + sessions.put(session.sid, session) + } + + fun removeSession(session: JingleSession) { + sessions.remove(session.sid) + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleRequestHandler.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleRequestHandler.kt new file mode 100644 index 0000000..ce8013a --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleRequestHandler.kt @@ -0,0 +1,90 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.jingle + +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension +import org.jitsi.xmpp.extensions.jingle.JingleIQ +import org.jivesoftware.smack.packet.StanzaError + +/** + * Listener class notified about Jingle requests received during a session. + * + * @author Pawel Domas + */ +interface JingleRequestHandler { + /** + * A 'source-add' IQ was received. + * + * @return a [StanzaError] if an error should be returned as response to the original request or null if + * processing was successful. + */ + fun onAddSource(jingleSession: JingleSession, contents: List): StanzaError? = null + + /** + * A 'source-remove' IQ was received. + * + * @return a [StanzaError] if an error should be returned as response to the original request or null if + * processing was successful. + */ + fun onRemoveSource(jingleSession: JingleSession, contents: List): StanzaError? = null + + /** + * A 'session-accept' IQ was received. + * + * @return a [StanzaError] if an error should be returned as response to the original request or null if + * processing was successful. + */ + fun onSessionAccept(jingleSession: JingleSession, contents: List): StanzaError? = null + + /** + * A 'session-info' IQ was received. + * + * @return a [StanzaError] if an error should be returned as response to the original request or null if + * processing was successful. + */ + fun onSessionInfo(jingleSession: JingleSession, iq: JingleIQ): StanzaError? = null + + /** + * A 'session-terminate' IQ was received. + * + * @return a [StanzaError] if an error should be returned as response to the original request or null if + * processing was successful. + */ + fun onSessionTerminate(jingleSession: JingleSession, iq: JingleIQ): StanzaError? = null + + /** + * A 'transport-info' IQ was received. + */ + fun onTransportInfo(jingleSession: JingleSession, contents: List): StanzaError? = null + + /** + * A 'transport-accept' IQ was received. + * + * @return a [StanzaError] if an error should be returned as response to the original request or null if + * processing was successful. + */ + fun onTransportAccept(jingleSession: JingleSession, contents: List): StanzaError? = null + + /** + * A 'transport-reject' IQ was received. + */ + fun onTransportReject(jingleSession: JingleSession, iq: JingleIQ) { } +} + +/** Export a default impl so it can be used in java without -Xjvm-default */ +class NoOpJingleRequestHandler : JingleRequestHandler diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleSession.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleSession.kt new file mode 100644 index 0000000..f44f63f --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleSession.kt @@ -0,0 +1,472 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.jingle + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.api.trace.Tracer +import io.opentelemetry.context.Context +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.conference.source.ConferenceSourceMap +import org.jitsi.jicofo.xmpp.IqProcessingResult +import org.jitsi.jicofo.xmpp.createSessionInitiate +import org.jitsi.jicofo.xmpp.createTransportReplace +import org.jitsi.jicofo.xmpp.sendIqAndGetResponse +import org.jitsi.jicofo.xmpp.tryToSendStanza +import org.jitsi.tracing.TracingGlobal +import org.jitsi.utils.MediaType +import org.jitsi.utils.logging2.createLogger +import org.jitsi.utils.queue.PacketQueue +import org.jitsi.xmpp.extensions.TraceParent +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension +import org.jitsi.xmpp.extensions.jingle.GroupPacketExtension +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension +import org.jitsi.xmpp.extensions.jingle.JingleAction +import org.jitsi.xmpp.extensions.jingle.JingleIQ +import org.jitsi.xmpp.extensions.jingle.JinglePacketFactory +import org.jitsi.xmpp.extensions.jingle.Reason +import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.ClientVersionPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.JsonMessageExtension +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.SmackException +import org.jivesoftware.smack.packet.ExtensionElement +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import org.jxmpp.jid.Jid +import java.util.Objects + +/** + * Extracts a remote [Span] from the `traceparent` extension of an IQ, if present. + * + * Inlined from jicoco-tracing's TracingUtil, which was removed because it pulled in a Smack + * dependency that isn't available on Maven Central: https://github.com/jitsi/jicoco/pull/241 + */ +private fun remoteSpanFromIq(iq: IQ): Span? { + val extension = iq.getExtension(TraceParent::class.java) ?: return null + return Span.wrap( + SpanContext.createFromRemoteParent( + extension.traceId, + extension.parentId, + TraceFlags.fromHex(extension.traceFlags, 0), + TraceState.getDefault() + ) + ) +} + +/** + * Extracts a remote [Context] from the `traceparent` extension of an IQ, if present, or the root + * context otherwise. + */ +private fun remoteContextFromIq(iq: IQ): Context { + val root = Context.root() + val span = remoteSpanFromIq(iq) ?: return root + return root.with(span) +} + +/** + * Class describes Jingle session. + * + * @author Pawel Domas + * @author Lyubomir Marinov + */ +class JingleSession( + /** Jingle session identifier. */ + val sid: String, + /** Remote peer XMPP address. */ + val remoteJid: Jid, + private val jingleIqRequestHandler: JingleIqRequestHandler, + private val connection: AbstractXMPPConnection, + private val requestHandler: JingleRequestHandler, + private val encodeSourcesAsJson: Boolean +) { + private var state = State.PENDING + fun isActive() = state == State.ACTIVE + + val logger = createLogger().apply { + addContext("remoteJid", remoteJid.toString()) + addContext("sid", sid) + } + val tracer: Tracer = TracingGlobal.sdk.getTracer("org.jitsi.jicofo.jingle") + + private val incomingIqQueue = PacketQueue( + Integer.MAX_VALUE, + true, + "jingle-iq-queue", + { iq -> + val span = tracer.spanBuilder("jingle.${iq.action}") + .setParent(remoteContextFromIq(iq)) + .setAttribute("initiator.id", Objects.toString(iq.initiator)) + .setAttribute("responder.id", Objects.toString(iq.responder)) + .setAttribute("session.id", Objects.toString(iq.sid)) + .startSpan() + try { + span.makeCurrent().use { + doProcessIq(iq) + } + } catch (e: Throwable) { + span.setStatus(StatusCode.ERROR, e.message ?: "") + throw e + } finally { + span.end() + } + return@PacketQueue true + }, + TaskPools.ioPool + ) + + private val localJid: Jid = connection.user + + fun processIq(iq: JingleIQ): IqProcessingResult { + val action = iq.action + ?: return IqProcessingResult.RejectedWithError( + IQ.createErrorResponse( + iq, + StanzaError.getBuilder(StanzaError.Condition.bad_request) + .setConditionText("Missing 'action'").build() + ) + ) + JingleStats.stanzaReceived(action) + + if (state == State.ENDED) { + return IqProcessingResult.RejectedWithError( + IQ.createErrorResponse( + iq, + StanzaError.getBuilder(StanzaError.Condition.gone).setConditionText("session ended").build() + ) + ) + } + + incomingIqQueue.add(iq) + return IqProcessingResult.AcceptedWithNoResponse() + } + + /** The version of the client, if it included one in its session-accept. */ + var clientVersion: String? = null + private set + + private fun doProcessIq(iq: JingleIQ) { + val error = when (iq.action) { + JingleAction.SESSION_ACCEPT -> { + // The client includes its version in the session-accept that it sends to us. + iq.getExtension(ClientVersionPacketExtension::class.java)?.version?.let { + clientVersion = it + logger.info("Client version: $it") + } + // The session needs to be marked as active early to allow code executing as part of onSessionAccept + // to proceed (e.g. to signal source updates). + state = State.ACTIVE + val error = requestHandler.onSessionAccept(this, iq.contentList) + if (error != null) state = State.ENDED + error + } + + JingleAction.SESSION_INFO -> requestHandler.onSessionInfo(this, iq) + + JingleAction.SESSION_TERMINATE -> requestHandler.onSessionTerminate(this, iq).also { + state = State.ENDED + } + + JingleAction.TRANSPORT_ACCEPT -> requestHandler.onTransportAccept(this, iq.contentList) + + JingleAction.TRANSPORT_INFO -> requestHandler.onTransportInfo(this, iq.contentList) + + JingleAction.TRANSPORT_REJECT -> { + requestHandler.onTransportReject(this, iq) + null + } + + JingleAction.ADDSOURCE, JingleAction.SOURCEADD -> requestHandler.onAddSource(this, iq.contentList) + + JingleAction.REMOVESOURCE, JingleAction.SOURCEREMOVE -> requestHandler.onRemoveSource( + this, + iq.contentList + ) + + else -> { + logger.warn("unsupported action ${iq.action}") + StanzaError.getBuilder(StanzaError.Condition.feature_not_implemented) + .setConditionText("Unsupported 'action'").build() + } + } + + val response = if (error == null) { + IQ.createResultIQ(iq) + } else { + logger.info("Returning error: request=${iq.toXML()}, error=${error.toXML()} ") + IQ.createErrorResponse(iq, error) + } + connection.tryToSendStanza(response) + } + + fun terminate( + reason: Reason, + message: String?, + /** Whether to send a session-terminate IQ, or only terminate the session locally. */ + sendIq: Boolean + ) { + logger.debug("Terminating session with $remoteJid, reason=$reason, sendIq=$sendIq") + val oldState = state + state = State.ENDED + + if (oldState == State.ENDED) logger.warn("Terminating session which is already in state ENDED") + + if (sendIq) { + if (oldState == State.ENDED) { + logger.warn("Not sending session-terminate for session in state $state") + } else { + val terminate = JinglePacketFactory.createSessionTerminate( + localJid, + remoteJid, + sid, + reason, + message + ) + connection.tryToSendStanza(terminate) + JingleStats.stanzaSent(JingleAction.SESSION_TERMINATE) + } + } + + jingleIqRequestHandler.removeSession(this) + } + + /** + * Send a transport-replace IQ and wait for a response. Return true if the response is successful, and false + * otherwise. + */ + @Throws(SmackException.NotConnectedException::class) + fun replaceTransport( + contents: List, + additionalExtensions: List, + sources: ConferenceSourceMap + ): Boolean { + logger.info("Sending transport-replace, sources=$sources.") + if (state != State.ACTIVE) logger.error("Sending transport-replace for session in state $state") + + val contentsWithSources = if (encodeSourcesAsJson) contents else sources.toContents(contents) + val jingleIq = createTransportReplace(localJid, this, contentsWithSources) + + jingleIq.addExtension(GroupPacketExtension.createBundleGroup(jingleIq.contentList)) + additionalExtensions.forEach { jingleIq.addExtension(it) } + if (encodeSourcesAsJson) { + jingleIq.addExtension(sources.toJsonMessageExtension()) + } + + JingleStats.stanzaSent(jingleIq.action) + val response = connection.sendIqAndGetResponse(jingleIq) + return if (response?.type == IQ.Type.result) { + true + } else { + logger.error("Unexpected response to transport-replace: ${response?.toXML()}") + false + } + } + + /** + * Send a source-remove IQ with the specified sources. Returns immediately without waiting for a response. + */ + fun removeSource(sourcesToRemove: ConferenceSourceMap) { + val removeSourceIq = JingleIQ(JingleAction.SOURCEREMOVE, sid).apply { + from = localJid + type = IQ.Type.set + to = remoteJid + } + + if (encodeSourcesAsJson) { + removeSourceIq.addExtension(sourcesToRemove.toJsonMessageExtension()) + } else { + sourcesToRemove.toJingle().forEach { removeSourceIq.addContent(it) } + } + logger.debug { "Sending source-remove, sources=$sourcesToRemove" } + if (state != State.ACTIVE) logger.error("Sending source-remove for session in state $state") + connection.tryToSendStanza(removeSourceIq) + JingleStats.stanzaSent(JingleAction.SOURCEREMOVE) + } + + /** + * Send a transport-info IQ carrying the bridge's transport. Used after the bridge performed an ICE restart + * and rotated its ICE credentials: the client's connectivity checks are addressed to those credentials, so + * it has to be told the new ones. Returns immediately without waiting for a response. + * + * The transport is sent on a single content; the client applies the credentials to its whole (bundled) + * transport, so which content carries them does not matter. + */ + fun sendTransportInfo(transport: IceUdpTransportPacketExtension) { + val transportInfoIq = JingleIQ(JingleAction.TRANSPORT_INFO, sid).apply { + from = localJid + type = IQ.Type.set + to = remoteJid + addContent( + ContentPacketExtension().apply { + creator = ContentPacketExtension.CreatorEnum.initiator + name = "audio" + addChildExtension(IceUdpTransportPacketExtension.cloneTransportAndCandidates(transport, true)) + } + ) + } + + logger.info( + "ICE restart: sending transport-info with the bridge's transport, generation=${transport.iceGeneration}, " + + "ufrag=${transport.ufrag}" + ) + if (state != State.ACTIVE) logger.error("Sending transport-info for session in state $state") + connection.tryToSendStanza(transportInfoIq) + JingleStats.stanzaSent(JingleAction.TRANSPORT_INFO) + } + + /** + * Send a source-add IQ with the specified sources. Returns immediately without waiting for a response. + */ + fun addSource(sources: ConferenceSourceMap) { + logger.debug { "Sending source-add, sources=$sources" } + JingleStats.stanzaSent(JingleAction.SOURCEADD) + if (state != State.ACTIVE) logger.error("Sending source-add for session in state $state") + connection.tryToSendStanza(createAddSourceIq(sources)) + } + + @Throws(SmackException.NotConnectedException::class) + fun initiateSession( + contents: List, + additionalExtensions: List, + sources: ConferenceSourceMap, + ): Boolean { + if (state != State.PENDING) logger.error("Sending session-initiate for session in state $state") + val contentsWithSources = if (encodeSourcesAsJson) contents else sources.toContents(contents) + val sessionInitiate = createSessionInitiate(localJid, remoteJid, sid, contentsWithSources).apply { + addExtension(GroupPacketExtension.createBundleGroup(contentList)) + additionalExtensions.forEach { addExtension(it) } + if (encodeSourcesAsJson) { + addExtension(sources.toJsonMessageExtension()) + } + Span.fromContextOrNull(Context.current())?.let { + addExtension( + TraceParent( + it.spanContext.traceId, + it.spanContext.spanId, + it.spanContext.traceFlags.asHex() + ) + ) + } + } + + jingleIqRequestHandler.registerSession(this) + JingleStats.stanzaSent(sessionInitiate.action) + val response = connection.sendIqAndGetResponse(sessionInitiate) + // We treat a timeout (null) as success. This prevents failures in case the client delays processing, observed + // when joining a large conference. The session will be pending until we receive session-accept. + return if (response == null || response.type == IQ.Type.result) { + true + } else { + logger.error("Unexpected response to session-initiate: $response") + false + } + } + + private fun createAddSourceIq(sources: ConferenceSourceMap) = JingleIQ(JingleAction.SOURCEADD, sid).apply { + from = localJid + type = IQ.Type.set + to = remoteJid + if (encodeSourcesAsJson) { + addExtension(sources.toJsonMessageExtension()) + } else { + sources.toJingle().forEach { addContent(it) } + } + } + + fun debugState(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + put("sid", sid) + put("remoteJid", remoteJid.toString()) + put("state", state.toString()) + put("client_version", clientVersion) + } + + enum class State { PENDING, ACTIVE, ENDED } +} + +/** + * Encodes the sources described in `sources` as a [JsonMessageExtension] in the compact JSON format + * (see [ConferenceSourceMap.compactJson]). + * @return the [JsonMessageExtension] encoding `sources`. + */ +fun ConferenceSourceMap.toJsonMessageExtension() = JsonMessageExtension("{\"sources\":${compactJson()}}") + +/** + * Encodes the sources described in `sources` in the list of Jingle contents. If necessary, new + * [ContentPacketExtension]s are created. Returns the resulting list of [ContentPacketExtension] which + * contains the encoded sources. + * + * @return the resulting list of [ContentPacketExtension], which consists of `contents` plus any new + * [ContentPacketExtension]s that were created. + */ +private fun ConferenceSourceMap.toContents( + /** The list of existing [ContentPacketExtension] to which to add sources if possible. */ + existingContents: List +): List { + val ret = mutableListOf() + var audioContent = existingContents.find { it.name == "audio" } + var videoContent = existingContents.find { it.name == "video" } + + audioContent?.let { ret.add(it) } + videoContent?.let { ret.add(it) } + + val audioSourceExtensions = createSourcePacketExtensions(MediaType.AUDIO) + val audioSsrcGroupExtensions = createSourceGroupPacketExtensions(MediaType.AUDIO) + if (audioSourceExtensions.isNotEmpty() || audioSsrcGroupExtensions.isNotEmpty()) { + if (audioContent == null) { + audioContent = ContentPacketExtension().apply { name = "audio" } + ret.add(audioContent) + } + var audioDescription = audioContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java) + if (audioDescription == null) { + audioDescription = RtpDescriptionPacketExtension().apply { media = "audio" } + audioContent.addChildExtension(audioDescription) + } + for (extension in audioSourceExtensions) { + audioDescription.addChildExtension(extension) + } + for (extension in audioSsrcGroupExtensions) { + audioDescription.addChildExtension(extension) + } + } + + val videoSourceExtensions = createSourcePacketExtensions(MediaType.VIDEO) + val videoSsrcGroupExtensions = createSourceGroupPacketExtensions(MediaType.VIDEO) + if (videoSourceExtensions.isNotEmpty() || videoSsrcGroupExtensions.isNotEmpty()) { + if (videoContent == null) { + videoContent = ContentPacketExtension().apply { name = "video" } + ret.add(videoContent) + } + var videoDescription = videoContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java) + if (videoDescription == null) { + videoDescription = RtpDescriptionPacketExtension().apply { media = "video" } + videoContent.addChildExtension(videoDescription) + } + for (extension in videoSourceExtensions) { + videoDescription.addChildExtension(extension) + } + for (extension in videoSsrcGroupExtensions) { + videoDescription.addChildExtension(extension) + } + } + return ret +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleStats.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleStats.kt new file mode 100644 index 0000000..fd4c826 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/jingle/JingleStats.kt @@ -0,0 +1,68 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jicofo.xmpp.jingle + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.metrics.CounterMetric +import org.jitsi.xmpp.extensions.jingle.JingleAction +import java.util.concurrent.ConcurrentHashMap + +private val jsonMapper = jacksonObjectMapper() + +class JingleStats { + companion object { + private val stanzasReceivedByAction: MutableMap = ConcurrentHashMap() + private val stanzasSentByAction: MutableMap = ConcurrentHashMap() + + @JvmStatic + fun stanzaReceived(action: JingleAction) { + stanzasReceivedByAction.computeIfAbsent(action) { + JicofoMetricsContainer.instance.registerCounter( + "jingle_${action.name.lowercase()}_received", + "Number of ${action.name} stanzas received" + ) + }.inc() + } + + @JvmStatic + fun stanzaSent(action: JingleAction) { + stanzasSentByAction.computeIfAbsent(action) { + JicofoMetricsContainer.instance.registerCounter( + "jingle_${action.name.lowercase()}_sent", + "Number of ${action.name} stanzas sent." + ) + }.inc() + } + + @JvmStatic + fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + set( + "sent", + jsonMapper.valueToTree(stanzasSentByAction.map { it.key to it.value.get() }.toMap()) + ) + set( + "received", + jsonMapper.valueToTree(stanzasReceivedByAction.map { it.key to it.value.get() }.toMap()) + ) + } + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoom.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoom.kt new file mode 100644 index 0000000..4b36e83 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoom.kt @@ -0,0 +1,213 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.MediaType +import org.jitsi.jicofo.xmpp.RoomMetadata +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jivesoftware.smack.SmackException +import org.jivesoftware.smack.XMPPException +import org.jivesoftware.smack.packet.ExtensionElement +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.Jid + +/** Wraps a Smack [MultiUserChat] for the purposes of jicofo. */ +interface ChatRoom { + val xmppProvider: XmppProvider + + /** The JID of the chat room. */ + val roomJid: EntityBareJid + + /** Whether the local user is currently in the multi user chat (after calling one of the [.join] methods). */ + val isJoined: Boolean + + /** + * Returns the list of members of this [ChatRoom]. Note that this does not include a representation of the local + * user as an occupant of the MUC. + */ + val members: List + + /** The size of [members], exposed separately for performance (avoid creating a new list just to get the count) */ + val memberCount: Int + + /** + * The number of [members] with role VISITOR. Exposed separately for performance (avoid creating a new list + * just to get the count). Also includes visitors who have joined within the last vnode-join-latency-interval, + * as reported by [visitorInvited]. */ + val visitorCount: Int + + /** Whether a lobby is enabled for the room. Read from the MUC config form. */ + val lobbyEnabled: Boolean + + /** Whether the visitors feature is enabled for the room. Read from the MUC config form. */ + val visitorsEnabled: Boolean? + + /** Whether the visitorsLive flag is enabled for the room. Read from the MUC config form. */ + val visitorsLive: Boolean + + /** The number of participants in the room after which new endpoints should be redirected to visitors. + * Read from the MUC config form. */ + val participantsSoftLimit: Int? + + /** Transcription configuration from room metadata. */ + val transcription: RoomMetadata.Metadata.Transcription? + + /** + * Whether the room is configured for async (backend/proxy) transcription rather than a live Jigasi + * transcriber. Read from room metadata. When true, a client-initiated transcriber dial-out request + * should be rejected -- the client is expected to enable transcription via room metadata instead. + */ + val asyncTranscription: Boolean + + /** Translation configuration from room metadata (e.g. per-customer connect headers). */ + val translation: RoomMetadata.Metadata.Translation? + + /** + * The languages the visitors want transcriptions translated into, from room metadata. + * + * Visitor presence is not visible in this room, so their requests arrive this way instead. Use + * [getTranslationLanguages] to get the whole room's set. + */ + val visitorTranslationLanguages: Set + + /** + * All the languages this room wants transcriptions translated into: the members' requested languages together + * with [visitorTranslationLanguages]. + */ + fun getTranslationLanguages(): Set = translationLanguages(members, visitorTranslationLanguages) + + companion object { + /** + * The union of the languages [members] request and the [visitorLanguages] the visitors request. Split out + * from [getTranslationLanguages] so it can be tested without an implementation of this interface. + */ + fun translationLanguages(members: List, visitorLanguages: Set): Set = + members.mapNotNullTo(mutableSetOf()) { it.translationLanguage } + visitorLanguages + } + + val debugState: ObjectNode + + /** Returns the number of members that currently have their audio sources unmuted. */ + var audioSendersCount: Int + + /** Returns the number of members that currently have their video sources unmuted. */ + var videoSendersCount: Int + + /** + * Whether a user with a certain ID and a certain group ID would be allowed to join the main room. Note that this + * does not actually perform authentication (i.e. if the user has a valid claim for the user ID and group ID), + * just checks the MUC configuration. + */ + fun isAllowedInMainRoom(userId: String?, groupId: String?): Boolean + + /** + * Whether a user with a certain ID and a certain group ID is preferred in the main room, i.e. is explicitly listed + * in the list of participants or moderators for the main room. + */ + fun isPreferredInMainRoom(userId: String?, groupId: String?): Boolean + + /** + * Joins this chat room with the preconfigured nickname. Returns the fields read from the MUC config form after + * joining. + */ + @Throws(SmackException::class, XMPPException::class, InterruptedException::class) + fun join(): ChatRoomInfo + + /** Leave the chat room. */ + fun leave() + + /** + * Grants ownership privileges to another user. Room owners may grant + * ownership privileges. Some room implementations will not allow to grant + * ownership privileges to other users. An owner is allowed to change + * defining room features as well as perform all administrative functions. + * + * @param member the member to grant ownership to. + */ + fun grantOwnership(member: ChatRoomMember) + + /** + * Get the [ChatRoomMember] for a given MUC occupant jid. + * + * @param occupantJid full MUC jid of the user for whom we want to find chat + * member instance. Ex. chatroom1@muc.server.com/nick1234 + */ + fun getChatMember(occupantJid: EntityFullJid): ChatRoomMember? + + /** Add all of [extensions] to our presence. */ + fun addPresenceExtensions(extensions: Collection) + + /** Add [extension] to our presence, no-op if we already have an extension with the same QName. */ + fun addPresenceExtensionIfMissing(extension: ExtensionElement) + + /** Remove presence extensions matching the predicate [pred]. */ + fun removePresenceExtensions(pred: (ExtensionElement) -> Boolean) + + /** + * Add [extension] to presence and remove any previous extensions with the same QName. + * + * Note that this always sends a presence stanza. If the intention is to only send a packet if the extension was + * modified use [addPresenceExtensionIfMissing]. + */ + fun setPresenceExtension(extension: ExtensionElement) + + /** Add a [ChatRoomListener] to the list of listeners to be notified of events from this [ChatRoom]. */ + fun addListener(listener: ChatRoomListener) + + /** Removes a [ChatRoomListener] from the list of listeners to be notified of events from this [ChatRoom]. */ + fun removeListener(listener: ChatRoomListener) + + /** Removes all [ChatRoomListener]s. */ + fun removeAllListeners() + + /** Whether A/V moderation is enabled for [mediaType] (audio or video). */ + fun isAvModerationEnabled(mediaType: MediaType): Boolean + + /** Enable or disable a/v moderation for [mediaType]. */ + fun setAvModerationEnabled(mediaType: MediaType, value: Boolean) + + /** Updates the list of members that are allowed to unmute audio or video. */ + fun setAvModerationWhitelist(mediaType: MediaType, whitelist: List) + + /** Update the value in the room_metadata structure */ + fun setRoomMetadata(roomMetadata: RoomMetadata) + + /** whether the current A/V moderation setting allow the member [jid] to unmute (for a specific [mediaType]). */ + fun isMemberAllowedToUnmute(jid: Jid, mediaType: MediaType): Boolean + + /** Notify the chatroom that a visitor has been redirected to this room. + */ + fun visitorInvited() + + /** Queue a task to be executed sequentially with respect to other tasks queued through this method. */ + fun queueXmppTask(runnable: () -> Unit) +} + +/** Holds fields read from the MUC config form at join time, which never change. */ +data class ChatRoomInfo( + /** The meeting ID, or null if none is set. */ + val meetingId: String?, + /** The JID of the main room if this is a breakout room, otherwise null. */ + val mainRoomJid: EntityBareJid? +) + +/** Whether a user with a certain JID is allowed to unmute with any of [mediaTypes]. */ +fun ChatRoom.isMemberAllowedToUnmute(jid: Jid, mediaTypes: Set): Boolean = + mediaTypes.any { isMemberAllowedToUnmute(jid, it) } diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomImpl.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomImpl.kt new file mode 100644 index 0000000..824398f --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomImpl.kt @@ -0,0 +1,835 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import org.jitsi.jicofo.JicofoConfig +import org.jitsi.jicofo.MediaType +import org.jitsi.jicofo.TaskPools.Companion.ioPool +import org.jitsi.jicofo.util.PendingCount +import org.jitsi.jicofo.xmpp.RoomMetadata +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jitsi.jicofo.xmpp.muc.MemberRole.Companion.fromSmack +import org.jitsi.jicofo.xmpp.sendIqAndGetResponse +import org.jitsi.jicofo.xmpp.tryToSendStanza +import org.jitsi.utils.event.EventEmitter +import org.jitsi.utils.event.SyncEventEmitter +import org.jitsi.utils.logging2.createLogger +import org.jitsi.utils.observableWhenChanged +import org.jitsi.utils.queue.PacketQueue +import org.jivesoftware.smack.PresenceListener +import org.jivesoftware.smack.SmackException +import org.jivesoftware.smack.XMPPConnection +import org.jivesoftware.smack.XMPPException +import org.jivesoftware.smack.packet.ExtensionElement +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.Presence +import org.jivesoftware.smack.packet.PresenceBuilder +import org.jivesoftware.smack.util.Consumer +import org.jivesoftware.smackx.muc.MUCAffiliation +import org.jivesoftware.smackx.muc.MUCRole +import org.jivesoftware.smackx.muc.MultiUserChat +import org.jivesoftware.smackx.muc.MultiUserChatManager +import org.jivesoftware.smackx.muc.Occupant +import org.jivesoftware.smackx.muc.UserStatusListener +import org.jivesoftware.smackx.muc.packet.MUCAdmin +import org.jivesoftware.smackx.muc.packet.MUCInitialPresence +import org.jivesoftware.smackx.muc.packet.MUCItem +import org.jivesoftware.smackx.muc.packet.MUCUser +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.Jid +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.jid.parts.Resourcepart +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.logging.Level + +private val jsonMapper = jacksonObjectMapper() + +@SuppressFBWarnings( + value = ["JLM_JSR166_UTILCONCURRENT_MONITORENTER"], + justification = "We intentionally synchronize on [members] (a ConcurrentHashMap)." +) +class ChatRoomImpl( + override val xmppProvider: XmppProvider, + override val roomJid: EntityBareJid, + logLevel: Level?, + /** Callback to call when the room is left. */ + private val leaveCallback: (ChatRoomImpl) -> Unit +) : ChatRoom, PresenceListener { + private val logger = createLogger(minLogLevel = logLevel ?: Level.ALL).apply { + addContext("room", roomJid.toString()) + } + + /** + * Keep track of the recently added visitors. + */ + private val pendingVisitorsCounter = PendingCount( + JicofoConfig.config.vnodeJoinLatencyInterval + ) + + /** + * Latch that is counted down when the room metadata is set. + * This is used to block the join until the room metadata is available. + */ + private val roomMetadataLatch = CountDownLatch(1) + + /** + * Latch that is counted down when the room is joined. Processing presence blocks until this latch is counted down. + */ + private val roomJoinedLatch = CountDownLatch(1) + + override fun visitorInvited() { + pendingVisitorsCounter.eventPending() + } + + /** + * A queue for tasks originating from XMPP for this room which need to be processed in order. + */ + private val xmppTaskQueue = PacketQueue( + Integer.MAX_VALUE, + false, + "ChatRoomImpl presence queue", + { + try { + it.run() + } catch (e: Exception) { + logger.warn("Error processing XMPP task", e) + } + true + }, + ioPool + ) + + private val membersMap: MutableMap = ConcurrentHashMap() + override val members: List + get() = synchronized(membersMap) { return membersMap.values.toList() } + override val memberCount + get() = membersMap.size + private var visitorMemberCount: Int = 0 + override val visitorCount: Int + get() = synchronized(membersMap) { visitorMemberCount } + + pendingVisitorsCounter.getCount().toInt() + + /** Stores our last MUC presence packet for future update. */ + private var lastPresenceSent: PresenceBuilder? = null + private val memberListener: MemberListener = MemberListener() + private val userListener = LocalUserStatusListener() + + /** Listener for presence that smack sends on our behalf. */ + private var presenceInterceptor = Consumer { presenceBuilder -> + // The initial presence sent by smack contains an empty "x" + // extension. If this extension is included in a subsequent stanza, + // it indicates that the client lost its synchronization and causes + // the MUC service to re-send the presence of each occupant in the + // room. + synchronized(this@ChatRoomImpl) { + val p = presenceBuilder.build() + p.removeExtension( + MUCInitialPresence.ELEMENT, + MUCInitialPresence.NAMESPACE + ) + lastPresenceSent = p.asBuilder() + } + } + + /** Smack multi user chat backend instance. */ + private val muc: MultiUserChat = + MultiUserChatManager.getInstanceFor(xmppProvider.xmppConnection).getMultiUserChat(this.roomJid).apply { + addUserStatusListener(userListener) + addParticipantListener(this@ChatRoomImpl) + } + + override val isJoined + get() = muc.isJoined && roomJoinedLatch.count <= 0 + + /** Our full Multi User Chat XMPP address. */ + private var myOccupantJid: EntityFullJid? = null + + override var lobbyEnabled: Boolean = false + private set(value) { + if (value != field) { + logger.info("Lobby is now ${if (value) "enabled" else "disabled"}.") + field = value + } + } + + override var visitorsEnabled: Boolean? = null + private set(value) { + if (value != field) { + logger.info("Visitors is now: $value") + field = value + } + } + override var visitorsLive: Boolean = false + private set(value) { + if (value != field) { + logger.info("VisitorsLive is now: $value") + field = value + } + } + + override var participantsSoftLimit: Int? = null + private set(value) { + if (value != field) { + logger.info("ParticipantsSoftLimit is now $value.") + field = value + } + } + + /** Transcription configuration from room metadata. */ + override var transcription: RoomMetadata.Metadata.Transcription? = null + private set + + /** Translation configuration from room metadata. */ + override var translation: RoomMetadata.Metadata.Translation? = null + private set + + /** Whether the room is configured for async (backend/proxy) transcription. Read from room metadata. */ + override var asyncTranscription: Boolean = false + private set + + /** + * The languages the visitors want transcriptions translated into, from room metadata. + * + * Visitor presence is not visible in this room, so the visitors' requests reach us this way instead: each visitor + * node collects them and the main prosody aggregates them into the room metadata. Fires + * [ChatRoomListener.translationLanguagesChanged] on change, like the per-member languages do. + */ + override var visitorTranslationLanguages: Set = emptySet() + private set(value) { + if (value != field) { + logger.info("Visitor translation languages are now: $value") + field = value + eventEmitter.fireEvent { translationLanguagesChanged() } + } + } + + /** + * List of user IDs which the room is configured to allow to be moderators. + */ + private var moderators: List = emptyList() + + /** + * List of user IDs which the room is configured to allow to be moderators. + * When [null], the feature is not used, meaning that the room is open to participants. + * When empty, the feature is used and no participants are explicitly allowed (so the room requires a token, + * and users without a token should be redirected as visitors. + */ + private var participants: List? = null + + override fun isAllowedInMainRoom(userId: String?, groupId: String?): Boolean { + if (participants == null) { + // The room is open to participants. + return true + } + return isPreferredInMainRoom(userId, groupId) + } + + override fun isPreferredInMainRoom(userId: String?, groupId: String?): Boolean { + if (userId != null && (moderators.contains(userId) || participants?.contains(userId) == true)) { + // The user is explicitly allowed to join the main room. + return true + } + if (groupId != null && (moderators.contains(groupId) || participants?.contains(groupId) == true)) { + // The user is explicitly allowed to join the main room based on their group ID. + return true + } + return false + } + + private val avModerationByMediaType = ConcurrentHashMap() + + /** The emitter used to fire events. */ + private val eventEmitter: EventEmitter = SyncEventEmitter() + + /** The number of members that currently have their audio sources unmuted. */ + override var audioSendersCount by observableWhenChanged(0) { _, _, newValue -> + logger.debug { "The number of audio senders has changed to $newValue." } + eventEmitter.fireEvent { numAudioSendersChanged(newValue) } + } + + /** The number of members that currently have their video sources unmuted. */ + override var videoSendersCount by observableWhenChanged(0) { _, _, newValue -> + logger.debug { "The number of video senders has changed to $newValue." } + eventEmitter.fireEvent { numVideoSendersChanged(newValue) } + } + + override val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("room_jid", roomJid.toString()) + put("my_occupant_jid", myOccupantJid.toString()) + val membersJson = JsonNodeFactory.instance.objectNode() + membersMap.values.forEach { + membersJson.set(it.name, it.debugState) + } + set("members", membersJson) + put("audio_senders_count", audioSendersCount) + put("video_senders_count", videoSendersCount) + put("lobby_enabled", lobbyEnabled) + put("participants_soft_limit", participantsSoftLimit ?: -1) + participants?.let { + set("participants", jsonMapper.valueToTree(it)) + } + set("moderators", jsonMapper.valueToTree(moderators)) + put("visitors_enabled", visitorsEnabled?.toString() ?: "null") + put("visitors_live", visitorsLive) + put("async_transcription", asyncTranscription) + set( + "av_moderation", + JsonNodeFactory.instance.objectNode().apply { + avModerationByMediaType.forEach { (k, v) -> set(k.toString(), v.debugState) } + } + ) + } + + override fun addListener(listener: ChatRoomListener) = eventEmitter.addHandler(listener) + override fun removeListener(listener: ChatRoomListener) = eventEmitter.removeHandler(listener) + + // Use toList to avoid concurrent modification. TODO: add a removeAll to EventEmitter. + override fun removeAllListeners() = eventEmitter.eventHandlers.toList().forEach { eventEmitter.removeHandler(it) } + + private fun avModeration(mediaType: MediaType): AvModerationForMediaType = + avModerationByMediaType.computeIfAbsent(mediaType) { AvModerationForMediaType(mediaType) } + override fun isAvModerationEnabled(mediaType: MediaType) = avModeration(mediaType).enabled + override fun setAvModerationEnabled(mediaType: MediaType, value: Boolean) { + avModeration(mediaType).enabled = value + } + override fun setAvModerationWhitelist(mediaType: MediaType, whitelist: List) { + avModeration(mediaType).whitelist = whitelist + } + + override fun getChatMember(occupantJid: EntityFullJid) = membersMap[occupantJid] + override fun isMemberAllowedToUnmute(jid: Jid, mediaType: MediaType): Boolean = + avModeration(mediaType).isAllowedToUnmute(jid) + + @Throws(SmackException::class, XMPPException::class, InterruptedException::class) + override fun join(): ChatRoomInfo { + // TODO: clean-up the way we figure out what nickname to use. + resetState() + return joinAs(xmppProvider.config.username) + } + + /** + * Prepare this [ChatRoomImpl] for a call to [.joinAs], which sends initial presence to the MUC. Resets any state + * that might have been set the previous time the MUC was joined. + */ + private fun resetState() { + synchronized(membersMap) { + if (membersMap.isNotEmpty()) { + logger.warn("Removing ${membersMap.size} stale members ($visitorMemberCount stale visitors).") + membersMap.clear() + visitorMemberCount = 0 + audioSendersCount = 0 + videoSendersCount = 0 + } + } + synchronized(this) { + lastPresenceSent = null + logger.removeContext("meeting_id") + avModerationByMediaType.values.forEach { it.reset() } + } + } + + @Throws(SmackException::class, XMPPException::class, InterruptedException::class) + private fun joinAs(nickname: Resourcepart): ChatRoomInfo { + // The queue should block until the room is fully joined. + xmppTaskQueue.add { roomJoinedLatch.await() } + + myOccupantJid = JidCreate.entityFullFrom(roomJid, nickname) + synchronized(muc) { + if (muc.isJoined) { + muc.leave() + } + } + + muc.addPresenceInterceptor(presenceInterceptor) + muc.createOrJoin(nickname) + val config = muc.configurationForm + + // Make the room non-anonymous, so that others can recognize focus JID + val answer = config.fillableForm + answer.setAnswer(MucConfigFields.WHOIS, "anyone") + muc.sendConfigurationForm(answer) + + // Read the breakout room and meetingId. + val mainRoomStr = if (config.getField(MucConfigFields.IS_BREAKOUT_ROOM)?.firstValue?.toBoolean() == true) { + config.getField(MucConfigFields.MAIN_ROOM)?.firstValue + } else { + null + } + + if (config.getField(MucConfigFields.CONFERENCE_PRESET_ENABLED)?.firstValue?.toBoolean() == true) { + logger.info("Conference presets service is enabled. Will block until RoomMetadata is set.") + if (roomMetadataLatch.await(10, TimeUnit.SECONDS)) { + logger.info("RoomMetadata is set, room is fully joined.") + } else { + logger.warn("Timed out waiting for RoomMetadata to be set. Will continue without it.") + } + } + roomJoinedLatch.countDown() + + return ChatRoomInfo( + meetingId = config.getField(MucConfigFields.MEETING_ID)?.firstValue, + mainRoomJid = if (mainRoomStr == null) null else JidCreate.entityBareFrom(mainRoomStr) + ) + } + + override fun setRoomMetadata(roomMetadata: RoomMetadata) { + // The initial RoomMetadata is required to consider the room fully joined. The queue will block until that + // happens, so handle it outside the queue + if (roomJoinedLatch.count > 0L) { + ioPool.submit { doSetRoomMetadata(roomMetadata) } + } else { + queueXmppTask { doSetRoomMetadata(roomMetadata) } + } + } + + private fun doSetRoomMetadata(roomMetadata: RoomMetadata) { + logger.info("Setting room metadata: $roomMetadata") + lobbyEnabled = roomMetadata.metadata?.lobbyEnabled == true + visitorsLive = roomMetadata.metadata?.visitors?.live == true + moderators = roomMetadata.metadata?.moderators ?: emptyList() + participants = roomMetadata.metadata?.participants + // We read these fields from both the config form (for backwards compatibility) and the room metadata. Only + // override if they are set. + roomMetadata.metadata?.visitorsEnabled?.let { + visitorsEnabled = it + } + roomMetadata.metadata?.participantsSoftLimit?.let { + participantsSoftLimit = it + } + roomMetadata.metadata?.startMuted?.let { + eventEmitter.fireEvent { startMutedChanged(it.audio == true, it.video == true) } + } + transcription = roomMetadata.metadata?.transcription + translation = roomMetadata.metadata?.translation + asyncTranscription = roomMetadata.metadata?.asyncTranscription == true + eventEmitter.fireEvent { + transcribingEnabledChanged( + roomMetadata.metadata?.recording?.isTranscribingEnabled == true && + roomMetadata.metadata.asyncTranscription == true + ) + } + eventEmitter.fireEvent { + audioTranslationRequestsChanged(roomMetadata.metadata?.audioTranslationRequests ?: emptyMap()) + } + visitorTranslationLanguages = parseVisitorTranslationLanguages(roomMetadata) + roomMetadataLatch.countDown() + } + + override fun leave() { + muc.removePresenceInterceptor(presenceInterceptor) + muc.removeUserStatusListener(userListener) + muc.removeParticipantListener(this) + leaveCallback(this) + + // Unblock any threads waiting on the latches + xmppTaskQueue.close() + roomMetadataLatch.countDown() + roomJoinedLatch.countDown() + + // Call MultiUserChat.leave() in an IO thread, because it now (with Smack 4.4.3) blocks waiting for a response + // from the XMPP server (and we want ChatRoom#leave to return immediately). + ioPool.execute { + val connection: XMPPConnection = xmppProvider.xmppConnection + try { + // FIXME smack4: there used to be a custom dispose() method if leave() fails, there might still be some + // listeners lingering around + if (muc.isJoined) { + muc.leave() + } else { + // If the join attempt timed out the XMPP server might have processed it and created the MUC, and + // since the XMPP connection is long-lived the MUC will leak. + val leavePresence = connection.stanzaFactory.buildPresenceStanza() + .ofType(Presence.Type.unavailable) + .to(myOccupantJid) + .build() + connection.sendStanza(leavePresence) + } + } catch (e: Exception) { + // when the connection is not connected or we get NotConnectedException, this is expected (skip log) + if (connection.isConnected || e !is SmackException.NotConnectedException) { + logger.error("Failed to properly leave $muc", e) + } + } + } + } + + override fun grantOwnership(member: ChatRoomMember) { + logger.debug("Grant owner to $member") + + // Have to construct the IQ manually as Smack version used here seems + // to be using wrong namespace(muc#owner instead of muc#admin) + // which does not work with the Prosody. + val jid = member.jid?.asBareJid() ?: run { + logger.warn("Can not grant ownership to ${member.name}, real JID unknown") + return + } + val item = MUCItem(MUCAffiliation.owner, jid) + + val admin = MUCAdmin().apply { + type = IQ.Type.set + to = roomJid + addItem(item) + } + try { + val reply = xmppProvider.xmppConnection.sendIqAndGetResponse(admin) + if (reply == null || reply.type != IQ.Type.result) { + logger.warn("Failed to grant ownership: ${reply?.toString() ?: "timeout"}") + } + } catch (e: SmackException.NotConnectedException) { + logger.warn("Failed to grant ownership: XMPP disconnected") + } + } + + fun getOccupant(chatMember: ChatRoomMemberImpl): Occupant? = muc.getOccupant(chatMember.occupantJid) + + override fun setPresenceExtension(extension: ExtensionElement) { + val presenceToSend = synchronized(this) { + val presence = lastPresenceSent ?: run { + logger.error("No presence packet obtained yet") + return + } + + presence.getExtensions(extension.qName).toList().forEach { existingExtension -> + presence.removeExtension(existingExtension) + } + presence.addExtension(extension) + + presence.build() + } + presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) } + } + + override fun addPresenceExtensionIfMissing(extension: ExtensionElement) { + val presenceToSend = synchronized(this) { + val presence = lastPresenceSent ?: run { + logger.error("No presence packet obtained yet") + return + } + + if (presence.extensions?.any { it.qName == extension.qName } == true) { + null + } else { + presence.addExtension(extension).build() + } + } + presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) } + } + + override fun removePresenceExtensions(pred: (ExtensionElement) -> Boolean) { + val presenceToSend = synchronized(this) { + lastPresenceSent?.extensions?.filter { pred(it) }?.let { + modifyPresenceExtensions(toRemove = it) + } + } + presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) } + } + + override fun addPresenceExtensions(extensions: Collection) { + val presenceToSend = synchronized(this) { + modifyPresenceExtensions(toAdd = extensions) + } + presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) } + } + + /** Add/remove extensions to/from our presence and return the updated presence or null if it wasn't modified. */ + private fun modifyPresenceExtensions( + toRemove: Collection = emptyList(), + toAdd: Collection = emptyList() + ): Presence? = synchronized(this) { + val presence = lastPresenceSent ?: run { + logger.error("No presence packet obtained yet") + return null + } + var changed = false + toRemove.forEach { + presence.removeExtension(it) + // We don't have a good way to check if it was actually removed. + changed = true + } + toAdd.forEach { + presence.addExtension(it) + changed = true + } + return if (changed) presence.build() else null + } + + /** + * Adds a new [ChatRoomMemberImpl] with the given JID to [.members]. + * If a member with the given JID already exists, it returns the existing + * instance. + * @param jid the JID of the member. + * @return the [ChatRoomMemberImpl] for the member with the given JID. + */ + private fun addMember(jid: EntityFullJid): ChatRoomMemberImpl { + synchronized(membersMap) { + membersMap[jid]?.let { return it } + val newMember = ChatRoomMemberImpl(jid, this@ChatRoomImpl, logger) + membersMap[jid] = newMember + if (!newMember.isAudioMuted) audioSendersCount++ + if (!newMember.isVideoMuted) videoSendersCount++ + if (newMember.role == MemberRole.VISITOR) { + pendingVisitorsCounter.eventOccurred() + visitorMemberCount++ + } + return newMember + } + } + + /** + * Gets the "real" JID of an occupant of this room specified by its + * occupant JID. + * @param occupantJid the occupant JID. + * @return the "real" JID of the occupant, or `null`. + */ + fun getJid(occupantJid: EntityFullJid): Jid? = muc.getOccupant(occupantJid)?.jid ?: run { + logger.error("Unable to get occupant for $occupantJid") + null + } + + /** + * Processes a Presence packet addressed to our own occupant + * JID. + * @param presence the packet to process. + */ + private fun processOwnPresence(presence: Presence) { + val mucUser = presence.getExtension(MUCUser::class.java) + if (mucUser != null) { + val affiliation = mucUser.item.affiliation + val role = mucUser.item.role + + // This is our initial role and affiliation, as smack does not fire any initial events. + if (!presence.isAvailable && MUCAffiliation.none == affiliation && MUCRole.none == role) { + val destroy = mucUser.destroy + if (destroy == null) { + // the room is unavailable to us, there is no + // message we will just leave + leave() + } else { + logger.info("Leave, reason: ${destroy.reason} alt-jid: ${destroy.jid}") + leave() + } + } else { + eventEmitter.fireEvent { localRoleChanged(fromSmack(role, affiliation)) } + } + } + } + + /** + * Process a Presence packet sent by one of the other room + * occupants. + * @param presence the presence. + */ + private fun processOtherPresence(presence: Presence) { + val jid = presence.from.asEntityFullJidIfPossible() ?: run { + logger.warn("Presence without a valid jid: ${presence.from}") + return + } + + var memberJoined = false + var memberLeft = false + val member = synchronized(membersMap) { + val m = getChatMember(jid) + if (m == null) { + if (presence.type == Presence.Type.available) { + // This is how we detect that a new member has joined. We do not use the + // ParticipantStatusListener#joined callback. + logger.debug { "Joined $jid room: $roomJid" } + memberJoined = true + addMember(jid) + } else { + // We received presence from an unknown member which doesn't look like a new member's presence. + // Ignore it. The member might have been just removed via left(), which is fine. + null + } + } else { + memberLeft = presence.type == Presence.Type.unavailable + m + } + } + if (member != null) { + val previousTranslationLanguage = member.translationLanguage + member.processPresence(presence) + if (memberJoined) { + // Trigger member "joined" + eventEmitter.fireEvent { memberJoined(member) } + } else if (memberLeft) { + if (MUCUser.from(presence).status.contains(MUCUser.Status.KICKED_307)) { + memberListener.kicked(jid) + } else { + memberListener.left(jid) + } + } + if (!memberLeft) { + eventEmitter.fireEvent { memberPresenceChanged(member) } + } + // This member picked a translation language, changed it, or gave it up, so the room's set may have + // changed. A member joining with one set is covered too, because its language is null before its first + // presence is processed. A member leaving is handled by MemberListener instead, because it can also be + // removed without an unavailable presence. + if (member.translationLanguage != previousTranslationLanguage) { + eventEmitter.fireEvent { translationLanguagesChanged() } + } + } + } + + /** + * Offload processing presence for the room from Smack's thread to a queue running in the jicofo IO pool. + */ + override fun processPresence(presence: Presence?) { + if (presence == null) { + logger.warn("Received null presence packet") + return + } + xmppTaskQueue.add { doProcessPresence(presence) } + } + + private fun doProcessPresence(presence: Presence) { + if (presence.error != null) { + logger.warn("Received presence with error: ${presence.toXML()}") + return + } + logger.trace { "Presence received ${presence.toXML()}" } + + // Should never happen, but log if something is broken + val myOccupantJid = this.myOccupantJid + if (myOccupantJid == null) { + logger.error("Processing presence when myOccupantJid is not set: ${presence.toXML()}") + } + if (myOccupantJid != null && myOccupantJid.equals(presence.from)) { + processOwnPresence(presence) + } else { + processOtherPresence(presence) + } + } + + override fun queueXmppTask(runnable: () -> Unit) = xmppTaskQueue.add(runnable) + + private object MucConfigFields { + const val IS_BREAKOUT_ROOM = "muc#roominfo_isbreakout" + const val MAIN_ROOM = "muc#roominfo_breakout_main_room" + const val MEETING_ID = "muc#roominfo_meetingId" + const val WHOIS = "muc#roomconfig_whois" + const val CONFERENCE_PRESET_ENABLED = "muc#roominfo_conference_presets_service_enabled" + } + + private inner class MemberListener { + private fun removeMember(occupantJid: EntityFullJid?): ChatRoomMemberImpl? { + synchronized(membersMap) { + val removed = membersMap.remove(occupantJid) + if (removed == null) { + logger.error("$occupantJid not in room") + } else { + if (!removed.isAudioMuted) audioSendersCount-- + if (!removed.isVideoMuted) videoSendersCount-- + if (removed.role == MemberRole.VISITOR) { + visitorMemberCount-- + } + } + return removed + } + } + + /** This needs to be prepared to run twice for the same member. */ + fun left(occupantJid: EntityFullJid) { + logger.debug { "Left $occupantJid room: $roomJid" } + + val member = synchronized(membersMap) { removeMember(occupantJid) } + member?.let { + eventEmitter.fireEvent { memberLeft(it) } + translationLanguageReleased(it) + } ?: logger.info("Member left event for non-existing member: $occupantJid") + } + + fun kicked(occupantJid: EntityFullJid) { + val member = synchronized(membersMap) { removeMember(occupantJid) } + member?.let { + eventEmitter.fireEvent { memberKicked(it) } + translationLanguageReleased(it) + } ?: logger.error("Kicked member does not exist: $occupantJid") + } + + /** + * A member that was removed from the room may have been the only one wanting its translation language, so the + * room's set may have shrunk. Fired here rather than from presence handling, because a member can also be + * removed without an unavailable presence (e.g. the stale-member cleanup). + */ + private fun translationLanguageReleased(member: ChatRoomMemberImpl) { + if (member.translationLanguage != null) { + eventEmitter.fireEvent { translationLanguagesChanged() } + } + } + } + + /** + * Listens for room destroyed and pass it to the conference. + */ + private inner class LocalUserStatusListener : UserStatusListener { + override fun roomDestroyed(alternateMUC: MultiUserChat?, reason: String?) { + eventEmitter.fireEvent { roomDestroyed(reason) } + } + } + + @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") + private inner class AvModerationForMediaType(mediaType: MediaType) { + var enabled: Boolean by observableWhenChanged(false) { _, _, newValue -> + logger.info("Setting enabled=$newValue for $mediaType") + } + var whitelist: List by observableWhenChanged(emptyList()) { _, _, newValue -> + logger.info("Setting whitelist for $mediaType: $newValue") + } + + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("enabled", enabled) + set("whitelist", jsonMapper.valueToTree(whitelist)) + } + + fun isAllowedToUnmute(jid: Jid) = !enabled || whitelist.contains(jid.toString()) + + fun reset() { + enabled = false + whitelist = emptyList() + } + } + + companion object { + /** + * The visitors' requested translation languages from [roomMetadata], as a set. + * + * Prosody sends them as one comma-separated string, and an empty string when there are none. Note that this + * must clear the set when the string is empty, so that the languages are released when the last visitor that + * wanted them goes away. + */ + private fun parseVisitorTranslationLanguages(roomMetadata: RoomMetadata): Set = + roomMetadata.metadata?.visitors?.transcribingLanguages + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.toSet() + ?: emptySet() + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomListener.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomListener.kt new file mode 100644 index 0000000..964c488 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomListener.kt @@ -0,0 +1,50 @@ +/* + * Jitsi, the OpenSource Java VoIP and Instant Messaging client. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +/** Listener for events fired from a [org.jitsi.impl.protocol.xmpp.ChatRoom] **/ +interface ChatRoomListener { + fun memberJoined(member: ChatRoomMember) {} + fun memberKicked(member: ChatRoomMember) {} + fun memberLeft(member: ChatRoomMember) {} + fun memberPresenceChanged(member: ChatRoomMember) {} + + fun roomDestroyed(reason: String? = null) {} + fun startMutedChanged(startAudioMuted: Boolean, startVideoMuted: Boolean) {} + fun localRoleChanged(newRole: MemberRole) {} + fun numAudioSendersChanged(numAudioSenders: Int) {} + fun numVideoSendersChanged(numVideoSenders: Int) {} + fun transcribingEnabledChanged(enabled: Boolean) {} + + /** + * The aggregated live-translation request map changed: sender endpoint id -> set of requested language codes. + */ + fun audioTranslationRequestsChanged(requests: Map>) {} + + /** + * The set of languages the room wants transcriptions translated into may have changed, because a member changed + * or gave up its language, a member holding one left, or the visitors' languages changed. + * + * The new set is not passed: read it from [ChatRoom.getTranslationLanguages], which unions the members' languages + * with the visitors'. The event only says "recompute" -- it can fire when the union is in fact unchanged. + */ + fun translationLanguagesChanged() {} +} + +/** A class with the default kotlin method implementations (to avoid using @JvmDefault) **/ +open class DefaultChatRoomListener : ChatRoomListener diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMember.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMember.kt new file mode 100644 index 0000000..3f608c8 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMember.kt @@ -0,0 +1,96 @@ +/* + * Jitsi, the OpenSource Java VoIP and Instant Messaging client. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.xmpp.Features +import org.jivesoftware.smack.packet.Presence +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.Jid + +/** + * This interface represents chat room participants. Instances are retrieved + * through implementations of the ChatRoom interface and offer methods + * that allow querying member properties, such as, moderation permissions, + * associated chat room and other. + * + * @author Emil Ivov + * @author Boris Grozev + */ +interface ChatRoomMember { + val chatRoom: ChatRoom + + /** The ID of this member. Set to the resource part of the occupant JID. */ + val name: String + + /** The role of this chat room member in its containing room. */ + val role: MemberRole + + /** Returns the JID of the user (outside the MUC), i.e. the "real" JID. It may not always be known. */ + val jid: Jid? + + /** Get the latest [SourceInfo]s advertised by this chat member in presence. */ + val sourceInfos: Set + + /** The occupant JID of the member in the chat room */ + val occupantJid: EntityFullJid + + /** The last [Presence] packet received for this member (or null it if no presence has been received yet) */ + val presence: Presence? + + val isJigasi: Boolean + val isTranscriber: Boolean + val isJibri: Boolean + val isAudioMuted: Boolean + val isVideoMuted: Boolean + + /** Whether this member requested per-endpoint diarization (signaled via the "diarize" participant property). */ + val diarize: Boolean + + /** + * The language this member wants transcriptions translated into, or null when it wants none or signaled one that + * does not look like a language code. + * + * Signaled via the "translation_language" participant property, the same presence element jigasi has always used. + * Whether transcription runs at all is a separate question, decided by the room metadata. + */ + val translationLanguage: String? + + /** Gets the region (e.g. "us-east") of this [ChatRoomMember]. */ + val region: String? + + /** The statistics id if any. */ + val statsId: String? + + /** The supported video codecs if any */ + val videoCodecs: List? + + /** + * The list of features advertised as XMPP capabilities. Note that although the features are cached (XEP-0115), + * the first time [features] is accessed it may block waiting for a disco#info response! + */ + val features: Set + + /** + * Whether [features] were actually discovered. When this is false [features] is just an assumed default set, and + * the absence of a feature from it does not mean that the member does not support the feature. + */ + val featuresDiscovered: Boolean + + val debugState: ObjectNode +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberImpl.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberImpl.kt new file mode 100644 index 0000000..c3e445e --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberImpl.kt @@ -0,0 +1,325 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.jitsi.jicofo.xmpp.FeatureDiscoveryResult +import org.jitsi.jicofo.xmpp.Features +import org.jitsi.jicofo.xmpp.XmppCapsStats +import org.jitsi.jicofo.xmpp.XmppConfig +import org.jitsi.jicofo.xmpp.muc.MemberRole.Companion.fromSmack +import org.jitsi.utils.MediaType +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import org.jitsi.xmpp.extensions.jitsimeet.AudioMutedExtension +import org.jitsi.xmpp.extensions.jitsimeet.FeaturesExtension +import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantCodecList +import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantRegionPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.StatsId +import org.jitsi.xmpp.extensions.jitsimeet.VideoMutedExtension +import org.jivesoftware.smack.packet.Presence +import org.jivesoftware.smack.packet.StandardExtensionElement +import org.jivesoftware.smackx.caps.packet.CapsExtension +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.Jid + +private val jsonMapper = jacksonObjectMapper() + +/** + * Stripped Smack implementation of [ChatRoomMember]. + * + * @author Pawel Domas + */ +class ChatRoomMemberImpl( + /** The full MUC JID (room_name@muc.server.net/nickname) */ + override val occupantJid: EntityFullJid, + override val chatRoom: ChatRoomImpl, + parentLogger: Logger +) : ChatRoomMember { + override val name: String = occupantJid.resourceOrThrow.toString() + + private val logger: Logger = createChildLogger(parentLogger).apply { + addContext("id", name) + } + + override var region: String? = null + private set + override var presence: Presence? = null + private set + override var isJigasi = false + private set + override var isTranscriber = false + private set + override var isJibri = false + private set + override var statsId: String? = null + private set + override var diarize: Boolean = false + + override var translationLanguage: String? = null + private set + override var videoCodecs: List? = null + private set + override var isAudioMuted = true + private set + override var isVideoMuted = true + private set + override var sourceInfos = emptySet() + private set + + /** The node#ver advertised in a Caps extension. */ + private var capsNodeVer: String? = null + + override var role: MemberRole = + chatRoom.getOccupant(this)?.let { fromSmack(it.role, it.affiliation) } ?: MemberRole.VISITOR + private set + + /** + * Caches user JID of the participant if we're able to see it. + */ + override var jid: Jid? = null + get() { + if (field == null) { + field = chatRoom.getJid(occupantJid) + } + return field + } + private set + + private fun updateSourceInfo(presence: Presence) { + val sourceInfo = presence.getExtension("SourceInfo", "jabber:client") + if (sourceInfo == null) { + sourceInfos = emptySet() + } else { + val sourceInfos: Set = try { + parseSourceInfoJson(sourceInfo.text) + } catch (e: Exception) { + logger.warn("Ignoring invalid SourceInfo JSON", e) + return + } + this.sourceInfos = sourceInfos + } + + val wasAudioMuted = isAudioMuted + isAudioMuted = if (sourceInfo == null) { + // Support the old format, still used by jigasi + presence.getExtension(AudioMutedExtension::class.java)?.isAudioMuted ?: true + } else { + sourceInfos.filter { it.mediaType == MediaType.AUDIO }.none { !it.muted } + } + if (isAudioMuted != wasAudioMuted) { + logger.debug { "isAudioMuted = $isAudioMuted" } + if (isAudioMuted) chatRoom.audioSendersCount-- else chatRoom.audioSendersCount++ + } + + val wasVideoMuted = isVideoMuted + isVideoMuted = if (sourceInfo == null) { + // Support the old format, still used by jigasi + presence.getExtension(VideoMutedExtension::class.java)?.isVideoMuted ?: true + } else { + sourceInfos.filter { it.mediaType == MediaType.VIDEO }.none { !it.muted } + } + if (isVideoMuted != wasVideoMuted) { + logger.debug { "isVideoMuted = $isVideoMuted" } + if (isVideoMuted) chatRoom.videoSendersCount-- else chatRoom.videoSendersCount++ + } + } + + /** + * Process a new presence published by the member and update the mutable fields. + * + * @param presence the Presence which was sent by this chat member. + * @throws IllegalArgumentException if [presence] does not + * belong to this ChatMemberImpl. + */ + fun processPresence(presence: Presence) { + require(presence.from == occupantJid) { "Ignoring presence for a different member: ${presence.from}" } + + val firstPresence = (this.presence == null) + this.presence = presence + + presence.getExtension(CapsExtension::class.java)?.let { + capsNodeVer = "${it.node}#${it.ver}" + } + + updateSourceInfo(presence) + + // We recognize jigasi by the existence of a "feature" extension in its presence. + val features = presence.getExtension(FeaturesExtension::class.java) + if (features != null) { + val domain = jid?.asDomainBareJid() + val jidTrusted = domain != null && XmppConfig.config.trustedDomains.contains(domain) + val jibriSignaled = features.featureExtensions.any { it.`var` == "http://jitsi.org/protocol/jibri" } + val jigasiSignaled = features.featureExtensions.any { it.`var` == "http://jitsi.org/protocol/jigasi" } + val transcriberSignaled = features.featureExtensions.any { + it.`var` == "http://jitsi.org/protocol/transcriber" + } + + isJibri = jibriSignaled && jidTrusted + isJigasi = jigasiSignaled && jidTrusted + isTranscriber = transcriberSignaled && jidTrusted + + if (jibriSignaled && !jidTrusted) { + logger.warn( + "Jibri signaled from a non-trusted domain ($domain). The domain can be " + + "configured as trusted with the jicofo.xmpp.trusted-domains property." + ) + } + if (jigasiSignaled && !jidTrusted) { + logger.warn( + "Jigasi signaled from a non-trusted domain ($domain). The domain can be " + + "configured as trusted with the jicofo.xmpp.trusted-domains property." + ) + } + } else { + isJigasi = false + isJibri = false + isTranscriber = false + } + + var newRole: MemberRole = MemberRole.VISITOR + chatRoom.getOccupant(this)?.let { newRole = fromSmack(it.role, it.affiliation) } + if (!firstPresence && (role == MemberRole.VISITOR) != (newRole == MemberRole.VISITOR)) { + // Allowing this to change would mess up various member counts, ignore the change + // TODO: Should we try to update them, instead? + if (presence.type != Presence.Type.unavailable) { + logger.warn("Member role changed from $role to $newRole - not supported!") + } + } else { + role = newRole + } + + presence.getExtension(JitsiParticipantRegionPacketExtension::class.java)?.let { + region = it.regionId + } + + presence.getExtension(StatsId::class.java)?.let { + statsId = it.statsId + } + + val diarizeElement = presence.getExtensionElement("jitsi_participant_diarize", "jabber:client") + diarize = (diarizeElement as? StandardExtensionElement)?.text?.toBoolean() ?: false + + translationLanguage = parseTranslationLanguage(presence) + + val newVideoCodecs = + presence.getExtension(JitsiParticipantCodecList::class.java)?.let { + if (!it.codecs.contains("vp8")) { + if (firstPresence) { + logger.warn("Video codec list {${it.codecs}} does not contain vp8! Adding manually.") + } + it.codecs + "vp8" + } else { + it.codecs + } + } ?: // Older clients sent a single codec in codecType rather than all supported ones in codecList + presence.getExtensionElement("jitsi_participant_codecType", "jabber:client")?.let { + if (it is StandardExtensionElement) { + val codec = it.text.lowercase() + if (codec == "vp8") { + listOf(codec) + } else { + listOf(codec, "vp8") + } + } else { + null + } + } + if (!firstPresence && newVideoCodecs != videoCodecs) { + // Allowing this to change would mess up visitor codec preference counts, ignore the change + if (role == MemberRole.VISITOR && presence.type != Presence.Type.unavailable && newVideoCodecs != null) { + logger.warn("Visitor video codec list changed from $videoCodecs to $newVideoCodecs - not supported!") + } + } else { + videoCodecs = newVideoCodecs + } + } + + override fun toString() = "ChatMember[id=$name role=$role]" + + companion object { + /** + * The shape of a language code, as jitsi-meet sends them (the keys of its + * lang/translation-languages.json): a 2- or 3-letter primary subtag with optional region or script subtags, + * for example "fr", "ceb" or "zh-CN". + */ + private val LANGUAGE_CODE_REGEX = Regex("[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*") + + /** + * The language a member wants transcriptions translated into, or null when it wants none. A blank value means + * the same as an absent one. + * + * The value is set by the participant, so it is validated rather than trusted. It ends up in a colibri2 + * attribute, in the bridge's log lines, and (later) in a request to a translation provider. Smack escapes the + * attribute and Jackson escapes the JSON, so this is not about breaking out of the XML or the JSON. It keeps + * junk and control characters out of the signalling, and it bounds the length. + */ + private fun parseTranslationLanguage(presence: Presence): String? { + val language = presence + .getExtensionElement("jitsi_participant_translation_language", "jabber:client") + .let { (it as? StandardExtensionElement)?.text } + ?.trim() + ?: return null + if (language.isEmpty()) { + return null + } + return language.takeIf { LANGUAGE_CODE_REGEX.matches(it) } + } + } + + private val featureDiscoveryResult: FeatureDiscoveryResult by lazy { + val result = chatRoom.xmppProvider.discoverFeatures(occupantJid) + // Update the stats once when the features are discovered. + capsNodeVer?.let { + XmppCapsStats.update(it, result.features) + } ?: logger.error("No caps nodeVer found") + + result + } + + override val features: Set + get() = featureDiscoveryResult.features + + override val featuresDiscovered: Boolean + get() = featureDiscoveryResult.discovered + + override val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("region", region.toString()) + put("occupant_jid", occupantJid.toString()) + put("jid", jid.toString()) + put("is_jibri", isJibri) + put("is_jigasi", isJigasi) + put("is_transcriber", isTranscriber) + put("role", role.toString()) + set( + "video_codecs", + JsonNodeFactory.instance.arrayNode().apply { videoCodecs?.forEach { add(it) } } + ) + put("stats_id", statsId.toString()) + put("is_audio_muted", isAudioMuted) + put("is_video_muted", isVideoMuted) + put("diarize", diarize) + put("translation_language", translationLanguage) + set("features", jsonMapper.valueToTree(features.map { it.name })) + put("features_discovered", featuresDiscovered) + put("capsNodeVer", capsNodeVer.toString()) + } +} diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/MemberRole.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/MemberRole.kt new file mode 100644 index 0000000..ff049ba --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/MemberRole.kt @@ -0,0 +1,55 @@ +/* + * Jitsi, the OpenSource Java VoIP and Instant Messaging client. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import org.jivesoftware.smackx.muc.MUCAffiliation +import org.jivesoftware.smackx.muc.MUCRole + +/** Indicates roles that a chat room member detains in its containing chat room. */ +enum class MemberRole { + /** A role implying the full set of chat room permissions */ + OWNER, + + /** A role implying moderator permissions. */ + MODERATOR, + + /** A role implying the ability to send to a chat room */ + PARTICIPANT, + + /** A role implying only the ability to watch a chat room. */ + VISITOR; + + companion object { + @JvmStatic + fun fromSmack(mucRole: MUCRole?, mucAffiliation: MUCAffiliation?) = when (mucAffiliation) { + MUCAffiliation.admin -> MODERATOR + + MUCAffiliation.owner -> OWNER + + else -> when (mucRole) { + MUCRole.moderator -> MODERATOR + MUCRole.participant -> PARTICIPANT + else -> VISITOR + } + } + } +} + +/** Has sufficient rights to moderate (i.e. is MODERATOR or OWNER). */ +fun MemberRole?.hasModeratorRights() = this != null && this <= MemberRole.MODERATOR +fun MemberRole?.hasOwnerRights() = this != null && this <= MemberRole.OWNER diff --git a/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/SourceInfo.kt b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/SourceInfo.kt new file mode 100644 index 0000000..684b670 --- /dev/null +++ b/jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/SourceInfo.kt @@ -0,0 +1,80 @@ +/* + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.jitsi.jicofo.conference.source.VideoType +import org.jitsi.utils.MediaType + +/** The information about a source contained in a jitsi-meet SourceInfo extension. */ +data class SourceInfo( + val name: String, + val muted: Boolean, + val videoType: VideoType?, + val mediaType: MediaType? +) + +/** + * Parse the JSON string encoded in a SourceInfo XML extension as a set of [SourceInfo]s. + * @throws IllegalArgumentException if the string is not valid JSON or not in the expected format. + */ +@kotlin.jvm.Throws(IllegalArgumentException::class) +fun parseSourceInfoJson(s: String): Set { + val json = try { + jacksonObjectMapper().readTree(s) + } catch (e: JsonProcessingException) { + throw IllegalArgumentException("Illegal SourceInfo JSON: $s", e) + } + if (json !is ObjectNode) throw IllegalArgumentException("Illegal SourceInfo JSON: $s") + + return json.fields().asSequence().map { (name, sourceNode) -> + val sourceJson = sourceNode as? ObjectNode + ?: throw IllegalArgumentException("Invalid source value: $sourceNode") + val muted = sourceJson.get("muted")?.asBoolean() ?: true + val videoTypeNode = sourceJson.get("videoType") + val videoType = when { + videoTypeNode == null || videoTypeNode.isNull -> null + !videoTypeNode.isTextual -> throw IllegalArgumentException("Invalid videoType: $videoTypeNode") + else -> VideoType.parseString(videoTypeNode.asText()) + } + val mediaTypeField = sourceJson.get("mediaType")?.takeIf { it.isTextual }?.asText() + val mediaType = when { + "audio".equals(mediaTypeField, ignoreCase = true) -> MediaType.AUDIO + "video".equals(mediaTypeField, ignoreCase = true) -> MediaType.VIDEO + else -> parseMediaTypeFromName(name) + } + + SourceInfo(name, muted, videoType, mediaType) + }.toSet() +} + +/** + * If not explicitly signaled the media type is encoded in the name as in `abcd-a0` (an audio source) and `abcd-v5` + * (a video source). + */ +private fun parseMediaTypeFromName(name: String): MediaType? = name.indexOf('-').let { indexOfDash -> + if (indexOfDash < 0 || indexOfDash == name.length - 1) { + null + } else { + when (name[indexOfDash + 1]) { + 'a' -> MediaType.AUDIO + 'v' -> MediaType.VIDEO + else -> null + } + } +} diff --git a/jicofo-common/src/main/resources/reference.conf b/jicofo-common/src/main/resources/reference.conf new file mode 100644 index 0000000..1e661b0 --- /dev/null +++ b/jicofo-common/src/main/resources/reference.conf @@ -0,0 +1,214 @@ +// Default values for configuration read by classes in jicofo-common. Defaults for other jicofo.* sections live in +// the reference.conf of the module that owns them (jicofo-selector, jicofo). +jicofo { + // Configure the codecs and RTP extensions to be used in the offer sent to clients. + codec { + video { + av1 { + enabled = true + pt = 41 + // Payload type for the associated RTX stream. Set to -1 to disable RTX. + rtx-pt = 42 + enable-remb = true + } + vp8 { + enabled = true + pt = 100 + // Payload type for the associated RTX stream. Set to -1 to disable RTX. + rtx-pt = 96 + enable-remb = true + } + vp9 { + enabled = true + pt = 101 + // Payload type for the associated RTX stream. Set to -1 to disable RTX. + rtx-pt = 97 + enable-remb = true + } + h264 { + enabled = true + pt = 107 + // Payload type for the associated RTX stream. Set to -1 to disable RTX. + rtx-pt = 99 + enable-remb = true + } + } + + audio { + opus { + enabled = true + pt = 111 + minptime = 10 + use-inband-fec = true + red { + enabled = false + pt = 112 + } + } + telephone-event { + enabled = true + pt = 126 + } + } + + // RTP header extensions + rtp-extensions { + audio-level { + enabled = true + id = 1 + } + av1-dependency-descriptor { + enabled = true + id = 11 + } + video-layers-allocation { + enabled = false + id = 12 + } + tof { + // TOF is currently disabled, because we don't support it in the bridge + // (and currently clients seem to not use it when abs-send-time is + // available). + enabled = false + id = 2 + } + abs-send-time { + enabled = true + id = 3 + } + rid { + enabled = false + id = 4 + } + tcc { + enabled = true + id = 5 + } + video-content-type { + enabled = false + id = 7 + } + framemarking { + enabled = false + id = 9 + } + mid { + enabled = true + id = 10 + } + extmap-allow-mixed = true + } + } + + // The region in which the machine is running. + #local-region="us-east-1" + + // Controls the metrics accessible at /metrics (but not /stats). + metrics { + // A subset of the metrics are re-calculated and updated periodically. This controls how often. + update-interval = 30 seconds + } + + octo { + // Whether or not to use Octo. Note that when enabled, its use will be determined by + // $jicofo.bridge.selection-strategy. There's a corresponding flag in the JVB and these + // two MUST be in sync (otherwise bridges will crash because they won't know how to + // deal with octo channels). + enabled = false + + // Whether to allow bridges with different versions to be used in the same conference. Intended only for testing. + allow-mixed-versions = false + + // Whether bridge-to-bridge communication should use SCTP datachannels (as opposed to websockets) + sctp-datachannels = ${jicofo.sctp.enabled} + } + + sctp { + // Whether to allocate SCTP channels on the bridge (only when the client advertises support, and SCTP is + // enabled in the per-conference configuration). + enabled = true + } + + visitors { + # After a visitor is redirected to a vnode, the amount of time to count it toward the vnode's visitor count + # even before its presence shows up. This stops vnodes from getting overloaded when many visitors join simultaneously. + # The rest of the visitors configuration is defined in the jicofo module. + vnode-join-latency-interval = 60 seconds + } + + xmpp { + // Whether to use JitsiXmppStringprep to validate JIDs. If set to false uses the default validation in Smack. + use-jitsi-jid-validation = true + + // The separate XMPP connection used for communication with clients (endpoints). + client { + enabled = true + hostname = "localhost" + port = 5222 + # The domain to use for login. + #domain = + # The domain where XMPP pings will be sent and where components will be discovered. + #xmpp-domain = + username = "focus" + resource = ${jicofo.xmpp.client.username} + #password = + + // How long to wait for a response to a stanza before giving up. + reply-timeout = 15 seconds + + // The JID/domain of the MUC service used for conferencing. + # conference-muc-jid = conference.example.com + + // A flag to suppress the TLS certificate verification. + disable-certificate-verification = false + + // The JID of the mod_client_proxy component if used. It will be trusted to encode the JID of the original + // sender in the resource part of the JID. + #client-proxy = focus.example.com + + // Use TLS between Jicofo and the XMPP server + // Only disable this if your xmpp connection is on loopback! + use-tls = true + } + // The separate XMPP connection used for internal services (currently only jitsi-videobridge). + service { + enabled = false + hostname = "localhost" + port = 6222 + #domain = + # The domain where XMPP pings will be sent and where components will be discovered. + #xmpp-domain = + username = "focus" + resource = ${jicofo.xmpp.service.username} + #password = + + // How long to wait for a response to a stanza before giving up. + reply-timeout = 15 seconds + + // A flag to suppress the TLS certificate verification. + disable-certificate-verification = false + + // Use TLS between Jicofo and the XMPP server + // Only disable this if your xmpp connection is on loopback! + use-tls = true + } + // Additional XMPP environments to use for visitor nodes, mapped by an id/name. + visitors { + #v1 { + #enabled=true + #conference-service = conference.v1.example.com + #hostname = 127.0.0.1 + #domain = "auth.v1.example.com" + #port = 7222 + #password = "changeme" + #disable-certificate-verification = true + # The domain where XMPP pings will be sent and where components will be discovered. + #xmpp-domain = + #} + } + + // The list of domains with trusted services. Only members logged in to these domains can declare themselves to be + // Jibri instances. + trusted-domains = [] + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/impl/protocol/xmpp/log/PacketDebuggerTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/impl/protocol/xmpp/log/PacketDebuggerTest.kt new file mode 100644 index 0000000..b53127b --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/impl/protocol/xmpp/log/PacketDebuggerTest.kt @@ -0,0 +1,42 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.impl.protocol.xmpp.log + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import org.jitsi.xmpp.extensions.jitsimeet.ConferenceIq +import org.jitsi.xmpp.util.RedactColibri.Companion.redactToken +import org.jxmpp.jid.impl.JidCreate + +class PacketDebuggerTest : ShouldSpec({ + should("redact the token attribute of a logged ConferenceIq, as done by PacketDebugger") { + val token = "eyJhbGciOiJIUzI1NiJ9.super-secret-jwt" + val iq = ConferenceIq().apply { + room = JidCreate.entityBareFrom("room@conference.example.com") + this.token = token + stanzaId = "id1" + } + + val redacted = redactToken(iq.toXML().toString()) + + redacted shouldNotContain token + redacted shouldContain "room=\"room@conference.example.com\"" + redacted shouldContain "token=\"[redacted]\"" + } +}) diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/JicofoCommonKotestProjectConfig.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/JicofoCommonKotestProjectConfig.kt new file mode 100644 index 0000000..9e8b380 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/JicofoCommonKotestProjectConfig.kt @@ -0,0 +1,33 @@ +import io.kotest.core.config.AbstractProjectConfig +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.metaconfig.MetaconfigSettings + +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Note: the jicofo modules share packages, so the class name is unique to avoid conflicts. + */ +class JicofoCommonKotestProjectConfig : AbstractProjectConfig() { + override suspend fun beforeProject() = super.beforeProject().also { + // The only purpose of config caching is performance. We always want caching disabled in tests (so we can + // freely modify the config without affecting other tests executing afterwards). + MetaconfigSettings.cacheEnabled = false + + JicofoMetricsContainer.instance.checkForNameConflicts = false + JicofoMetricsContainer.instance.metricsUpdater.disablePeriodicUpdates = true + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/JicofoConfigTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/JicofoConfigTest.kt new file mode 100644 index 0000000..f718753 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/JicofoConfigTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright @ 2020 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.config.withLegacyConfig +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.JicofoConfig.Companion.config + +class JicofoConfigTest : ShouldSpec() { + init { + context("Local region config") { + context("With no config") { + config.localRegion shouldBe null + } + context("With legacy config") { + withLegacyConfig("org.jitsi.jicofo.BridgeSelector.LOCAL_REGION=legacy") { + config.localRegion shouldBe "legacy" + } + } + context("With new config") { + withNewConfig("jicofo { local-region=new }") { + config.localRegion shouldBe "new" + } + } + context("With both new and legacy") { + withNewConfig("jicofo { local-region=new }") { + withLegacyConfig("org.jitsi.jicofo.BridgeSelector.LOCAL_REGION=legacy") { + config.localRegion shouldBe "legacy" + } + } + } + } + + context("SCTP") { + context("Should be enabled by default") { + config.enableSctp shouldBe true + } + context("Should be disabled with new config") { + withNewConfig("jicofo { sctp { enabled=false } }") { + config.enableSctp shouldBe false + } + } + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/OctoConfigTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/OctoConfigTest.kt new file mode 100644 index 0000000..2f0948c --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/OctoConfigTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright @ 2020 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.config.withNewConfig + +class OctoConfigTest : ShouldSpec() { + init { + context("Enabled flag") { + // Note that there's a corresponding flag in the JVB and these two + // MUST be in sync (otherwise bridges will crash because they won't + // know how to deal with octo channels). + context("default") { + OctoConfig.config.enabled shouldBe false + } + context("disabled") { + withNewConfig("jicofo.octo.enabled=false") { + OctoConfig.config.enabled shouldBe false + } + } + context("enabled") { + withNewConfig("jicofo.octo.enabled=true") { + OctoConfig.config.enabled shouldBe true + } + } + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/codec/CodecConfigTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/codec/CodecConfigTest.kt new file mode 100644 index 0000000..164a642 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/codec/CodecConfigTest.kt @@ -0,0 +1,288 @@ +/* + * Copyright @ 2020 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.codec + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.config.withLegacyConfig +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.codec.Config.Companion.config + +class CodecConfigTest : ShouldSpec() { + init { + context("Default configuration") { + config.av1.enabled() shouldBe true + config.av1.pt() shouldBe 41 + config.av1.rtxEnabled() shouldBe true + config.av1.rtxPt() shouldBe 42 + + config.vp8.enabled() shouldBe true + config.vp8.pt() shouldBe 100 + config.vp8.rtxEnabled() shouldBe true + config.vp8.rtxPt() shouldBe 96 + + config.vp9.enabled() shouldBe true + config.vp9.pt() shouldBe 101 + config.vp9.rtxEnabled() shouldBe true + config.vp9.rtxPt() shouldBe 97 + + config.h264.enabled() shouldBe true + config.h264.pt() shouldBe 107 + config.h264.rtxEnabled() shouldBe true + config.h264.rtxPt() shouldBe 99 + + config.opus.enabled() shouldBe true + config.opus.pt() shouldBe 111 + config.opus.minptime() shouldBe 10 + config.opus.useInbandFec() shouldBe true + config.opus.red.enabled() shouldBe false + shouldThrow { config.opus.red.pt() } + + config.framemarking.enabled shouldBe false + config.framemarking.id shouldBe 9 + + config.absSendTime.enabled shouldBe true + config.absSendTime.id shouldBe 3 + + config.tof.enabled shouldBe false + config.tof.id shouldBe 2 + + config.videoContentType.enabled shouldBe false + config.videoContentType.id shouldBe 7 + + config.rid.enabled shouldBe false + config.rid.id shouldBe 4 + + config.tcc.enabled shouldBe true + config.tcc.id shouldBe 5 + + config.audioLevel.enabled shouldBe true + config.audioLevel.id shouldBe 1 + + config.mid.enabled shouldBe true + config.mid.id shouldBe 10 + + config.av1DependencyDescriptor.enabled shouldBe true + config.av1DependencyDescriptor.id shouldBe 11 + } + context("Legacy config") { + context("Disabling a codec") { + withLegacyConfig("org.jitsi.jicofo.ENABLE_VP8=false") { + config.vp8.enabled() shouldBe false + shouldThrow { config.vp8.pt() } + config.vp8.rtxEnabled() shouldBe false + shouldThrow { config.vp8.rtxPt() } + } + withLegacyConfig("org.jitsi.jicofo.VP8_PT=-1") { + config.vp8.enabled() shouldBe false + shouldThrow { config.vp8.pt() } + config.vp8.rtxEnabled() shouldBe false + shouldThrow { config.vp8.rtxPt() } + } + withLegacyConfig("org.jitsi.jicofo.VP8_RTX_PT=-1") { + config.vp8.enabled() shouldBe true + config.vp8.pt() shouldBe 100 + config.vp8.rtxEnabled() shouldBe false + shouldThrow { config.vp8.rtxPt() } + } + withLegacyConfig( + """ + org.jitsi.jicofo.VP8_PT=-1 + org.jitsi.jicofo.VP8_RTX_PT=111 + """.trimIndent() + ) { + config.vp8.enabled() shouldBe false + shouldThrow { config.vp8.pt() } + config.vp8.rtxEnabled() shouldBe false + shouldThrow { config.vp8.rtxPt() } + } + } + context("Changing the PT and RTX PT") { + withLegacyConfig("org.jitsi.jicofo.VP8_PT=111") { + config.vp8.enabled() shouldBe true + config.vp8.pt() shouldBe 111 + } + withLegacyConfig("org.jitsi.jicofo.VP8_RTX_PT=112") { + config.vp8.enabled() shouldBe true + config.vp8.pt() shouldBe 100 + config.vp8.rtxEnabled() shouldBe true + config.vp8.rtxPt() shouldBe 112 + } + } + context("Disabling/enabling extensions") { + withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=false") { + config.framemarking.enabled shouldBe false + } + withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=true") { + config.framemarking.enabled shouldBe true + } + withLegacyConfig("org.jitsi.jicofo.ENABLE_AST=false") { + config.absSendTime.enabled shouldBe false + } + withLegacyConfig("org.jitsi.jicofo.ENABLE_AST=true") { + config.absSendTime.enabled shouldBe true + } + withLegacyConfig("org.jitsi.jicofo.ENABLE_RID=false") { + config.rid.enabled shouldBe false + } + withLegacyConfig("org.jitsi.jicofo.ENABLE_RID=true") { + config.rid.enabled shouldBe true + } + withLegacyConfig("org.jitsi.jicofo.ENABLE_TOF=false") { + config.tof.enabled shouldBe false + } + withLegacyConfig("org.jitsi.jicofo.ENABLE_TOF=true") { + config.tof.enabled shouldBe true + } + } + } + context("New config") { + context("Disabling a codec") { + withNewConfig("jicofo.codec.video.vp8.enabled=false") { + config.vp8.enabled() shouldBe false + shouldThrow { config.vp8.pt() } + config.vp8.rtxEnabled() shouldBe false + shouldThrow { config.vp8.rtxPt() } + } + withNewConfig("jicofo.codec.video.vp8.pt=-1") { + config.vp8.enabled() shouldBe false + shouldThrow { config.vp8.pt() } + config.vp8.rtxEnabled() shouldBe false + shouldThrow { config.vp8.rtxPt() } + } + withNewConfig("jicofo.codec.video.vp8.rtx-pt=-1") { + config.vp8.enabled() shouldBe true + config.vp8.pt() shouldBe 100 + config.vp8.rtxEnabled() shouldBe false + shouldThrow { config.vp8.rtxPt() } + } + withNewConfig( + """ + jicofo { + codec { + video { + vp8 { + enabled=true + pt=-1 + rtp-pt=111 + } + } + } + } + """.trimIndent() + ) { + config.vp8.enabled() shouldBe false + shouldThrow { config.vp8.pt() } + config.vp8.rtxEnabled() shouldBe false + shouldThrow { config.vp8.rtxPt() } + } + } + context("Changing the PT and RTX PT") { + withNewConfig("jicofo.codec.video.vp8.pt=111") { + config.vp8.enabled() shouldBe true + config.vp8.pt() shouldBe 111 + } + withNewConfig("jicofo.codec.video.vp8.rtx-pt=112") { + config.vp8.enabled() shouldBe true + config.vp8.pt() shouldBe 100 + config.vp8.rtxEnabled() shouldBe true + config.vp8.rtxPt() shouldBe 112 + } + } + context("Disabling/enabling extensions") { + withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=false") { + config.framemarking.enabled shouldBe false + } + withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=true") { + config.framemarking.enabled shouldBe true + } + withNewConfig("jicofo.codec.rtp-extensions.abs-send-time.enabled=false") { + config.absSendTime.enabled shouldBe false + } + withNewConfig("jicofo.codec.rtp-extensions.abs-send-time.enabled=true") { + config.absSendTime.enabled shouldBe true + } + withNewConfig("jicofo.codec.rtp-extensions.rid.enabled=false") { + config.rid.enabled shouldBe false + } + withNewConfig("jicofo.codec.rtp-extensions.rid.enabled=true") { + config.rid.enabled shouldBe true + } + withNewConfig("jicofo.codec.rtp-extensions.tof.enabled=false") { + config.tof.enabled shouldBe false + } + withNewConfig("jicofo.codec.rtp-extensions.tof.enabled=true") { + config.tof.enabled shouldBe true + } + withNewConfig("jicofo.codec.rtp-extensions.tcc.enabled=false") { + config.tcc.enabled shouldBe false + } + withNewConfig("jicofo.codec.rtp-extensions.tcc.enabled=true") { + config.tcc.enabled shouldBe true + } + withNewConfig("jicofo.codec.rtp-extensions.mid.enabled=false") { + config.mid.enabled shouldBe false + } + withNewConfig("jicofo.codec.rtp-extensions.mid.enabled=true") { + config.mid.enabled shouldBe true + } + withNewConfig("jicofo.codec.rtp-extensions.av1-dependency-descriptor.enabled=true") { + config.av1DependencyDescriptor.enabled shouldBe true + } + withNewConfig("jicofo.codec.rtp-extensions.av1-dependency-descriptor.enabled=false") { + config.av1DependencyDescriptor.enabled shouldBe false + } + } + context("Changing extension IDs") { + withNewConfig("jicofo.codec.rtp-extensions.tcc.id=1") { + config.tcc.id shouldBe 1 + } + withNewConfig("jicofo.codec.rtp-extensions.abs-send-time.id=1") { + config.absSendTime.id shouldBe 1 + } + } + context("Opus config") { + withNewConfig("jicofo.codec.audio.opus.red.enabled=true") { + config.opus.red.enabled() shouldBe true + config.opus.red.pt() shouldBe 112 + } + } + } + context("With both legacy and new config, legacy should take precedence") { + withLegacyConfig("org.jitsi.jicofo.ENABLE_VP8=false") { + withNewConfig("jicofo.codec.video.vp8.enabled=true") { + config.vp8.enabled() shouldBe false + } + } + withLegacyConfig("org.jitsi.jicofo.ENABLE_VP8=true") { + withNewConfig("jicofo.codec.video.vp8.enabled=false") { + config.vp8.enabled() shouldBe true + } + } + withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=true") { + withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=false") { + config.framemarking.enabled() shouldBe true + } + } + withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=false") { + withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=true") { + config.framemarking.enabled() shouldBe false + } + } + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/codec/CodecUtilTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/codec/CodecUtilTest.kt new file mode 100644 index 0000000..b925287 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/codec/CodecUtilTest.kt @@ -0,0 +1,175 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.codec + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.jitsi.config.withNewConfig +import java.net.URI + +class CodecUtilTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + init { + context("Video") { + context("VP8") { + context("Default config") { + val vp8 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP8" } + vp8 shouldNotBe null + vp8!!.clockrate shouldBe 90000 + vp8.id shouldBe Config.config.vp8.pt() + vp8.rtcpFeedbackTypeList.any { it.feedbackType == "nack" } shouldBe true + vp8.rtcpFeedbackTypeList.any { it.feedbackType == "transport-cc" } shouldBe true + vp8.rtcpFeedbackTypeList.any { it.feedbackType == "goog-remb" } shouldBe false + + val vp8Rtx = CodecUtil.createVideoPayloadTypeExtensions().find { + it.id == Config.config.vp8.rtxPt() + } + vp8Rtx shouldNotBe null + vp8Rtx!!.id shouldBe Config.config.vp8.rtxPt() + vp8Rtx.name shouldBe "rtx" + vp8Rtx.parameters.any { + it.name == "apt" && it.value == Config.config.vp8.pt().toString() + } shouldBe true + } + withNewConfig("jicofo.codec.video.vp8.enabled=false") { + val vp8 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP8" } + vp8 shouldBe null + } + withNewConfig("jicofo.codec.video.vp8.pt=123") { + val vp8 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP8" } + vp8!!.id shouldBe 123 + } + } + context("VP9") { + context("Default config") { + val vp9 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP9" } + vp9 shouldNotBe null + vp9!!.clockrate shouldBe 90000 + vp9.id shouldBe Config.config.vp9.pt() + vp9.rtcpFeedbackTypeList.any { it.feedbackType == "nack" } shouldBe true + vp9.rtcpFeedbackTypeList.any { it.feedbackType == "transport-cc" } shouldBe true + vp9.rtcpFeedbackTypeList.any { it.feedbackType == "goog-remb" } shouldBe false + + val vp9Rtx = CodecUtil.createVideoPayloadTypeExtensions().find { + it.id == Config.config.vp9.rtxPt() + } + vp9Rtx shouldNotBe null + vp9Rtx!!.id shouldBe Config.config.vp9.rtxPt() + vp9Rtx.name shouldBe "rtx" + vp9Rtx.parameters.any { + it.name == "apt" && it.value == Config.config.vp9.pt().toString() + } shouldBe true + } + withNewConfig("jicofo.codec.video.vp9.enabled=false") { + val vp9 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP9" } + vp9 shouldBe null + } + withNewConfig("jicofo.codec.video.vp9.pt=123") { + val vp9 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP9" } + vp9!!.id shouldBe 123 + } + } + context("H264") { + context("Default config") { + val h264 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "H264" } + h264 shouldNotBe null + h264!!.clockrate shouldBe 90000 + h264.id shouldBe Config.config.h264.pt() + h264.rtcpFeedbackTypeList.any { it.feedbackType == "nack" } shouldBe true + h264.rtcpFeedbackTypeList.any { it.feedbackType == "transport-cc" } shouldBe true + h264.rtcpFeedbackTypeList.any { it.feedbackType == "goog-remb" } shouldBe false + + val h264Rtx = CodecUtil.createVideoPayloadTypeExtensions().find { + it.id == Config.config.h264.rtxPt() + } + h264Rtx shouldNotBe null + h264Rtx!!.id shouldBe Config.config.h264.rtxPt() + h264Rtx.name shouldBe "rtx" + h264Rtx.parameters.any { + it.name == "apt" && it.value == Config.config.h264.pt().toString() + } shouldBe true + } + withNewConfig("jicofo.codec.video.h264.enabled=false") { + val h264 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "H264" } + h264 shouldBe null + } + withNewConfig("jicofo.codec.video.h264.pt=123") { + val h264 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "H264" } + h264!!.id shouldBe 123 + } + } + } + context("Audio") { + context("Opus") { + val opus = CodecUtil.createAudioPayloadTypeExtensions().find { it.name == "opus" } + opus shouldNotBe null + opus!!.id shouldBe Config.config.opus.pt() + opus.clockrate shouldBe 48000 + opus.channels shouldBe 2 + } + context("RED") { + CodecUtil.createAudioPayloadTypeExtensions().any { it.name == "red" } shouldBe false + + withNewConfig("jicofo.codec.audio.opus.red.enabled=true") { + val red = CodecUtil.createAudioPayloadTypeExtensions().find { it.name == "red" } + red shouldNotBe null + val opusPt = Config.config.opus.pt() + red!!.parameters.any { it.value == "$opusPt/$opusPt" } shouldBe true + } + } + } + context("RTP Header Extensions") { + context("SSRC Audio Level") { + val alUri = URI.create("urn:ietf:params:rtp-hdrext:ssrc-audio-level") + + CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == alUri } shouldBe true + CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == alUri } shouldBe false + } + context("MID") { + val midUri = URI.create("urn:ietf:params:rtp-hdrext:sdes:mid") + + CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == midUri } shouldBe true + CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == midUri } shouldBe true + withNewConfig("jicofo.codec.rtp-extensions.mid.enabled=false") { + CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == midUri } shouldBe false + CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == midUri } shouldBe false + } + } + context("TCC") { + val tccUri = URI.create("http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01") + + CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe true + CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe true + CodecUtil.createAudioRtpHdrExtExtensions(OfferOptions(tcc = false)).any { + it.uri == tccUri + } shouldBe false + CodecUtil.createVideoRtpHdrExtExtensions(OfferOptions(tcc = false)).any { + it.uri == tccUri + } shouldBe false + + withNewConfig("jicofo.codec.rtp-extensions.tcc.enabled=false") { + CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe false + CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe false + } + } + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/ConferenceSourceMapTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/ConferenceSourceMapTest.kt new file mode 100644 index 0000000..d41b790 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/ConferenceSourceMapTest.kt @@ -0,0 +1,261 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.jitsi.jicofo.util.shouldBeValidJson +import org.jitsi.utils.MediaType +import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension +import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension +import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.SSRCInfoPacketExtension +import java.lang.UnsupportedOperationException + +@Suppress("NAME_SHADOWING") +@SuppressFBWarnings( + value = ["RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE"], + justification = "False positives." +) +class ConferenceSourceMapTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + init { + val s7 = Source(7, MediaType.AUDIO) + val endpoint1SourceSet = EndpointSourceSet( + setOf(s1, s2, s7), + setOf( + SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2)) + ) + ) + + val endpoint1AdditionalSourceSet = EndpointSourceSet( + setOf( + // The duplicates should be removed + s1, + s2, + s3, + s4, + s7 + ), + setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 4))) + ) + val endpoint1CombinedSourceSet = endpoint1SourceSet + endpoint1AdditionalSourceSet + + val endpoint2SourceSet = EndpointSourceSet( + setOf( + Source(101, MediaType.VIDEO), + Source(102, MediaType.VIDEO), + Source(103, MediaType.AUDIO) + ), + setOf( + SsrcGroup(SsrcGroupSemantics.Fid, listOf(101, 102)) + ) + ) + + context("Constructors") { + context("Default") { + val conferenceSourceMap = ConferenceSourceMap(mutableMapOf(JID_1 to endpoint1SourceSet)) + conferenceSourceMap.size shouldBe 1 + conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet + } + context("With a single EndpointSourceSet") { + val conferenceSourceMap = ConferenceSourceMap(JID_1 to endpoint1SourceSet) + conferenceSourceMap.size shouldBe 1 + conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet + } + context("With multiple EndpointSourceSets") { + val conferenceSourceMap = ConferenceSourceMap( + JID_1 to endpoint1SourceSet, + JID_2 to endpoint2SourceSet + ) + conferenceSourceMap.size shouldBe 2 + conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet + conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet + } + } + context("Add") { + val conferenceSourceMap = ConferenceSourceMap(JID_1 to endpoint1SourceSet) + + context("Without overlap in endpoints") { + conferenceSourceMap.add(JID_2, endpoint2SourceSet) + conferenceSourceMap.size shouldBe 2 + conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet + conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet + } + context("With overlap in endpoints") { + conferenceSourceMap.add( + ConferenceSourceMap( + JID_1 to endpoint1AdditionalSourceSet, + JID_2 to endpoint2SourceSet + ) + ) + + conferenceSourceMap.size shouldBe 2 + conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet + conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet + } + } + context("Remove") { + val conferenceSourceMap = ConferenceSourceMap( + JID_1 to endpoint1CombinedSourceSet, + JID_2 to endpoint2SourceSet + ) + context("All of an endpoint's sources") { + conferenceSourceMap.remove(ConferenceSourceMap(JID_2 to endpoint2SourceSet)) + conferenceSourceMap.size shouldBe 1 + conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet + conferenceSourceMap[JID_2] shouldBe null + } + context("Some of an endpoint's sources") { + conferenceSourceMap.remove(ConferenceSourceMap(JID_1 to endpoint1SourceSet)) + conferenceSourceMap.size shouldBe 2 + conferenceSourceMap[JID_1] shouldBe EndpointSourceSet( + setOf(s3, s4), + setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 4))) + ) + conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet + } + context("Nonexistent endpoint sources") { + conferenceSourceMap.remove( + ConferenceSourceMap( + JID_1 to EndpointSourceSet( + setOf(Source(12345, MediaType.VIDEO)), + setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(12345))) + ) + ) + ) + conferenceSourceMap.size shouldBe 2 + conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet + conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet + } + context("Nonexistent endpoint") { + conferenceSourceMap.remove(ConferenceSourceMap("differentJid" to endpoint1CombinedSourceSet)) + conferenceSourceMap.size shouldBe 2 + conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet + conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet + } + } + context("Operator plus") { + val a = ConferenceSourceMap(JID_1 to endpoint1SourceSet) + val b = ConferenceSourceMap(JID_2 to endpoint2SourceSet) + (a + b) shouldBe ConferenceSourceMap(JID_1 to endpoint1SourceSet, JID_2 to endpoint2SourceSet) + } + context("Operator minus") { + val a = ConferenceSourceMap(JID_1 to endpoint1SourceSet, JID_2 to endpoint2SourceSet) + val b = ConferenceSourceMap(JID_1 to endpoint1SourceSet) + (a - b) shouldBe ConferenceSourceMap(JID_2 to endpoint2SourceSet) + } + context("To Jingle") { + val conferenceSourceMap = ConferenceSourceMap(JID_1 to endpoint1SourceSet) + val contents = conferenceSourceMap.toJingle() + + contents.size shouldBe 2 + + val videoContent = contents.find { it.name == "video" }!! + val videoRtpDesc = videoContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!! + val videoSources = + videoRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java) + videoSources.map { Source(MediaType.VIDEO, it) }.toSet() shouldBe setOf( + Source(1, MediaType.VIDEO), + Source(2, MediaType.VIDEO) + ) + videoSources.forEach { + it.getFirstChildOfType(SSRCInfoPacketExtension::class.java).owner shouldBe JID_1 + } + + val sourceGroupPacketExtension = + videoRtpDesc.getFirstChildOfType(SourceGroupPacketExtension::class.java)!! + SsrcGroup.fromPacketExtension(sourceGroupPacketExtension) shouldBe + SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2)) + + val audioContent = contents.find { it.name == "audio" }!! + val audioRtpDesc = audioContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!! + val audioSources = audioRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java) + audioSources.map { Source(MediaType.AUDIO, it) } shouldBe listOf(s7) + audioSources.forEach { + it.getFirstChildOfType(SSRCInfoPacketExtension::class.java).owner shouldBe JID_1 + } + } + context("unmodifiable") { + val conferenceSourceMap = ConferenceSourceMap( + JID_1 to endpoint1SourceSet, + JID_2 to endpoint2SourceSet + ) + + val unmodifiable = conferenceSourceMap.unmodifiable + unmodifiable[JID_1] shouldBe endpoint1SourceSet + + conferenceSourceMap.add(JID_1, endpoint1AdditionalSourceSet) + conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet + unmodifiable[JID_1] shouldBe endpoint1CombinedSourceSet + + shouldThrow { + unmodifiable.add(ConferenceSourceMap()) + } + shouldThrow { + unmodifiable.remove(ConferenceSourceMap()) + } + } + context("Strip") { + val s7 = Source(7, MediaType.AUDIO) + val sourceSet = EndpointSourceSet( + setOf(s1, s2, s3, s4, s5, s6, s7), + setOf(sim, fid1, fid2, fid3) + ) + val conferenceSourceMap = ConferenceSourceMap(JID_1 to sourceSet, JID_2 to e2sourceSet) + + // Assume EndpointSourceSet.stripSimulcast works correctly, tested above. + context("Simulcast") { + conferenceSourceMap.stripSimulcast() + conferenceSourceMap[JID_1] shouldBe sourceSet.stripSimulcast + conferenceSourceMap[JID_2] shouldBe e2sourceSet.stripSimulcast + } + } + context("Compact JSON") { + val endpointId1 = "bebebebe" + val endpointId2 = "dadadada" + val jvb = "jvb" + val jvbEndpointSourceSet = EndpointSourceSet(Source(12345, MediaType.VIDEO)) + + val conferenceSourceMap = ConferenceSourceMap( + endpointId1 to sourceSet, + endpointId2 to e2sourceSet, + jvb to jvbEndpointSourceSet + ) + val jsonString = conferenceSourceMap.compactJson() + val json = jacksonObjectMapper().readTree(jsonString) + json.shouldBeInstanceOf() + json.size() shouldBe 3 + json.fieldNames().asSequence().toSet() shouldBe setOf(endpointId1, endpointId2, "jvb") + } + context("Debug json") { + val conferenceSourceMap = ConferenceSourceMap( + "jid1" to endpoint1CombinedSourceSet, + "jid2" to endpoint2SourceSet + ) + conferenceSourceMap.toJson().shouldBeValidJson() + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/EndpointSourceSetTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/EndpointSourceSetTest.kt new file mode 100644 index 0000000..a0ef7d3 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/EndpointSourceSetTest.kt @@ -0,0 +1,164 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.jitsi.utils.MediaType.AUDIO +import org.jitsi.utils.MediaType.VIDEO +import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension +import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension +import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension + +@Suppress("NAME_SHADOWING") +class EndpointSourceSetTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + init { + context("From XML") { + // Assume serializing works correctly -- it's tested below. + val contents = sourceSet.toJingle(JID_1) + val parsed = EndpointSourceSet.fromJingle(contents) + // Use the provided ownerJid, not the one encoded in XML. + parsed shouldBe sourceSet + } + context("To XML") { + val contents = sourceSet.toJingle() + contents.size shouldBe 2 + + val videoContent = contents.find { it.name == "video" }!! + val videoRtpDesc = videoContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!! + val videoSources = videoRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java) + .map { Source(VIDEO, it) }.toSet() + videoSources shouldBe sourceSet.sources.filter { it.mediaType == VIDEO }.toSet() + + val sourceGroupPacketExtensions = + videoRtpDesc.getChildExtensionsOfType(SourceGroupPacketExtension::class.java) + sourceGroupPacketExtensions.map { SsrcGroup.fromPacketExtension(it) }.toSet() shouldBe + sourceSet.ssrcGroups + + val audioContent = contents.find { it.name == "audio" }!! + val audioRtpDesc = audioContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!! + val audioSources = audioRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java) + .map { Source(AUDIO, it) }.toSet() + audioSources shouldBe sourceSet.sources.filter { it.mediaType == AUDIO }.toSet() + } + context("Strip simulcast") { + val s8 = Source(8, VIDEO) + + context("Without RTX") { + EndpointSourceSet( + setOf(s1, s2, s3, s7, s8), + setOf(sim) + ).stripSimulcast shouldBe EndpointSourceSet(setOf(s1, s7, s8)) + } + context("With multiple SIM groups") { + EndpointSourceSet( + sourceSet.sources + s8, + setOf( + sim, + SsrcGroup(SsrcGroupSemantics.Sim, listOf(4, 5, 6)) + ) + ).stripSimulcast shouldBe EndpointSourceSet(setOf(s1, s4, s7, s8)) + } + context("With RTX") { + EndpointSourceSet( + sourceSet.sources + s8, + sourceSet.ssrcGroups + ).stripSimulcast shouldBe EndpointSourceSet( + setOf(s1, s4, s7, s8), + setOf(fid1) + ) + } + context("Compact JSON") { + // See the documentation of [EndpointSourceSet.compactJson] for the expected JSON format. + val json = jacksonObjectMapper().readTree(sourceSet.compactJson) + json.shouldBeInstanceOf() + + val videoSourceList = json[0] + videoSourceList.shouldBeInstanceOf() + videoSourceList.map { node -> mapOf("s" to node["s"].asLong()) }.toSet() shouldBe setOf( + mapOf("s" to 1L), + mapOf("s" to 2L), + mapOf("s" to 3L), + mapOf("s" to 4L), + mapOf("s" to 5L), + mapOf("s" to 6L) + ) + + val videoSsrcGroups = json[1] + videoSsrcGroups.shouldBeInstanceOf() + videoSsrcGroups.map { groupNode -> + groupNode.map { if (it.isTextual) it.asText() else it.asInt() } + }.toSet() shouldBe setOf( + listOf("s", 1, 2, 3), + listOf("f", 1, 4), + listOf("f", 2, 5), + listOf("f", 3, 6) + ) + + val audioSourceList = json[2] + audioSourceList.shouldBeInstanceOf() + audioSourceList.map { node -> mapOf("s" to node["s"].asLong()) }.toSet() shouldBe setOf( + mapOf("s" to 7L) + ) + + // No audio source groups encoded. + json.size() shouldBe 3 + } + } + } +} + +const val JID_1 = "jid1" +const val JID_2 = "jid2" +val s1 = Source(1, VIDEO) +val s2 = Source(2, VIDEO) +val s3 = Source(3, VIDEO) +val s4 = Source(4, VIDEO) +val s5 = Source(5, VIDEO) +val s6 = Source(6, VIDEO) +val s7 = Source(7, AUDIO) +val sim = SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)) +val fid1 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 4)) +val fid2 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(2, 5)) +val fid3 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 6)) +val sourceSet = EndpointSourceSet( + setOf(s1, s2, s3, s4, s5, s6, s7), + setOf(sim, fid1, fid2, fid3) +) + +val e2s1 = Source(11, VIDEO) +val e2s2 = Source(12, VIDEO) +val e2s3 = Source(13, VIDEO) +val e2s4 = Source(14, VIDEO) +val e2s5 = Source(15, VIDEO) +val e2s6 = Source(16, VIDEO) +val e2s7 = Source(17, AUDIO) +val e2sim = SsrcGroup(SsrcGroupSemantics.Sim, listOf(11, 12, 13)) +val e2fid1 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(11, 14)) +val e2fid2 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(12, 15)) +val e2fid3 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(13, 16)) +val e2sourceSet = EndpointSourceSet( + setOf(e2s1, e2s2, e2s3, e2s4, e2s5, e2s6, e2s7), + setOf(e2sim, e2fid1, e2fid2, e2fid3) +) diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/SourceTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/SourceTest.kt new file mode 100644 index 0000000..7d6b034 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/SourceTest.kt @@ -0,0 +1,79 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.jitsi.utils.MediaType +import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension +import org.jitsi.xmpp.extensions.jingle.ParameterPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.SSRCInfoPacketExtension + +class SourceTest : ShouldSpec() { + init { + context("From XML") { + val packetExtension = SourcePacketExtension().apply { + ssrc = 1 + name = "name-1" + videoType = "camera" + addChildExtension(ParameterPacketExtension("msid", "msid")) + } + + Source(MediaType.VIDEO, packetExtension) shouldBe + Source(1, MediaType.VIDEO, name = "name-1", msid = "msid", videoType = VideoType.Camera) + } + context("To XML") { + val msidValue = "msid-value" + val nameValue = "source-name-value" + val videoType = VideoType.Desktop + val source = Source(1, MediaType.VIDEO, name = nameValue, msid = msidValue, videoType = videoType) + val owner = "abcdabcd" + val extension = source.toPacketExtension(owner = owner) + + extension.ssrc shouldBe 1 + extension.name shouldBe nameValue + extension.videoType shouldBe videoType.toString() + val parameters = extension.getChildExtensionsOfType(ParameterPacketExtension::class.java) + parameters.filter { it.name == "msid" && it.value == msidValue }.size shouldBe 1 + + val ssrcInfo = extension.getFirstChildOfType(SSRCInfoPacketExtension::class.java) + ssrcInfo shouldNotBe null + ssrcInfo.owner.toString() shouldBe owner + } + context("Compact JSON") { + Source(1, MediaType.VIDEO, name = "test-name", msid = "msid").compactJson shouldBe + """ + {"s":1,"n":"test-name","m":"msid"} + """.trimIndent() + Source( + 1, + MediaType.VIDEO, + videoType = VideoType.Desktop, + name = "test-name", + msid = "msid" + ).compactJson shouldBe + """ + {"s":1,"n":"test-name","m":"msid","v":"d"} + """.trimIndent() + Source(1, MediaType.AUDIO).compactJson shouldBe """ + {"s":1} + """.trimIndent() + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/SsrcGroupTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/SsrcGroupTest.kt new file mode 100644 index 0000000..164f4db --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/SsrcGroupTest.kt @@ -0,0 +1,77 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.utils.MediaType +import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension +import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension + +class SsrcGroupTest : ShouldSpec() { + init { + context("SsrcGroupSemantics") { + context("Parsing") { + SsrcGroupSemantics.fromString("sim") shouldBe SsrcGroupSemantics.Sim + SsrcGroupSemantics.fromString("SIM") shouldBe SsrcGroupSemantics.Sim + SsrcGroupSemantics.fromString("sIM") shouldBe SsrcGroupSemantics.Sim + SsrcGroupSemantics.fromString("fiD") shouldBe SsrcGroupSemantics.Fid + + shouldThrow { + SsrcGroupSemantics.fromString("invalid") + } + } + context("To string") { + SsrcGroupSemantics.Sim.toString() shouldBe "SIM" + SsrcGroupSemantics.Fid.toString() shouldBe "FID" + } + } + context("From XML") { + val packetExtension = SourceGroupPacketExtension().apply { + semantics = "sim" + addSources( + listOf( + SourcePacketExtension().apply { ssrc = 1 }, + SourcePacketExtension().apply { ssrc = 2 }, + SourcePacketExtension().apply { ssrc = 3 } + ) + ) + } + + val ssrcGroup = SsrcGroup.fromPacketExtension(packetExtension) + ssrcGroup shouldBe SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)) + } + context("To XML") { + val ssrcGroup = SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)) + val packetExtension = ssrcGroup.toPacketExtension() + packetExtension.semantics shouldBe "SIM" + packetExtension.sources.size shouldBe 3 + packetExtension.sources.map { Source(MediaType.VIDEO, it) }.toList() shouldBe listOf( + Source(1, MediaType.VIDEO), + Source(2, MediaType.VIDEO), + Source(3, MediaType.VIDEO) + ) + } + context("Compact JSON") { + SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)).compactJson shouldBe """ + ["s",1,2,3] + """.trimIndent() + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/SsrcValidationPerfTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/SsrcValidationPerfTest.kt new file mode 100644 index 0000000..362d15a --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/SsrcValidationPerfTest.kt @@ -0,0 +1,129 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.utils.MediaType +import org.jitsi.utils.logging2.createLogger +import java.time.Clock +import java.time.Duration + +class SsrcValidationPerfTest : ShouldSpec() { + init { + val numEndpoints = 500 + xcontext("ValidatingConferenceSourceMap") { + context("Add/Remove a source to a large conference") { + val conferenceSources = createConferenceSourceMap(numEndpoints) + conferenceSources.size shouldBe numEndpoints + + val newEndpointSourceSet = createEndpointSourceSet("new-endpoint", ssrcCount) + val newEndpointId = "new-endpoint" + ssrcCount += newEndpointSourceSet.sources.size + + var added: EndpointSourceSet? = null + measureAndLog("Single add") { + added = conferenceSources.tryToAdd(newEndpointId, newEndpointSourceSet) + } + added shouldBe newEndpointSourceSet + conferenceSources.size shouldBe numEndpoints + 1 + + var removed: EndpointSourceSet? = null + measureAndLog("Single remove") { + removed = conferenceSources.tryToRemove(newEndpointId, newEndpointSourceSet) + } + conferenceSources.size shouldBe numEndpoints + removed shouldBe newEndpointSourceSet + } + context("Sequential add/remove") { + val conferenceSources = ValidatingConferenceSourceMap( + MAX_SSRCS_PER_USER, + MAX_SSRC_GROUPS_PER_USER + ) + measureAndLog("Adding all endpoints") { + for (i in 0 until numEndpoints) { + val endpointId = "endpoint-$i" + val sourceSet = createEndpointSourceSet(endpointId, ssrcCount) + ssrcCount += sourceSet.sources.size + measureAndLog("Adding endpoint $i") { + conferenceSources.tryToAdd(endpointId, sourceSet) + } + } + } + conferenceSources.size shouldBe numEndpoints + measureAndLog("Removing all endpoints") { + var i = 0 + while (conferenceSources.isNotEmpty()) { + val sourceToRemove = conferenceSources.entries.first() + measureAndLog("Removing endpoint $i") { + conferenceSources.tryToRemove(sourceToRemove.key, sourceToRemove.value) + } + i++ + } + } + conferenceSources.size shouldBe 0 + } + } + } + + private var ssrcCount = 1L + + private fun createEndpointSourceSet(endpointId: String, ssrcBase: Long) = EndpointSourceSet( + setOf( + Source(ssrcBase, MediaType.VIDEO, msid = "msid-$endpointId"), + Source(ssrcBase + 1, MediaType.VIDEO, msid = "msid-$endpointId"), + Source(ssrcBase + 2, MediaType.VIDEO, msid = "msid-$endpointId"), + Source(ssrcBase + 3, MediaType.VIDEO, msid = "msid-$endpointId"), + Source(ssrcBase + 4, MediaType.VIDEO, msid = "msid-$endpointId"), + Source(ssrcBase + 5, MediaType.VIDEO, msid = "msid-$endpointId"), + Source(ssrcBase + 6, MediaType.AUDIO, msid = "msid-$endpointId") + ), + setOf( + SsrcGroup(SsrcGroupSemantics.Sim, listOf(ssrcBase, ssrcBase + 1, ssrcBase + 2)), + SsrcGroup(SsrcGroupSemantics.Fid, listOf(ssrcBase, ssrcBase + 3)), + SsrcGroup(SsrcGroupSemantics.Fid, listOf(ssrcBase + 1, ssrcBase + 4)), + SsrcGroup(SsrcGroupSemantics.Fid, listOf(ssrcBase + 2, ssrcBase + 5)), + ) + ) + + private fun createConferenceSourceMap(numEndpoints: Int) = ValidatingConferenceSourceMap( + MAX_SSRCS_PER_USER, + MAX_SSRC_GROUPS_PER_USER + ).apply { + for (i in 0 until numEndpoints) { + val endpointSourceSet = createEndpointSourceSet(i.toString(), ssrcCount) + ssrcCount += endpointSourceSet.sources.size + add("endpoint$i", endpointSourceSet) + } + } + + private val logger = createLogger() + private fun measureAndLog(name: String, clock: Clock = Clock.systemUTC(), block: () -> Unit) { + logger.info("$name took ${measure(clock, block).toMillis()}") + } +} + +fun measure(clock: Clock = Clock.systemUTC(), block: () -> Unit): Duration { + val start = clock.instant() + block() + return Duration.between(start, clock.instant()) +} + +// Default values from jicofo.conference.max-ssrcs-per-user / max-ssrc-groups-per-user. +private const val MAX_SSRCS_PER_USER = 20 +private const val MAX_SSRC_GROUPS_PER_USER = 20 diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/ValidatingConferenceSourceMapTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/ValidatingConferenceSourceMapTest.kt new file mode 100644 index 0000000..0283881 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/conference/source/ValidatingConferenceSourceMapTest.kt @@ -0,0 +1,417 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.utils.MediaType.AUDIO +import org.jitsi.utils.MediaType.VIDEO + +@Suppress("NAME_SHADOWING") +class ValidatingConferenceSourceMapTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + init { + val jid1 = "jid1" + val jid2 = "jid2" + val msid = "msid" + + val s1 = Source(1, VIDEO, msid = msid) + val s2 = Source(2, VIDEO, msid = msid) + val s3 = Source(3, VIDEO, msid = msid) + val s4 = Source(4, VIDEO, msid = msid) + val s5 = Source(5, VIDEO, msid = msid) + val s6 = Source(6, VIDEO, msid = msid) + val s7 = Source(7, AUDIO) + val videoSources = setOf(s1, s2, s3, s4, s5, s6) + val sources = setOf(s1, s2, s3, s4, s5, s6, s7) + + val sim = SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)) + val fid1 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 4)) + val fid2 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(2, 5)) + val fid3 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 6)) + val groups = setOf(sim, fid1, fid2, fid3) + + val sourceSet = EndpointSourceSet(sources, groups) + + val conferenceSources = ValidatingConferenceSourceMap( + MAX_SSRCS_PER_USER, + MAX_SSRC_GROUPS_PER_USER + ) + + context("Adding sources.") { + context("Empty sources") { + conferenceSources.tryToAdd(jid1, EndpointSourceSet()) shouldBe EndpointSourceSet.EMPTY + } + context("Standard VP8 signaling with simulcast and RTX") { + context("Signaled all together") { + conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet + } + context("Signaled with video first") { + conferenceSources.tryToAdd(jid1, EndpointSourceSet(videoSources, groups)) shouldBe + EndpointSourceSet(videoSources, groups) + conferenceSources.tryToAdd(jid1, EndpointSourceSet(s7)) shouldBe EndpointSourceSet(s7) + } + context("Signaled with audio first") { + conferenceSources.tryToAdd(jid1, EndpointSourceSet(s7)) shouldBe EndpointSourceSet(s7) + conferenceSources.tryToAdd(jid1, EndpointSourceSet(videoSources, groups)) shouldBe + EndpointSourceSet(videoSources, groups) + } + context("Adding a second endpoint") { + val sourceSet2 = EndpointSourceSet( + setOf( + Source(101, VIDEO, msid = "msid2"), + Source(102, VIDEO, msid = "msid2"), + Source(103, AUDIO) + ), + setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(101, 102))) + ) + + conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet + conferenceSources.tryToAdd(jid2, sourceSet2) shouldBe sourceSet2 + } + } + context("Invalid SSRCs") { + val invalidSsrcs = listOf(-1, 0, 0x1_0000_0000) + invalidSsrcs.forEach { + val sourcesWithInvalidSsrc = EndpointSourceSet(setOf(Source(it, AUDIO))) + + shouldThrow { + conferenceSources.tryToAdd(jid1, sourcesWithInvalidSsrc) + } + } + } + context("Max SSRC count limit") { + val conferenceSources = ValidatingConferenceSourceMap( + maxSsrcsPerUser = 4, + MAX_SSRC_GROUPS_PER_USER + ) + context("At once") { + shouldThrow { + conferenceSources.tryToAdd(jid1, sourceSet) + } + } + context("With multiple adds") { + val sourceSet1 = EndpointSourceSet( + setOf(s1, s2, s3), + setOf(sim) + ) + val sourceSet2 = EndpointSourceSet( + setOf(s4, s5, s6), + setOf(fid1, fid2, fid3) + ) + + conferenceSources.tryToAdd(jid1, sourceSet1) shouldBe sourceSet1 + shouldThrow { + conferenceSources.tryToAdd(jid1, sourceSet2) + } + } + } + context("Max ssrc-group count limit") { + val conferenceSources = ValidatingConferenceSourceMap( + MAX_SSRCS_PER_USER, + maxSsrcGroupsPerUser = 2 + ) + context("At once") { + shouldThrow { + conferenceSources.tryToAdd(jid1, sourceSet) + } + } + context("With multiple adds") { + val sourceSet1 = EndpointSourceSet( + setOf(s1, s2, s3), + setOf(sim) + ) + val sourceSet2 = EndpointSourceSet( + setOf(s4, s5, s6), + setOf(fid1, fid2, fid3) + ) + conferenceSources.tryToAdd(jid1, sourceSet1) shouldBe sourceSet1 + shouldThrow { + conferenceSources.tryToAdd(jid1, sourceSet2) + } + } + } + context("Duplicate SSRCs") { + val source = EndpointSourceSet(setOf(Source(1, AUDIO))) + val duplicateSourceAudio = EndpointSourceSet( + setOf(Source(1, AUDIO, msid = "differentMsid")) + ) + val duplicateSourceVideo = EndpointSourceSet( + setOf(Source(1, VIDEO, msid = "differentMsid")) + ) + + conferenceSources.tryToAdd(jid1, source) shouldBe source + context("Advertised by the same endpoint.") { + shouldThrow { + conferenceSources.tryToAdd(jid1, duplicateSourceAudio) + } + shouldThrow { + conferenceSources.tryToAdd(jid1, duplicateSourceVideo) + } + } + context("Advertised by another endpoint.") { + shouldThrow { + conferenceSources.tryToAdd(jid2, duplicateSourceAudio) + } + shouldThrow { + conferenceSources.tryToAdd(jid2, duplicateSourceVideo) + } + } + } + context("SSRC group with missing MSID") { + val sources = setOf( + Source(1, VIDEO, msid = null), + Source(2, VIDEO, msid = null), + Source(3, VIDEO, msid = null) + ) + val groups = setOf(SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3))) + shouldThrow { + conferenceSources.tryToAdd(jid1, EndpointSourceSet(sources, groups)) + } + } + context("SSRC group with different MSID values") { + val sources = setOf( + Source(1, VIDEO, msid = msid), + Source(2, VIDEO, msid = msid), + Source(3, VIDEO, msid = "differentMsid") + ) + val groups = setOf(SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3))) + + shouldThrow { + conferenceSources.tryToAdd(jid1, EndpointSourceSet(sources, groups)) + } + } + context("Group with an SSRC that has no source signaled.") { + shouldThrow { + conferenceSources.tryToAdd( + jid1, + EndpointSourceSet( + // No source for ssrc 2 + setOf(Source(1, VIDEO, msid = "msid")), + setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2))) + ) + ) + } + } + context("FID groups") { + // Fid groups must have exactly 2 ssrcs + shouldThrow { + conferenceSources.tryToAdd( + jid1, + EndpointSourceSet( + setOf(s1, s2, s3), + setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2, 3))) + ) + ) + } + shouldThrow { + conferenceSources.tryToAdd( + jid1, + EndpointSourceSet( + setOf(s1), + setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(1))) + ) + ) + } + } + context("MSID Conflicts") { + val sourceSet1 = EndpointSourceSet(setOf(s1)) + val sourceSet2 = EndpointSourceSet(setOf(s2)) + val combinedSourceSet = EndpointSourceSet(setOf(s1, s2)) + + context("With another endpoint") { + conferenceSources.tryToAdd(jid1, sourceSet1) + shouldThrow { + // jid2 tries to use an MSID already used by jid1 + conferenceSources.tryToAdd(jid2, sourceSet2) + } + } + context("Multiple non-grouped SSRCs can have MSID=null") { + // I don't know if this is actually desired and/or used by clients. It is existing behavior that + // AdvertiseSSRCsTest depends upon. + val sourceSet = EndpointSourceSet( + setOf( + Source(1, VIDEO), + Source(2, VIDEO), + Source(3, VIDEO), + Source(4, AUDIO), + Source(5, AUDIO) + ) + ) + conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet + } + context("Within the sources of the same endpoint.") { + context("Ungrouped") { + context("Added separately") { + conferenceSources.tryToAdd(jid1, sourceSet1) + // s1 and s2 have the same MSID + shouldThrow { + conferenceSources.tryToAdd(jid1, sourceSet2) + } + } + context("Added together") { + shouldThrow { + conferenceSources.tryToAdd(jid1, combinedSourceSet) + } + } + } + context("In independent FID groups") { + val endpointSourceSet = EndpointSourceSet( + setOf(s1, s2, s4, s5), + setOf(fid1, fid2) + ) + shouldThrow { + conferenceSources.tryToAdd(jid1, endpointSourceSet) + } + } + context("In independent SIM groups") { + val endpointSourceSet = EndpointSourceSet( + setOf(s1, s2, s3, s4), + setOf( + SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2)), + SsrcGroup(SsrcGroupSemantics.Sim, listOf(3, 4)) + ) + ) + shouldThrow { + conferenceSources.tryToAdd(jid1, endpointSourceSet) + } + } + } + context("Audio and video can have the same MSID") { + context("With no MSID") { + val sourceSet = EndpointSourceSet( + setOf( + Source(1, AUDIO), + Source(2, VIDEO) + ) + ) + conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet + } + context("With MSID and no groups") { + val sourceSet = EndpointSourceSet(setOf(s1, Source(111, AUDIO, msid = msid))) + + conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet + } + context("With MSID and groups") { + val sourceSet = EndpointSourceSet( + sources + Source(111, AUDIO, msid = msid), + groups + ) + + conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet + } + } + } + context("Adding an empty group") { + val accepted = conferenceSources.tryToAdd( + jid1, + EndpointSourceSet( + setOf(s1, s2, s3), + setOf( + sim, + SsrcGroup(SsrcGroupSemantics.Sim, emptyList()), + SsrcGroup(SsrcGroupSemantics.Fid, emptyList()), + ) + ) + ) + + // The empty groups should be silently ignored. + accepted shouldBe EndpointSourceSet(setOf(s1, s2, s3), setOf(sim)) + } + } + context("Removing sources") { + + conferenceSources.tryToAdd(jid1, sourceSet) + + context("Empty sources") { + conferenceSources.tryToRemove(jid1, EndpointSourceSet()) shouldBe EndpointSourceSet.EMPTY + } + context("Successful removal") { + context("Of all sources and groups") { + conferenceSources.tryToRemove(jid1, sourceSet) shouldBe sourceSet + } + context("Of all sources, groups assumed") { + conferenceSources.tryToRemove(jid1, EndpointSourceSet(sources)) shouldBe sourceSet + } + context("Of a subset of sources") { + // s1 remains, with no associated groups. + val toRemove = EndpointSourceSet(sources - s1, groups) + conferenceSources.tryToRemove(jid1, toRemove) shouldBe toRemove + } + } + context("Removing non-signaled sources") { + context("Another endpoint's sources") { + shouldThrow { + conferenceSources.tryToRemove(jid2, sourceSet) + } + } + context("A non-signaled source") { + shouldThrow { + conferenceSources.tryToRemove(jid1, EndpointSourceSet(Source(1111, AUDIO))) + } + } + context("A non-signaled ssrc-group") { + shouldThrow { + conferenceSources.tryToRemove( + jid1, + EndpointSourceSet( + sources, + groups + SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2)) + ) + ) + } + } + } + context("Removing a subset of sources") { + context("Leaving two FID groups") { + shouldThrow { + conferenceSources.tryToRemove( + jid1, + EndpointSourceSet( + setOf(s1, s4), + setOf(sim, fid1) + ) + ) + } + } + context("Removing only part of a simulcast group's sources") { + // This matches multiple failure conditions (MSIF conflict, missing source from a group) + shouldThrow { + conferenceSources.tryToRemove(jid1, EndpointSourceSet(s1)) + } + } + context("Removing a group, but not its sources") { + shouldThrow { + conferenceSources.tryToRemove(jid1, EndpointSourceSet(sim)) + } + shouldThrow { + conferenceSources.tryToRemove(jid1, EndpointSourceSet(fid1)) + } + shouldThrow { + conferenceSources.tryToRemove(jid1, EndpointSourceSet(ssrcGroups = groups)) + } + } + } + } + } +} + +// Default values from jicofo.conference.max-ssrcs-per-user / max-ssrc-groups-per-user. +private const val MAX_SSRCS_PER_USER = 20 +private const val MAX_SSRC_GROUPS_PER_USER = 20 diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/InPlaceExecutors.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/InPlaceExecutors.kt new file mode 100644 index 0000000..6643f06 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/InPlaceExecutors.kt @@ -0,0 +1,44 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.mock + +import io.mockk.every +import io.mockk.mockk +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService +import java.util.concurrent.ScheduledExecutorService + +// Execute tasks in place (in the current thread, blocking) +val inPlaceExecutor: ExecutorService = mockk { + every { submit(any()) } answers { + firstArg().run() + CompletableFuture().apply { + complete(Unit) + } + } + every { execute(any()) } answers { + firstArg().run() + } +} + +val inPlaceScheduledExecutor: ScheduledExecutorService = mockk { + every { schedule(any(), any(), any()) } answers { + firstArg().run() + mockk(relaxed = true) { + every { isDone } returns true + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/MockChatRoom.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/MockChatRoom.kt new file mode 100644 index 0000000..33edcc8 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/MockChatRoom.kt @@ -0,0 +1,103 @@ +/* + * Copyright @ 2022 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.mock + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.xmpp.Features +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jitsi.jicofo.xmpp.muc.ChatRoom +import org.jitsi.jicofo.xmpp.muc.ChatRoomInfo +import org.jitsi.jicofo.xmpp.muc.ChatRoomListener +import org.jitsi.jicofo.xmpp.muc.ChatRoomMember +import org.jitsi.jicofo.xmpp.muc.MemberRole +import org.jivesoftware.smack.packet.ExtensionElement +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import java.lang.IllegalArgumentException +import javax.xml.namespace.QName + +class MockChatRoom( + val xmppProvider: XmppProvider, + val roomJid: EntityBareJid = JidCreate.entityBareFrom("room@conference.example.com") +) { + val chatRoomListeners = mutableListOf() + val memberList = mutableListOf() + + /** Settable audio/video sender counts (the real ChatRoom derives them from member presence). */ + var audioSenders = 0 + var videoSenders = 0 + + /** Settable visitor count (the real ChatRoom derives it from member presence). */ + var visitors = 0 + + val chatRoom = mockk(relaxed = true) { + every { addListener(capture(chatRoomListeners)) } returns Unit + every { roomJid } returns this@MockChatRoom.roomJid + every { members } returns memberList + every { memberCount } answers { memberList.size } + every { audioSendersCount } answers { audioSenders } + every { videoSendersCount } answers { videoSenders } + every { visitorCount } answers { visitors } + // Without this a relaxed mock returns a ChatRoomInfo with a non-null mainRoomJid, i.e. the room looks like a + // breakout room. Let jicofo generate the meeting ID, as it does when the MUC does not advertise one. + every { join() } returns ChatRoomInfo(meetingId = null, mainRoomJid = null) + every { xmppProvider } returns this@MockChatRoom.xmppProvider + every { debugState } returns JsonNodeFactory.instance.objectNode() + every { getChatMember(any()) } answers { memberList.find { it.occupantJid == arg(0) } } + } + + fun addMember( + id: String, + role: MemberRole = MemberRole.PARTICIPANT, + jibri: Boolean = false, + jigasi: Boolean = false + ): ChatRoomMember { + val occupant = JidCreate.entityFullFrom("$roomJid/$id") + val member = mockk(relaxed = true) { + every { name } returns id + every { occupantJid } returns occupant + every { chatRoom } returns this@MockChatRoom.chatRoom + every { features } returns Features.defaultFeatures + every { isJibri } returns jibri + every { isJigasi } returns jigasi + every { debugState } returns JsonNodeFactory.instance.objectNode() + every { presence } returns mockk { + every { status } returns null + every { getExtension(any()) } returns null + every { getExtension(any()) } returns null + every { getExtension(any>()) } returns null + } + } + every { member.role } returns role + memberList.add(member) + chatRoomListeners.forEach { it.memberJoined(member) } + return member + } + + /** Change the role of [member] and notify listeners, as if a presence update was received. */ + fun setRole(member: ChatRoomMember, role: MemberRole) { + every { member.role } returns role + chatRoomListeners.forEach { it.memberPresenceChanged(member) } + } + + fun removeMember(member: ChatRoomMember) { + if (!memberList.contains(member)) throw IllegalArgumentException("not a member") + memberList.remove(member) + chatRoomListeners.forEach { it.memberLeft(member) } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/MockXmppConnection.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/MockXmppConnection.kt new file mode 100644 index 0000000..e3e2468 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/MockXmppConnection.kt @@ -0,0 +1,54 @@ +/* + * Copyright @ 2022 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.mock + +import io.mockk.every +import io.mockk.mockk +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.Stanza +import org.jivesoftware.smack.packet.StanzaFactory +import org.jivesoftware.smack.packet.id.StanzaIdSource + +open class MockXmppConnection { + val xmppConnection: AbstractXMPPConnection = mockk(relaxed = true) { + every { createStanzaCollectorAndSend(any()) } answers { + val request = arg(0) + mockk(relaxed = true) { + every { nextResult() } answers { handleIq(request) } + } + } + + every { trySendStanza(any()) } answers { + val request = arg(0) + if (request is IQ) handleIq(request) + true + } + + every { sendStanza(any()) } answers { + val request = arg(0) + if (request is IQ) handleIq(request) + } + + val stanzaIdSource = object : StanzaIdSource { + private var stanzaId = 0 + override fun getNewStanzaId() = "${stanzaId++}" + } + every { stanzaFactory } returns StanzaFactory(stanzaIdSource) + } + + open fun handleIq(iq: IQ): IQ? = null +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/MockXmppProvider.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/MockXmppProvider.kt new file mode 100644 index 0000000..57e66fe --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/MockXmppProvider.kt @@ -0,0 +1,70 @@ +/* + * Copyright @ 2022 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.mock + +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.ConnectionListener +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate + +class MockXmppProvider( + val xmppConnection: AbstractXMPPConnection = MockXmppConnection().xmppConnection, + /** The name of the connection, as it appears in [XmppProvider.getConfig]. */ + val name: String = "mock", + /** The XMPP domain of the connection, needed to map a main room JID to a visitor room JID. */ + xmppDomain: String? = null +) { + val chatRooms = mutableMapOf() + + /** Settable registration state, to simulate the XMPP connection going down and coming back up. */ + var registered = true + + val xmppProvider = mockk(relaxed = true) { + every { registered } answers { this@MockXmppProvider.registered } + every { findOrCreateRoom(any(), any()) } answers { getRoom(arg(0)).chatRoom } + every { xmppConnection } returns this@MockXmppProvider.xmppConnection + every { config } returns mockk(relaxed = true) { + every { this@mockk.name } returns this@MockXmppProvider.name + every { this@mockk.xmppDomain } returns xmppDomain?.let { JidCreate.domainBareFrom(it) } + } + } + + /** + * The Smack connection listeners that were registered on [xmppConnection]. Note that when instances share an + * [xmppConnection] only the instance that was created last captures the listeners. + */ + val connectionListeners = mutableListOf() + + init { + every { xmppConnection.addConnectionListener(capture(connectionListeners)) } returns Unit + every { xmppConnection.removeConnectionListener(any()) } answers { + connectionListeners.remove(arg(0)) + Unit + } + } + + /** Simulate the connection authenticating, i.e. coming up or coming back up after a disconnect. */ + fun authenticated(resumed: Boolean) { + registered = true + connectionListeners.toList().forEach { it.authenticated(xmppConnection, resumed) } + } + + fun getRoom(jid: EntityBareJid): MockChatRoom = + chatRooms.computeIfAbsent(jid) { MockChatRoom(this.xmppProvider, jid) } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/PendingExecutor.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/PendingExecutor.kt new file mode 100644 index 0000000..e59a32a --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/mock/PendingExecutor.kt @@ -0,0 +1,47 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.mock + +import io.mockk.every +import io.mockk.mockk +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService + +/** + * An executor that queues tasks until [runAll] is called, so tests can control when "async" work executes. Unlike + * [inPlaceExecutor] it does not run tasks re-entrantly, which matters for code that assumes responses are processed + * on a separate thread (e.g. after a lock has been released). + */ +class PendingExecutor { + private val pending = ArrayDeque() + + val executor: ExecutorService = mockk { + every { submit(any()) } answers { + pending.add(firstArg()) + CompletableFuture().apply { complete(Unit) } + } + every { execute(any()) } answers { + pending.add(firstArg()) + } + } + + /** Run all queued tasks, including any that get queued while draining. */ + fun runAll() { + while (pending.isNotEmpty()) { + pending.removeFirst().run() + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/ConfigHelpers.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/ConfigHelpers.kt new file mode 100644 index 0000000..e9d8827 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/ConfigHelpers.kt @@ -0,0 +1,25 @@ +/* + * Copyright @ 2020 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import io.kotest.core.spec.style.ShouldSpec +import org.jitsi.config.withNewConfig + +fun ShouldSpec.context(config: String, name: String, block: () -> Unit) = context(name) { + withNewConfig(config) { + block() + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/JsonAssertions.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/JsonAssertions.kt new file mode 100644 index 0000000..7a8c563 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/JsonAssertions.kt @@ -0,0 +1,27 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import com.fasterxml.jackson.databind.JsonNode +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings + +@SuppressFBWarnings( + value = ["RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"], + justification = "The point is to exercise serialization; a thrown exception is the failure signal." +) +fun JsonNode.shouldBeValidJson() { + this.toString() +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/PendingCountTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/PendingCountTest.kt new file mode 100644 index 0000000..505f77e --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/PendingCountTest.kt @@ -0,0 +1,100 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.utils.time.FakeClock +import java.time.Duration + +class PendingCountTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + init { + context("Pending count") { + val clock = FakeClock() + val pendingCount = PendingCount(Duration.ofSeconds(15), clock) + + should("Correctly count events that are pending") { + pendingCount.getCount() shouldBe 0 + pendingCount.eventPending() + pendingCount.getCount() shouldBe 1 + pendingCount.eventPending() + pendingCount.getCount() shouldBe 2 + } + + should("Correctly count down when events occur") { + pendingCount.eventPending() + pendingCount.eventPending() + pendingCount.getCount() shouldBe 2 + pendingCount.eventOccurred() + pendingCount.getCount() shouldBe 1 + } + + should("Expire pending events after the expiration time") { + pendingCount.eventPending() + clock.elapse(Duration.ofSeconds(10)) + + pendingCount.eventPending() + pendingCount.getCount() shouldBe 2 + + clock.elapse(Duration.ofSeconds(10)) + pendingCount.getCount() shouldBe 1 + + pendingCount.eventOccurred() + pendingCount.getCount() shouldBe 0 + } + + should("Correctly handle count when occurred events expire") { + pendingCount.eventPending() + clock.elapse(Duration.ofSeconds(10)) + + pendingCount.eventPending() + pendingCount.eventOccurred() + pendingCount.getCount() shouldBe 1 + + clock.elapse(Duration.ofSeconds(10)) + pendingCount.getCount() shouldBe 1 + + clock.elapse(Duration.ofSeconds(10)) + pendingCount.getCount() shouldBe 0 + } + + should("Not let the count go negative even if more events occur than are pending") { + pendingCount.eventPending() + pendingCount.eventOccurred() + pendingCount.getCount() shouldBe 0 + + pendingCount.eventOccurred() + pendingCount.getCount() shouldBe 0 + + clock.elapse(Duration.ofSeconds(10)) + + pendingCount.eventPending() + pendingCount.getCount() shouldBe 1 + + clock.elapse(Duration.ofSeconds(10)) + pendingCount.getCount() shouldBe 1 + + clock.elapse(Duration.ofSeconds(10)) + pendingCount.getCount() shouldBe 0 + } + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/WeakValueMapTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/WeakValueMapTest.kt new file mode 100644 index 0000000..a2ddc41 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/util/WeakValueMapTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import java.lang.ref.WeakReference + +class WeakValueMapTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + // Note the test values must not have references held elsewhere (e.g. interned strings or boxed small integers). + private class Value(val v: Int) + + private fun addCollectableValue(map: WeakValueMap): WeakReference { + val value = Value(2) + map.put("weak", value) + return WeakReference(value) + } + + init { + val map = WeakValueMap(cleanInterval = 2) + + context("Basic operations") { + val value = Value(1) + map.put("one", value) + + should("get") { + map.get("one")?.v shouldBe 1 + map.get("two") shouldBe null + } + should("containsKey") { + map.containsKey("one") shouldBe true + map.containsKey("two") shouldBe false + } + should("values") { + map.values().map { it.v } shouldBe listOf(1) + } + should("remove") { + map.remove("one")?.v shouldBe 1 + map.containsKey("one") shouldBe false + map.values() shouldBe emptyList() + } + } + context("Weak semantics") { + val strong = Value(1) + map.put("strong", strong) + // The only strong reference to the value is held inside addCollectableValue, so it is collectable once + // the function returns. The returned WeakReference tells us when it has actually been collected. + val canary = addCollectableValue(map) + map.containsKey("weak") shouldBe true + + var collected = false + for (i in 0..500) { + System.gc() + if (canary.get() == null) { + collected = true + break + } + Thread.sleep(10) + } + + should("expire entries once the value is collected") { + collected shouldBe true + map.get("weak") shouldBe null + map.containsKey("weak") shouldBe false + map.values().map { it.v } shouldBe listOf(1) + map.containsKey("strong") shouldBe true + } + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/AbstractIqHandlerTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/AbstractIqHandlerTest.kt new file mode 100644 index 0000000..3003197 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/AbstractIqHandlerTest.kt @@ -0,0 +1,117 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.Nonza +import org.jivesoftware.smack.packet.Stanza +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.jid.parts.Resourcepart +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * This tests the bridge between Smack and our [AbstractIqHandler]. [AbstractXMPPConnection] uses its own executors + * to process stanzas, and since it's impossible to mock them we're forced to just wait. + * + * The intention is to leave this as the only test that requires blindly waiting for other threads, while the rest + * of the tests can assume that the Smack layer works and can pass IQs directly and synchronously to the intended + * IQ handler. + */ +class AbstractIqHandlerTest : ShouldSpec() { + init { + context("Receiving IQs of both types, on both connections") { + val jid1 = JidCreate.from("jid1@example.com") + val jid2 = JidCreate.from("jid2@example.com") + val connection1 = TestXmppConnection().apply { connect() } + val connection2 = TestXmppConnection().apply { connect() } + + val senderJid = JidCreate.from("sender@example.com") + + // Pre-construct the IQs we'll send + val requests = buildList { + listOf(IQ.Type.get, IQ.Type.set).forEach { type -> + listOf(jid1, jid2).forEach { to -> + add( + DummyIq().apply { + this.from = senderJid + this.to = to + this.type = type + } + ) + } + } + } + + // We expect to receive each request from each of the 2 connections + val latch = CountDownLatch(2 * requests.size) + val handler = DummyIqHandler(setOf(connection1, connection2), latch) + requests.forEach { + connection1.mockProcessStanza(it) + connection2.mockProcessStanza(it) + } + + should("Receive all requests") { + // This is a very simple test and should complete quickly unless something is completely broken. Use + // a very long timeout to prevent false-positives coming from e.g. the test machines not being scheduled + // enough CPU cycles. + latch.await(1, TimeUnit.MINUTES) shouldBe true + } + + handler.shutdown() + } + } + + private class DummyIq : IQ(ELEMENT, NAMESPACE) { + override fun getIQChildElementBuilder(p0: IQChildElementXmlStringBuilder): IQChildElementXmlStringBuilder = + mockk() + companion object { + const val ELEMENT = "dummy-name" + const val NAMESPACE = "dummy-namespace" + } + } + + private class DummyIqHandler( + connections: Set, + private val latch: CountDownLatch + ) : AbstractIqHandler(connections, DummyIq.ELEMENT, DummyIq.NAMESPACE) { + + override fun handleRequest(request: IqRequest): IqProcessingResult = + IqProcessingResult.AcceptedWithNoResponse().also { latch.countDown() } + } +} + +private class TestXmppConnection : AbstractXMPPConnection(mockk(relaxed = true)) { + override fun isSecureConnection(): Boolean = true + override fun isUsingCompression(): Boolean = false + override fun sendNonza(p0: Nonza?) { } + override fun sendStanzaInternal(p0: Stanza?) { } + override fun connectInternal() { + connected = true + } + override fun loginInternal(p0: String?, p1: String?, p2: Resourcepart?) { } + override fun instantShutdown() { } + override fun shutdown() { } + + /** Expose as public. */ + fun mockProcessStanza(stanza: Stanza) = invokeStanzaCollectorsAndNotifyRecvListeners(stanza) +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/FeaturesTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/FeaturesTest.kt new file mode 100644 index 0000000..609eb53 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/FeaturesTest.kt @@ -0,0 +1,30 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe + +class FeaturesTest : ShouldSpec() { + init { + context("Parsing") { + Features.parseString(Features.AUDIO.value) shouldBe Features.AUDIO + Features.parseString("something else") shouldBe null + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/JsonMessageTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/JsonMessageTest.kt new file mode 100644 index 0000000..6fc9324 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/JsonMessageTest.kt @@ -0,0 +1,326 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.jitsi.jicofo.MediaType + +class JsonMessageTest : ShouldSpec() { + init { + context("RoomMetadata") { + context("Valid") { + context("With visitors.live set") { + val parsed = JsonMessage.parse( + """ + { + "type": "room_metadata", + "metadata": { + "visitors": { + "live": true, + "anotherField": 123 + } + }, + "anotherField": {} + } + } + """.trimIndent() + ) + parsed.shouldBeInstanceOf() + parsed.metadata!!.visitors!!.live shouldBe true + } + context("With no visitors included") { + + val parsed = JsonMessage.parse( + """ + { + "type": "room_metadata", + "metadata": { + "key": { + "key2": "value2" + }, + "anotherField": {} + } + } + """.trimIndent() + ) + parsed.shouldBeInstanceOf() + parsed.metadata!!.visitors shouldBe null + } + context("With visitors, mainMeetingParticipants, and startMuted") { + JsonMessage.parse( + """ + { + "metadata": { + "transcriberType": "EGHT_WHISPER", + "visitors": { + "live": true + }, + "moderators": [ + "user_id_1", + "user_id_2" + ], + "participants": [ + "user_id_3", + "user_id_4" + ], + "startMuted": { + "audio": true + } + }, + "type": "room_metadata" + } + """.trimIndent() + ).apply { + shouldBeInstanceOf() + metadata.apply { + shouldNotBeNull() + visitors.apply { + shouldNotBeNull() + live shouldBe true + } + moderators shouldBe listOf("user_id_1", "user_id_2") + participants shouldBe listOf("user_id_3", "user_id_4") + startMuted.apply { + shouldNotBeNull() + audio shouldBe true + video shouldBe null + } + } + type shouldBe "room_metadata" + } + } + context("With audioTranslationRequests") { + JsonMessage.parse( + """ + { + "type": "room_metadata", + "metadata": { + "audioTranslationRequests": { + "aaaaaaaa": ["en", "es"], + "bbbbbbbb": ["fr"] + } + } + } + """.trimIndent() + ).apply { + shouldBeInstanceOf() + metadata!!.audioTranslationRequests.apply { + shouldNotBeNull() + this!!["aaaaaaaa"] shouldBe listOf("en", "es") + this["bbbbbbbb"] shouldBe listOf("fr") + } + } + } + context("Without audioTranslationRequests") { + JsonMessage.parse( + """ + { "type": "room_metadata", "metadata": {} } + """.trimIndent() + ).apply { + shouldBeInstanceOf() + metadata!!.audioTranslationRequests shouldBe null + } + } + context("With visitor transcribing languages") { + JsonMessage.parse( + """ + { + "type": "room_metadata", + "metadata": { + "visitors": { + "live": true, + "transcribingLanguages": "de,fr", + "transcribingCount": 3 + } + } + } + """.trimIndent() + ).apply { + shouldBeInstanceOf() + metadata!!.visitors.apply { + shouldNotBeNull() + live shouldBe true + transcribingLanguages shouldBe "de,fr" + transcribingCount shouldBe 3L + } + } + } + context("With no visitor transcribing languages") { + // Prosody sends an empty string once the last visitor gives up its language. + JsonMessage.parse( + """ + { + "type": "room_metadata", + "metadata": { + "visitors": { "transcribingLanguages": "", "transcribingCount": 0 } + } + } + """.trimIndent() + ).apply { + shouldBeInstanceOf() + metadata!!.visitors.apply { + shouldNotBeNull() + live shouldBe null + transcribingLanguages shouldBe "" + transcribingCount shouldBe 0L + } + } + } + context("With a visitors block from an older prosody") { + JsonMessage.parse( + """ + { "type": "room_metadata", "metadata": { "visitors": { "live": true } } } + """.trimIndent() + ).apply { + shouldBeInstanceOf() + metadata!!.visitors.apply { + shouldNotBeNull() + transcribingLanguages shouldBe null + transcribingCount shouldBe null + } + } + } + } + context("Invalid") { + context("Missing type") { + shouldThrow { + JsonMessage.parse( + """ + { "key": 123 } + """.trimIndent() + ) + } + } + context("Invalid JSON") { + shouldThrow { + JsonMessage.parse("{") + } + } + } + } + + context("AvModerationMessage") { + context("Valid") { + context("With minimal fields") { + val parsed = JsonMessage.parse( + """ + { + "type": "av_moderation", + "room": "room1@conference.example.com" + } + """.trimIndent() + ) + parsed.shouldBeInstanceOf() + parsed.room shouldBe "room1@conference.example.com" + parsed.enabled shouldBe null + parsed.mediaType shouldBe null + parsed.actor shouldBe null + parsed.whitelists shouldBe null + } + + context("With all fields") { + val parsed = JsonMessage.parse( + """ + { + "type": "av_moderation", + "room": "room1@conference.example.com", + "enabled": true, + "mediaType": "AUDIO", + "actor": "user1@example.com", + "whitelists": { + "AUDIO": ["user2@example.com", "user3@example.com"], + "VIDEO": ["user2@example.com"] + } + } + """.trimIndent() + ) + parsed.shouldBeInstanceOf() + parsed.room shouldBe "room1@conference.example.com" + parsed.enabled shouldBe true + parsed.mediaType shouldBe MediaType.AUDIO + parsed.actor shouldBe "user1@example.com" + parsed.whitelists.shouldNotBeNull() + parsed.whitelists!![MediaType.AUDIO] shouldBe listOf("user2@example.com", "user3@example.com") + parsed.whitelists!![MediaType.VIDEO] shouldBe listOf("user2@example.com") + // parsed.whitelists!![MediaType.DESKTOP] shouldBe null + } + + context("With lowercase enum values") { + val parsed = JsonMessage.parse( + """ + { + "type": "av_moderation", + "room": "room1@conference.example.com", + "mediaType": "video", + "whitelists": { + "audio": ["user1@example.com"] + } + } + """.trimIndent() + ) + parsed.shouldBeInstanceOf() + parsed.mediaType shouldBe MediaType.VIDEO + parsed.whitelists.shouldNotBeNull() + parsed.whitelists!![MediaType.AUDIO] shouldBe listOf("user1@example.com") + // parsed.whitelists!![AvModerationMessage.MediaType.DESKTOP] shouldBe listOf("user2@example.com") + } + + context("With mixed case enum values") { + val parsed = JsonMessage.parse( + """ + { + "type": "av_moderation", + "room": "room1@conference.example.com", + "mediaType": "vIdEo", + "whitelists": { + "AuDiO": ["user1@example.com"], + "ViDeO": ["user2@example.com"] + } + } + """.trimIndent() + ) + parsed.shouldBeInstanceOf() + parsed.mediaType shouldBe MediaType.VIDEO + parsed.whitelists.shouldNotBeNull() + parsed.whitelists!![MediaType.AUDIO] shouldBe listOf("user1@example.com") + parsed.whitelists!![MediaType.VIDEO] shouldBe listOf("user2@example.com") + } + } + + context("Invalid") { + context("Invalid media type") { + shouldThrow { + JsonMessage.parse( + """ + { + "type": "av_moderation", + "room": "room1@conference.example.com", + "mediaType": "INVALID_TYPE" + } + """.trimIndent() + ) + } + } + } + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/XmppConfigTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/XmppConfigTest.kt new file mode 100644 index 0000000..58034d4 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/XmppConfigTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright @ 2020 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.maps.shouldBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.jitsi.config.withLegacyConfig +import org.jitsi.config.withNewConfig +import org.jitsi.metaconfig.ConfigException +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.jid.parts.Resourcepart + +class XmppConfigTest : ShouldSpec() { + init { + context("XMPP client connection config") { + context("Default values") { + val config = XmppClientConnectionConfig() + config.enabled shouldBe true + config.hostname shouldBe "localhost" + config.port shouldBe 5222 + shouldThrow { + config.domain + } + config.username shouldBe Resourcepart.from("focus") + config.password shouldBe null + } + context("Legacy config") { + withLegacyConfig( + """ + org.jitsi.jicofo.HOSTNAME=hostname + org.jitsi.jicofo.XMPP_PORT=5223 + org.jitsi.jicofo.FOCUS_USER_DOMAIN=domain + org.jitsi.jicofo.FOCUS_USER_NAME=user + org.jitsi.jicofo.FOCUS_USER_PASSWORD=pass + """ + ) { + val config = XmppClientConnectionConfig() + config.enabled shouldBe true + config.hostname shouldBe "hostname" + config.port shouldBe 5223 + config.domain shouldBe JidCreate.domainBareFrom("domain") + config.username shouldBe Resourcepart.from("user") + config.password shouldBe "pass" + } + } + context("New config") { + withNewConfig( + """ + jicofo.xmpp.client { + hostname = "hostname2" + port = 5224 + domain = "domain2" + username = "user2" + password = "pass2" + } + """ + ) { + val config = XmppClientConnectionConfig() + config.enabled shouldBe true + config.hostname shouldBe "hostname2" + config.port shouldBe 5224 + config.domain shouldBe JidCreate.domainBareFrom("domain2") + config.username shouldBe Resourcepart.from("user2") + config.password shouldBe "pass2" + } + } + } + context("XMPP service connection config") { + context("Default values") { + val config = XmppServiceConnectionConfig() + config.enabled shouldBe false + config.hostname shouldBe "localhost" + config.port shouldBe 6222 + shouldThrow { + config.domain + } + config.username shouldBe Resourcepart.from("focus") + config.password shouldBe null + } + context("Legacy config") { + withLegacyConfig( + """ + org.jitsi.jicofo.BRIDGE_MUC_XMPP_HOST=hostname + org.jitsi.jicofo.BRIDGE_MUC_XMPP_PORT=6223 + org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER_DOMAIN=domain + org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER=user + org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER_PASS=pass + """ + ) { + val config = XmppServiceConnectionConfig() + config.enabled shouldBe true + config.hostname shouldBe "hostname" + config.port shouldBe 6223 + config.domain shouldBe JidCreate.domainBareFrom("domain") + config.username shouldBe Resourcepart.from("user") + config.password shouldBe "pass" + } + } + context("New config") { + withNewConfig( + """ + jicofo.xmpp.service { + enabled = true + hostname = "hostname2" + port = 6224 + domain = "domain2" + username = "user2" + password = "pass2" + } + """ + ) { + val config = XmppServiceConnectionConfig() + config.enabled shouldBe true + config.hostname shouldBe "hostname2" + config.port shouldBe 6224 + config.domain shouldBe JidCreate.domainBareFrom("domain2") + config.username shouldBe Resourcepart.from("user2") + config.password shouldBe "pass2" + } + } + } + context("Visitor connections config") { + context("Default") { + XmppConfig.visitors.shouldBeEmpty() + } + context("With visitors configured") { + withNewConfig( + """ + jicofo.xmpp.visitors { + v1 { + hostname = "hostname1" + port = 5223 + domain = "domain1" + username = "user1" + password = "pass1" + conference-service = conference.v1.example.com + } + v2 { + hostname = "hostname2" + port = 5224 + domain = "domain2" + username = "user2" + password = "pass2" + conference-service = conference.v2.example.com + } + } + """ + ) { + XmppConfig.visitors.size shouldBe 2 + XmppConfig.visitors.keys shouldBe setOf("v1", "v2") + XmppConfig.visitors["v1"].let { + it shouldNotBe null + it!!.hostname shouldBe "hostname1" + it.port shouldBe 5223 + it.domain shouldBe "domain1" + it.username shouldBe Resourcepart.from("user1") + it.password shouldBe "pass1" + it.conferenceService shouldBe JidCreate.domainBareFrom("conference.v1.example.com") + } + XmppConfig.visitors["v2"].let { + it shouldNotBe null + it!!.hostname shouldBe "hostname2" + it.port shouldBe 5224 + it.domain shouldBe "domain2" + it.username shouldBe Resourcepart.from("user2") + it.password shouldBe "pass2" + it.conferenceService shouldBe JidCreate.domainBareFrom("conference.v2.example.com") + } + } + } + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomImplTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomImplTest.kt new file mode 100644 index 0000000..254e291 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomImplTest.kt @@ -0,0 +1,233 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.jitsi.jicofo.MediaType +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.mock.MockXmppConnection +import org.jitsi.jicofo.mock.inPlaceExecutor +import org.jitsi.jicofo.xmpp.RoomMetadata +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jivesoftware.smack.packet.ExtensionElement +import org.jivesoftware.smack.packet.Presence +import org.jivesoftware.smack.packet.StandardExtensionElement +import org.jivesoftware.smack.packet.StanzaBuilder +import org.jivesoftware.smackx.muc.MUCAffiliation +import org.jivesoftware.smackx.muc.MUCRole +import org.jivesoftware.smackx.muc.MultiUserChat +import org.jivesoftware.smackx.muc.MultiUserChatManager +import org.jivesoftware.smackx.muc.Occupant +import org.jivesoftware.smackx.muc.packet.MUCUser +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.impl.JidCreate +import java.util.logging.Level + +class ChatRoomImplTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val roomJid = JidCreate.entityBareFrom("room@conference.example.com") + private val xmppConnection = MockXmppConnection().xmppConnection + private val xmppProvider: XmppProvider = mockk(relaxed = true) { + every { xmppConnection } returns this@ChatRoomImplTest.xmppConnection + } + + /** The role/affiliation of each occupant, as would be reported by the MUC. */ + private val occupantMap = mutableMapOf() + private val muc: MultiUserChat = mockk(relaxed = true) { + every { getOccupant(any()) } answers { occupantMap[firstArg()] } + } + + private var left = false + private val chatRoom by lazy { + mockkStatic(MultiUserChatManager::class) + val manager = mockk { + every { getMultiUserChat(any()) } returns muc + } + every { MultiUserChatManager.getInstanceFor(any()) } returns manager + try { + ChatRoomImpl(xmppProvider, roomJid, Level.INFO) { left = true }.apply { + addListener( + object : ChatRoomListener { + override fun memberJoined(member: ChatRoomMember) { + joined.add(member) + } + override fun memberLeft(member: ChatRoomMember) { + leftMembers.add(member) + } + override fun memberKicked(member: ChatRoomMember) { + kicked.add(member) + } + } + ) + } + } finally { + unmockkStatic(MultiUserChatManager::class) + } + } + + private val joined = mutableListOf() + private val leftMembers = mutableListOf() + private val kicked = mutableListOf() + + private fun addOccupant(jid: EntityFullJid, mucRole: MUCRole, mucAffiliation: MUCAffiliation) { + occupantMap[jid] = mockk { + every { role } returns mucRole + every { affiliation } returns mucAffiliation + every { this@mockk.jid } returns JidCreate.from("user@example.com") + } + } + + private fun memberPresence( + nick: String, + type: Presence.Type = Presence.Type.available, + vararg extensions: ExtensionElement + ): Presence = StanzaBuilder.buildPresence() + .from(JidCreate.entityFullFrom("$roomJid/$nick")) + .ofType(type) + .build().apply { + addExtension(MUCUser()) + extensions.forEach { addExtension(it) } + } + + private fun join(nick: String, role: MUCRole = MUCRole.participant, vararg extensions: ExtensionElement) { + addOccupant(JidCreate.entityFullFrom("$roomJid/$nick"), role, MUCAffiliation.none) + chatRoom.processPresence(memberPresence(nick, Presence.Type.available, *extensions)) + } + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.ioPool = inPlaceExecutor + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetIoPool() + } + + private fun sourceInfo(nick: String, audioMuted: Boolean) = StandardExtensionElement + .builder("SourceInfo", "jabber:client") + .setText("""{"$nick-a0": {"muted": $audioMuted}}""") + .build() + + init { + context("Members joining") { + join("member1") + chatRoom.memberCount shouldBe 1 + joined.size shouldBe 1 + val member = chatRoom.getChatMember(JidCreate.entityFullFrom("$roomJid/member1"))!! + member.name shouldBe "member1" + member.role shouldBe MemberRole.PARTICIPANT + + context("A second member") { + join("member2") + chatRoom.memberCount shouldBe 2 + joined.size shouldBe 2 + } + context("Repeated presence from the same member") { + chatRoom.processPresence(memberPresence("member1")) + chatRoom.memberCount shouldBe 1 + joined.size shouldBe 1 + } + } + context("Members leaving") { + join("member1") + join("member2") + + context("Normally") { + chatRoom.processPresence(memberPresence("member1", Presence.Type.unavailable)) + chatRoom.memberCount shouldBe 1 + leftMembers.map { it.name } shouldBe listOf("member1") + kicked.size shouldBe 0 + } + context("Kicked") { + chatRoom.processPresence( + memberPresence("member1", Presence.Type.unavailable).apply { + getExtension(MUCUser::class.java).addStatusCode(MUCUser.Status.KICKED_307) + } + ) + chatRoom.memberCount shouldBe 1 + kicked.map { it.name } shouldBe listOf("member1") + leftMembers.size shouldBe 0 + } + } + context("Visitors") { + join("visitor1", role = MUCRole.visitor) + join("member1") + + chatRoom.memberCount shouldBe 2 + chatRoom.visitorCount shouldBe 1 + chatRoom.getChatMember(JidCreate.entityFullFrom("$roomJid/visitor1"))!!.role shouldBe MemberRole.VISITOR + + chatRoom.processPresence(memberPresence("visitor1", Presence.Type.unavailable)) + chatRoom.visitorCount shouldBe 0 + } + context("Sender counts") { + chatRoom.audioSendersCount shouldBe 0 + join("member1", MUCRole.participant, sourceInfo("member1", audioMuted = false)) + chatRoom.audioSendersCount shouldBe 1 + + context("Muting") { + chatRoom.processPresence(memberPresence("member1", extensions = arrayOf(sourceInfo("member1", true)))) + chatRoom.audioSendersCount shouldBe 0 + } + context("Leaving while unmuted") { + chatRoom.processPresence(memberPresence("member1", Presence.Type.unavailable)) + chatRoom.audioSendersCount shouldBe 0 + } + } + context("AV moderation") { + val jid = JidCreate.from("someone@example.com") + chatRoom.isAvModerationEnabled(MediaType.AUDIO) shouldBe false + chatRoom.isMemberAllowedToUnmute(jid, MediaType.AUDIO) shouldBe true + + chatRoom.setAvModerationEnabled(MediaType.AUDIO, true) + chatRoom.isAvModerationEnabled(MediaType.AUDIO) shouldBe true + chatRoom.isAvModerationEnabled(MediaType.VIDEO) shouldBe false + chatRoom.isMemberAllowedToUnmute(jid, MediaType.AUDIO) shouldBe false + + chatRoom.setAvModerationWhitelist(MediaType.AUDIO, listOf(jid.toString())) + chatRoom.isMemberAllowedToUnmute(jid, MediaType.AUDIO) shouldBe true + chatRoom.isMemberAllowedToUnmute(JidCreate.from("other@example.com"), MediaType.AUDIO) shouldBe false + } + context("Leaving the room") { + chatRoom.leave() + left shouldBe true + } + context("Room metadata") { + chatRoom.setRoomMetadata( + RoomMetadata( + metadata = RoomMetadata.Metadata( + visitors = RoomMetadata.Metadata.Visitors(live = true), + startMuted = null, + moderators = null, + participants = null, + recording = null, + participantsSoftLimit = 30 + ) + ) + ) + chatRoom.visitorsLive shouldBe true + chatRoom.participantsSoftLimit shouldBe 30 + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberImplTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberImplTest.kt new file mode 100644 index 0000000..1a7002d --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberImplTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.jitsi.config.withNewConfig +import org.jitsi.utils.logging2.LoggerImpl +import org.jitsi.xmpp.extensions.jitsimeet.AudioMutedExtension +import org.jitsi.xmpp.extensions.jitsimeet.FeatureExtension +import org.jitsi.xmpp.extensions.jitsimeet.FeaturesExtension +import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantCodecList +import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantRegionPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.StatsId +import org.jitsi.xmpp.extensions.jitsimeet.VideoMutedExtension +import org.jivesoftware.smack.packet.ExtensionElement +import org.jivesoftware.smack.packet.Presence +import org.jivesoftware.smack.packet.StandardExtensionElement +import org.jivesoftware.smack.packet.StanzaBuilder +import org.jivesoftware.smackx.muc.MUCAffiliation +import org.jivesoftware.smackx.muc.MUCRole +import org.jivesoftware.smackx.muc.Occupant +import org.jxmpp.jid.impl.JidCreate + +class ChatRoomMemberImplTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val occupantJid = JidCreate.entityFullFrom("room@conference.example.com/nick") + + private var occupant: Occupant? = mockk { + every { role } returns MUCRole.participant + every { affiliation } returns MUCAffiliation.none + } + + private val chatRoom: ChatRoomImpl = mockk(relaxed = true) { + every { getOccupant(any()) } answers { occupant } + every { getJid(any()) } returns JidCreate.from("user@example.com") + } + + private val member = ChatRoomMemberImpl(occupantJid, chatRoom, LoggerImpl("test")) + + private fun presence(vararg extensions: ExtensionElement): Presence = + StanzaBuilder.buildPresence().from(occupantJid).build().apply { + extensions.forEach { addExtension(it) } + } + + private fun sourceInfo(json: String) = + StandardExtensionElement.builder("SourceInfo", "jabber:client").setText(json).build() + + init { + context("Presence from another occupant") { + shouldThrow { + member.processPresence( + StanzaBuilder.buildPresence().from(JidCreate.from("room@conference.example.com/other")).build() + ) + } + } + context("Defaults") { + member.processPresence(presence()) + member.role shouldBe MemberRole.PARTICIPANT + member.isAudioMuted shouldBe true + member.isVideoMuted shouldBe true + member.isJigasi shouldBe false + member.isJibri shouldBe false + member.sourceInfos shouldBe emptySet() + member.region shouldBe null + member.statsId shouldBe null + } + context("Mute state") { + context("Via SourceInfo") { + member.processPresence( + presence(sourceInfo("""{"nick-a0": {"muted": false}, "nick-v0": {"muted": false}}""")) + ) + member.isAudioMuted shouldBe false + member.isVideoMuted shouldBe false + member.sourceInfos.size shouldBe 2 + + member.processPresence(presence(sourceInfo("""{"nick-a0": {"muted": true}}"""))) + member.isAudioMuted shouldBe true + member.isVideoMuted shouldBe true + } + context("Via the legacy extensions") { + member.processPresence( + presence( + AudioMutedExtension().apply { setAudioMuted(false) }, + VideoMutedExtension().apply { setVideoMuted(false) } + ) + ) + member.isAudioMuted shouldBe false + member.isVideoMuted shouldBe false + } + context("With invalid SourceInfo JSON") { + member.processPresence(presence(sourceInfo("invalid json"))) + member.isAudioMuted shouldBe true + member.sourceInfos shouldBe emptySet() + } + } + context("Region and stats ID") { + member.processPresence( + presence( + JitsiParticipantRegionPacketExtension().apply { regionId = "us-east" }, + StatsId("stats-id-123") + ) + ) + member.region shouldBe "us-east" + member.statsId shouldBe "stats-id-123" + } + context("Video codecs") { + should("use the signaled codec list") { + member.processPresence( + presence(JitsiParticipantCodecList().apply { codecs = listOf("av1", "vp9", "vp8") }) + ) + member.videoCodecs shouldBe listOf("av1", "vp9", "vp8") + } + should("add vp8 when missing from the codec list") { + member.processPresence(presence(JitsiParticipantCodecList().apply { codecs = listOf("h264") })) + member.videoCodecs shouldBe listOf("h264", "vp8") + } + should("support the legacy codecType extension") { + member.processPresence( + presence( + StandardExtensionElement.builder("jitsi_participant_codecType", "jabber:client") + .setText("h264").build() + ) + ) + member.videoCodecs shouldBe listOf("h264", "vp8") + } + } + context("Jigasi and jibri detection") { + val jigasiFeature = FeaturesExtension().apply { + addChildExtension( + FeatureExtension().apply { setAttribute("var", "http://jitsi.org/protocol/jigasi") } + ) + } + context("From a trusted domain") { + withNewConfig("jicofo.xmpp.trusted-domains = [ \"example.com\" ]") { + member.processPresence(presence(jigasiFeature)) + member.isJigasi shouldBe true + member.isJibri shouldBe false + + // A presence without the extension resets the flag. + member.processPresence(presence()) + member.isJigasi shouldBe false + } + } + context("From an untrusted domain") { + member.processPresence(presence(jigasiFeature)) + member.isJigasi shouldBe false + } + } + context("Role changes") { + member.role shouldBe MemberRole.PARTICIPANT + member.processPresence(presence()) + + context("To another non-visitor role") { + occupant = mockk { + every { role } returns MUCRole.moderator + every { affiliation } returns MUCAffiliation.owner + } + member.processPresence(presence()) + member.role shouldBe MemberRole.OWNER + } + context("To visitor (not supported)") { + occupant = null + member.processPresence(presence()) + member.role shouldBe MemberRole.PARTICIPANT + } + } + } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/MemberRoleTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/MemberRoleTest.kt new file mode 100644 index 0000000..b684ad6 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/MemberRoleTest.kt @@ -0,0 +1,77 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import io.kotest.assertions.withClue +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import org.jitsi.jicofo.xmpp.muc.MemberRole.MODERATOR +import org.jitsi.jicofo.xmpp.muc.MemberRole.OWNER +import org.jitsi.jicofo.xmpp.muc.MemberRole.PARTICIPANT +import org.jitsi.jicofo.xmpp.muc.MemberRole.VISITOR +import org.jivesoftware.smackx.muc.MUCAffiliation +import org.jivesoftware.smackx.muc.MUCRole + +class MemberRoleTest : ShouldSpec() { + init { + context("Order") { + (VISITOR > PARTICIPANT) shouldBe true + (PARTICIPANT > MODERATOR) shouldBe true + + VISITOR.compareTo(PARTICIPANT) shouldBeGreaterThan 0 + PARTICIPANT.compareTo(MODERATOR) shouldBeGreaterThan 0 + } + context("hasModeratorRights") { + VISITOR.hasModeratorRights().shouldBeFalse() + PARTICIPANT.hasModeratorRights().shouldBeFalse() + MODERATOR.hasModeratorRights().shouldBeTrue() + OWNER.hasModeratorRights().shouldBeTrue() + } + context("hasOwnerRights") { + VISITOR.hasOwnerRights().shouldBeFalse() + PARTICIPANT.hasOwnerRights().shouldBeFalse() + MODERATOR.hasOwnerRights().shouldBeFalse() + OWNER.hasOwnerRights().shouldBeTrue() + } + context("From Smack role and affiliation") { + enumPairsWithNull().forEach { (mucRole, mucAffiliation) -> + withClue("mucRole=$mucRole, mucAffiliation=$mucAffiliation") { + MemberRole.fromSmack(mucRole, mucAffiliation) shouldBe when (mucAffiliation) { + MUCAffiliation.admin -> MODERATOR + + MUCAffiliation.owner -> OWNER + + else -> when (mucRole) { + MUCRole.moderator -> MODERATOR + MUCRole.participant -> PARTICIPANT + else -> VISITOR + } + } + } + } + } + } + + private inline fun , reified U : Enum> enumPairsWithNull(): List> = + enumValuesAndNull().flatMap { l -> enumValuesAndNull().map { r -> l to r } } + private inline fun > enumValuesAndNull(): List = + mutableListOf(null).apply { addAll(enumValues()) } +} diff --git a/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/SourceInfoTest.kt b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/SourceInfoTest.kt new file mode 100644 index 0000000..723fb65 --- /dev/null +++ b/jicofo-common/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/SourceInfoTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.jicofo.conference.source.VideoType +import org.jitsi.utils.MediaType + +class SourceInfoTest : ShouldSpec() { + init { + context("Parsing invalid strings") { + shouldThrow { + parseSourceInfoJson("") + } + shouldThrow { + parseSourceInfoJson("{") + } + shouldThrow { + parseSourceInfoJson( + """ + { "a" : { "videoType": 5 } } + """.trimIndent() + ) + } + shouldThrow { + parseSourceInfoJson( + """ + { "a" : { "videoType": "x" } } + """.trimIndent() + ) + } + } + context("Parsing valid strings") { + parseSourceInfoJson("{}") shouldBe emptySet() + + parseSourceInfoJson( + """ + { + "3b554cf4-a0": { + "muted": false + }, + "3b554cf4-v0": { + }, + "3b554cf4-v1": { + "muted": true, + "videoType": "desktop" + }, + "3b554cf4-v3": { + "mediaType": "audio" + } + } + """.trimIndent() + ).shouldBe( + setOf( + SourceInfo("3b554cf4-a0", false, null, MediaType.AUDIO), + SourceInfo("3b554cf4-v0", true, null, MediaType.VIDEO), + SourceInfo("3b554cf4-v1", true, VideoType.Desktop, MediaType.VIDEO), + // The explicitly signaled "audio" precedes over "video" inferred from "-v3" in the name. + SourceInfo("3b554cf4-v3", true, null, MediaType.AUDIO), + ) + ) + } + context("Parsing media type") { + parseSourceInfoJson("""{ "abc-a0": {} }""".trimIndent()).first().mediaType shouldBe MediaType.AUDIO + parseSourceInfoJson("""{ "abc-v0": {} }""".trimIndent()).first().mediaType shouldBe MediaType.VIDEO + parseSourceInfoJson("""{ "abc-d0": {} }""".trimIndent()).first().mediaType shouldBe null + parseSourceInfoJson("""{ "abc0": {} }""".trimIndent()).first().mediaType shouldBe null + parseSourceInfoJson("""{ "abc0": { "mediaType": "audio" } }""".trimIndent()).first().mediaType shouldBe + MediaType.AUDIO + parseSourceInfoJson("""{ "abc0": { "mediaType": "VIDEO" } }""".trimIndent()).first().mediaType shouldBe + MediaType.VIDEO + } + } +} diff --git a/jicofo-selector/pom.xml b/jicofo-selector/pom.xml new file mode 100644 index 0000000..c0b0946 --- /dev/null +++ b/jicofo-selector/pom.xml @@ -0,0 +1,415 @@ + + + 4.0.0 + + org.jitsi + jicofo-parent + 1.1-SNAPSHOT + + jicofo-selector + 1.1-SNAPSHOT + jar + jicofo-selector + JItsi COnference FOcus is a server side focus component used in Jitsi Meet conferences. + + + ${project.groupId} + jicofo-common + ${project.version} + + + org.igniterealtime.smack + smack-tcp + + + + org.jetbrains + annotations + + + org.jetbrains.kotlin + kotlin-stdlib + compile + + + + ${project.groupId} + jicoco-config + + + ${project.groupId} + jicoco-metrics + + + ${project.groupId} + jitsi-metaconfig + + + ${project.groupId} + jitsi-utils + + + ${project.groupId} + jitsi-xmpp-extensions + + + org.slf4j + slf4j-jdk14 + + + org.slf4j + slf4j-api + + + + com.github.spotbugs + spotbugs-annotations + + + + io.sentry + sentry + runtime + + + + org.xmlunit + xmlunit-core + test + + + org.mockito + mockito-core + test + + + io.kotest + kotest-runner-junit5-jvm + test + + + io.kotest + kotest-assertions-core-jvm + test + + + io.kotest + kotest-property-jvm + test + + + org.junit.platform + junit-platform-launcher + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter + test + + + ${project.groupId} + jicoco-test-kotlin + test + + + ${project.groupId} + jicofo-common + ${project.version} + test-jar + test + + + io.mockk + mockk-jvm + test + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + + + test-compile + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + + 17 + + -Xallow-result-return-type + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + + default-compile + none + + + + default-testCompile + none + + + java-compile + compile + + compile + + + + java-test-compile + test-compile + + testCompile + + + + + 17 + 17 + + -Xlint:all,-serial + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + ${project.parent.basedir}/lib/logging.properties + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + + true + + + + + + + test-jar + + test-jar + + + + **/*Test.class + **/*Test$*.class + **/JicofoSelectorKotestProjectConfig.class + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.2 + + checkstyle.xml + + + + com.puppycrawl.tools + checkstyle + 9.1 + + + + + + check + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + spotbugs-exclude.xml + true + + + + verify + + check + + + + + + com.github.spotbugs + spotbugs + ${spotbugs.version} + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + + com.pinterest.ktlint + ktlint-cli + ${ktlint.version} + all + + + + + ktlint-check + verify + + exec + + + java + true + + -classpath + + com.pinterest.ktlint:ktlint-cli + + com.pinterest.ktlint.Main + --relative + src/main/kotlin/**/*.kt + src/test/kotlin/**/*.kt + + + + + ktlint-format + + exec + + + java + true + + -classpath + + com.pinterest.ktlint:ktlint-cli + + com.pinterest.ktlint.Main + --format + --relative + src/main/kotlin/**/*.kt + src/test/kotlin/**/*.kt + + + + + + + + + + jitsi-maven-repository-releases + default + Jitsi Maven Repository (Releases) + + true + + + false + + https://github.com/jitsi/jitsi-maven-repository/raw/master/releases/ + + + jitsi-maven-repository-snapshots + default + Jitsi Maven Repository (Snapshots) + + false + + + true + + https://github.com/jitsi/jitsi-maven-repository/raw/master/snapshots/ + + + + + coverage + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + + report + test + + report + + + + + + + + + diff --git a/jicofo-selector/spotbugs-exclude.xml b/jicofo-selector/spotbugs-exclude.xml new file mode 100644 index 0000000..075eab2 --- /dev/null +++ b/jicofo-selector/spotbugs-exclude.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jicofo-selector/src/main/java/org/jitsi/jicofo/bridge/JvbDoctor.java b/jicofo-selector/src/main/java/org/jitsi/jicofo/bridge/JvbDoctor.java new file mode 100644 index 0000000..ad5c617 --- /dev/null +++ b/jicofo-selector/src/main/java/org/jitsi/jicofo/bridge/JvbDoctor.java @@ -0,0 +1,398 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge; + +import org.jetbrains.annotations.*; +import org.jitsi.jicofo.*; +import org.jitsi.jicofo.xmpp.*; +import org.jitsi.utils.logging2.*; +import org.jitsi.xmpp.extensions.health.*; + +import org.jivesoftware.smack.*; +import org.jivesoftware.smack.packet.*; + +import java.util.*; +import java.util.concurrent.*; + +import static org.jitsi.jicofo.bridge.BridgeConfig.config; +import static org.jitsi.jicofo.xmpp.UtilKt.sendIqAndGetResponse; + +/** + * The class is responsible for doing health checks of currently known + * jitsi-videobridge instances. + * + * @author Pawel Domas + */ +public class JvbDoctor + implements BridgeSelector.EventHandler +{ + /** + * The logger. + */ + private static final Logger logger = new LoggerImpl(JvbDoctor.class.getName()); + + /** + * Tells how often we send health checks to the bridge in ms. + */ + private final long healthCheckInterval = config.getHealthChecksInterval().toMillis(); + + /** + * 2nd chance delay which tells how long we will wait to retry the health + * check after 1st attempt has timed out. + */ + private final long secondChanceDelay = config.getHealthChecksRetryDelay().toMillis(); + + /** + * Health check tasks map. + */ + private final Map tasks = new ConcurrentHashMap<>(); + + private final HealthCheckListener listener; + + @NotNull + private final XmppProvider xmppProvider; + + /** + * Creates new instance of JvbDoctor. + */ + public JvbDoctor(HealthCheckListener listener, @NotNull XmppProvider xmppProvider) + { + this.xmppProvider = xmppProvider; + this.listener = listener; + } + + private AbstractXMPPConnection getConnection() + { + return xmppProvider.getXmppConnection(); + } + + synchronized public void shutdown() + { + // Remove scheduled tasks + ArrayList bridges = new ArrayList<>(tasks.keySet()); + for (Bridge bridge : bridges) + { + bridgeRemoved(bridge); + } + } + + @Override + public void bridgeFailedHealthCheck(@NotNull Bridge bridge) + { + // JvbDoctor is the source of these events, no need for additional handling. + } + + @Override + public void bridgeRemoved(@NotNull Bridge bridge) + { + PeriodicHealthCheckTask healthTask = tasks.remove(bridge); + if (healthTask == null) + { + logger.warn("Trying to remove a bridge that does not exist anymore: " + bridge); + return; + } + + logger.info("Stopping health-check task for: " + bridge); + + healthTask.cancel(); + } + + @Override + public void bridgeAdded(@NotNull Bridge bridge) + { + if (tasks.containsKey(bridge)) + { + logger.warn("Trying to add already existing bridge: " + bridge); + return; + } + + Runnable task = config.getUsePresenceForHealth() + ? new HealthCheckPresenceTask(bridge) + : new HealthCheckTask(bridge); + + PeriodicHealthCheckTask periodicTask + = new PeriodicHealthCheckTask(task, healthCheckInterval); + + tasks.put(bridge, periodicTask); + + logger.info("Scheduled health-check task for: " + bridge); + } + + @Override + public void bridgeIsShuttingDown(@NotNull Bridge bridge) + { + } + + private static class PeriodicHealthCheckTask + { + private Runnable innerTask; + + private final ScheduledFuture future; + private Future innerFuture; + private final Object lock = new Object(); + + private PeriodicHealthCheckTask(Runnable task, long healthCheckInterval) + { + innerTask = task; + future = TaskPools.getScheduledPool().scheduleWithFixedDelay( + () -> innerFuture = TaskPools.getIoPool().submit(runInner), + healthCheckInterval, + healthCheckInterval, + TimeUnit.MILLISECONDS); + } + + private final Runnable runInner = () -> { + synchronized (lock) + { + innerTask.run(); + } + }; + + private void cancel() + { + future.cancel(true); + if (innerFuture != null) + { + innerFuture.cancel(true); + } + } + } + + private class HealthCheckTask extends AbstractHealthCheckTask + { + private HealthCheckTask(Bridge bridge) + { + super(bridge); + } + + private HealthCheckIQ newHealthCheckIQ(Bridge bridge) + { + HealthCheckIQ healthIq = new HealthCheckIQ(); + healthIq.setTo(bridge.getJid()); + healthIq.setType(IQ.Type.get); + return healthIq; + } + + /** + * Performs a health check. + * @throws org.jivesoftware.smack.SmackException.NotConnectedException when XMPP is not connected, + * the task should terminate. + */ + @Override + protected void doHealthCheck() + throws SmackException.NotConnectedException + { + AbstractXMPPConnection connection = getConnection(); + // If XMPP is currently not connected skip the health-check + if (!connection.isConnected()) + { + logger.warn("XMPP disconnected - skipping health check for: " + bridge); + return; + } + + if (taskInvalid()) + { + return; + } + + logger.debug("Sending health-check request to: " + bridge); + + IQ response = sendIqAndGetResponse(connection, newHealthCheckIQ(bridge)); + + // On timeout we'll give it one more try + if (response == null && secondChanceDelay > 0) + { + try + { + if (taskInvalid()) + return; + + logger.warn(bridge + " health-check timed out," + + " but will give it another try after: " + + secondChanceDelay); + + Thread.sleep(secondChanceDelay); + + if (taskInvalid()) + return; + + response = sendIqAndGetResponse(connection, newHealthCheckIQ(bridge)); + } + catch (InterruptedException e) + { + logger.error(bridge + " second chance delay wait interrupted", e); + } + } + + // Sync on start/stop and bridges state + synchronized (JvbDoctor.this) + { + if (taskInvalid()) + return; + + if (response == null) + { + logger.warn("Health check timed out for: " + bridge); + listener.healthCheckTimedOut(bridge.getJid()); + return; + } + + IQ.Type responseType = response.getType(); + if (IQ.Type.result.equals(responseType)) + { + // OK + if (logger.isDebugEnabled()) + { + logger.debug("Health check passed on: " + bridge); + } + listener.healthCheckPassed(bridge.getJid()); + return; + } + + if (IQ.Type.error.equals(responseType)) + { + StanzaError error = response.getError(); + StanzaError.Condition condition = error.getCondition(); + + if (StanzaError.Condition.internal_server_error.equals(condition) + || StanzaError.Condition.service_unavailable.equals(condition)) + { + // Health check failure + logger.warn("Health check failed for: " + bridge + ": " + error.toXML()); + listener.healthCheckFailed(bridge.getJid()); + } + else + { + logger.error( + "Unexpected error returned by the bridge: " + bridge + ", err: " + response.toXML() + ); + } + } + } + } + } + + private abstract class AbstractHealthCheckTask implements Runnable + { + protected final Bridge bridge; + AbstractHealthCheckTask(Bridge bridge) + { + this.bridge = bridge; + } + protected abstract void doHealthCheck() + throws SmackException.NotConnectedException, InterruptedException; + + @Override + public void run() + { + try + { + doHealthCheck(); + } + catch (Exception e) + { + // If the task was canceled while running it will throw InterruptedException, ignore it. + if (taskInvalid() && e instanceof InterruptedException) + { + logger.debug("The task has been canceled."); + } + else + { + logger.error("Error when doing health-check on: " + bridge, e); + } + } + } + + protected boolean taskInvalid() + { + synchronized (JvbDoctor.this) + { + if (!tasks.containsKey(bridge)) + { + logger.info("Health check task canceled for: " + bridge); + return true; + } + return false; + } + } + + } + + private class HealthCheckPresenceTask extends AbstractHealthCheckTask + { + private HealthCheckPresenceTask(Bridge bridge) + { + super(bridge); + } + + /** + * Performs a health check. + * @throws org.jivesoftware.smack.SmackException.NotConnectedException when XMPP is not connected, + * the task should terminate. + */ + @Override + protected void doHealthCheck() + { + AbstractXMPPConnection connection = getConnection(); + // If XMPP is currently not connected skip the health-check + if (!connection.isConnected()) + { + logger.warn("XMPP disconnected - skipping health check for: " + bridge); + return; + } + + if (taskInvalid()) + { + return; + } + + logger.debug("Checking presence for health for: " + bridge); + + boolean healthy = bridge.isHealthy(); + boolean timeout = bridge.getTimeSinceLastPresence().compareTo(config.getPresenceHealthTimeout()) > 0; + + // Sync on start/stop and bridges state + synchronized (JvbDoctor.this) + { + if (taskInvalid()) + return; + + if (timeout) + { + logger.warn("Health check timed out for: " + bridge); + listener.healthCheckTimedOut(bridge.getJid()); + } + else if (!healthy) + { + logger.warn("JVB reported unhealthy" + bridge); + listener.healthCheckFailed(bridge.getJid()); + } + else // healthy + { + if (logger.isDebugEnabled()) + { + logger.debug("Health check passed on: " + bridge); + } + // TODO: we should be able to do this directly when we receive presence with healthy=true, but + // I don't want to modify the flow right now. + listener.healthCheckPassed(bridge.getJid()); + } + } + } + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/TranscriptionConfig.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/TranscriptionConfig.kt new file mode 100644 index 0000000..08d9ea4 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/TranscriptionConfig.kt @@ -0,0 +1,106 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import com.typesafe.config.ConfigObject +import org.jitsi.config.JitsiConfig +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.optionalconfig +import org.jitsi.utils.TemplatedUrl +import org.jitsi.utils.logging2.createLogger +import java.time.Duration + +class TranscriptionConfig private constructor() { + val logger = createLogger() + + private val urlTemplate: String? by optionalconfig { + "jicofo.transcription.url-template".from(JitsiConfig.newConfig).transformedBy { + if (!it.contains("{{${MEETING_ID_TEMPLATE}}}")) { + logger.warn("Transcriber URL template does not contain $MEETING_ID_TEMPLATE") + } + it + } + } + + private val httpHeadersProp: Map? by optionalconfig { + "jicofo.transcription.http-headers".from(JitsiConfig.newConfig) + .convertFrom { cfg -> + cfg.entries.associate { entry -> + entry.key to entry.value.unwrapped().toString() + } + } + } + + val httpHeaders: Map + get() = httpHeadersProp ?: emptyMap() + + val pingEnabled: Boolean by config { + "jicofo.transcription.ping.enabled".from(JitsiConfig.newConfig) + } + + val pingInterval: Duration by config { + "jicofo.transcription.ping.interval".from(JitsiConfig.newConfig) + } + + val pingTimeout: Duration by config { + "jicofo.transcription.ping.timeout".from(JitsiConfig.newConfig) + } + + fun getUrl(meetingId: String): TemplatedUrl? = urlTemplate?.let { + TemplatedUrl(it, requiredKeys = setOf(REGION_TEMPLATE)).apply { + set(MEETING_ID_TEMPLATE, meetingId) + } + } + + companion object { + @JvmField + val config = TranscriptionConfig() + + const val MEETING_ID_TEMPLATE = "MEETING_ID" + const val REGION_TEMPLATE = "REGION" + + /** + * Process transcription metadata to extract custom headers and URL params. + * Merges custom headers with base headers, with custom headers taking precedence. + * + * @param transcription The transcription metadata from room metadata + * @param baseHeaders The base headers from configuration + * @return A Pair of (merged headers, url params), where either can be null + */ + @JvmStatic + fun processTranscriptionMetadata( + transcription: org.jitsi.jicofo.xmpp.RoomMetadata.Metadata.Transcription?, + baseHeaders: Map + ): Pair?, Map?> { + if (transcription == null) { + return Pair(null, null) + } + + val customHeaders = transcription.httpHeaders + val mergedHeaders = if (customHeaders != null) { + baseHeaders.toMutableMap().apply { + putAll(customHeaders) + } + } else { + null + } + + return Pair(mergedHeaders, transcription.urlParams) + } + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/TranslationConfig.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/TranslationConfig.kt new file mode 100644 index 0000000..9683985 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/TranslationConfig.kt @@ -0,0 +1,127 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import com.typesafe.config.ConfigObject +import org.jitsi.config.JitsiConfig +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.optionalconfig +import org.jitsi.utils.TemplatedUrl +import org.jitsi.utils.logging2.createLogger +import java.time.Duration + +/** Configuration for live audio translation (the websocket endpoint a bridge connects to). */ +class TranslationConfig private constructor() { + val logger = createLogger() + + private val urlTemplate: String? by optionalconfig { + "jicofo.translation.url-template".from(JitsiConfig.newConfig).transformedBy { + if (!it.contains("{{$MEETING_ID_TEMPLATE}}")) { + logger.warn("Translator URL template does not contain $MEETING_ID_TEMPLATE") + } + it + } + } + + private val httpHeadersProp: Map? by optionalconfig { + "jicofo.translation.http-headers".from(JitsiConfig.newConfig) + .convertFrom { cfg -> + cfg.entries.associate { entry -> + entry.key to entry.value.unwrapped().toString() + } + } + } + + /** Static HTTP headers added to the translator connect (e.g. Cloudflare Access service-token credentials). */ + val httpHeaders: Map + get() = httpHeadersProp ?: emptyMap() + + /** Whether live translation is configured (an URL template is set). */ + val enabled: Boolean + get() = urlTemplate != null + + /** How translation connects are placed on bridges. */ + val mode: Mode by config { + "jicofo.translation.mode".from(JitsiConfig.newConfig).convertFrom { + Mode.valueOf(it.uppercase().replace('-', '_')) + } + } + + /** + * The maximum number of target languages handled by a single translation connect. Only applies in [Mode.PER_SOURCE] + * (a sender requesting more is split across multiple connects on its bridge); ignored in [Mode.SINGLE_BRIDGE]. + */ + val maxLanguagesPerConnect: Int by config { + "jicofo.translation.max-languages-per-connect".from(JitsiConfig.newConfig) + } + + /** Whether the bridge should send keepalive pings on the translator connect (to avoid idle-timeout). */ + val pingEnabled: Boolean by config { + "jicofo.translation.ping.enabled".from(JitsiConfig.newConfig) + } + + val pingInterval: Duration by config { + "jicofo.translation.ping.interval".from(JitsiConfig.newConfig) + } + + val pingTimeout: Duration by config { + "jicofo.translation.ping.timeout".from(JitsiConfig.newConfig) + } + + fun getUrl(meetingId: String): TemplatedUrl? = urlTemplate?.let { + TemplatedUrl(it, requiredKeys = setOf(REGION_TEMPLATE)).apply { + set(MEETING_ID_TEMPLATE, meetingId) + } + } + + /** How translation connects are placed on bridges. */ + enum class Mode { + /** Each sender's audio is translated on its own (local) bridge, split by [maxLanguagesPerConnect]. */ + PER_SOURCE, + + /** A single connect on one selected bridge handles all sources and languages. */ + SINGLE_BRIDGE + } + + companion object { + @JvmField + val config = TranslationConfig() + + const val MEETING_ID_TEMPLATE = "MEETING_ID" + const val REGION_TEMPLATE = "REGION" + + /** + * Merge per-room translation headers (from room metadata) over the static config headers, with the + * per-room values taking precedence. Mirrors [TranscriptionConfig.processTranscriptionMetadata]. + * + * Returns null when there are no per-room headers, so callers fall back to the static config headers + * (i.e. `merged ?: TranslationConfig.config.httpHeaders`). + * + * @param translation the translation metadata from room metadata (e.g. a per-customer usage token) + * @param baseHeaders the static headers from configuration + */ + @JvmStatic + fun processTranslationMetadata( + translation: org.jitsi.jicofo.xmpp.RoomMetadata.Metadata.Translation?, + baseHeaders: Map + ): Map? { + val customHeaders = translation?.httpHeaders ?: return null + return baseHeaders.toMutableMap().apply { putAll(customHeaders) } + } + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/Bridge.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/Bridge.kt new file mode 100644 index 0000000..cc9d54a --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/Bridge.kt @@ -0,0 +1,384 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.LoggerImpl +import org.jitsi.utils.stats.RateTracker +import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension +import org.jxmpp.jid.Jid +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.max +import org.jitsi.jicofo.bridge.BridgeConfig.Companion.config as config + +/** + * Represents a jitsi-videobridge instance, reachable at a certain JID, which + * can be used by jicofo for hosting conferences. Contains the state related + * to the jitsi-videobridge instance, such as numbers of channels and streams, + * the region in which the instance resides, etc. + * + * TODO fix comparator (should not be reflexive unless the objects are the same?) + * @author Pawel Domas + * @author Boris Grozev + */ +@SuppressFBWarnings("EQ_COMPARETO_USE_OBJECT_EQUALS") +class Bridge @JvmOverloads internal constructor( + /** + * The XMPP address of the bridge. + */ + val jid: Jid, + private val clock: Clock = Clock.systemUTC() +) : Comparable { + + /** Keep track of the recently added endpoints. */ + private val newEndpointsRate = RateTracker( + config.participantRampupInterval, + Duration.ofMillis(100), + clock + ) + + private val endpointRestartRequestRate = RateTracker( + config.iceFailureDetection.interval, + Duration.ofSeconds(1), + clock + ) + + /** Number of endpoints currently allocated on this bridge by this jicofo instance. */ + val endpoints = AtomicInteger(0) + + /** The last report stress level */ + var lastReportedStressLevel = 0.0 + private set + + /** Holds bridge version (if known - not all bridge version are capable of reporting it). */ + private var version: String? = null + + /** Whether the last received presence indicated the bridge is healthy. */ + var isHealthy = true + private set + + /** Holds bridge release ID, or null if not known. */ + private var releaseId: String? = null + + /** + * Stores the `operational` status of the bridge, which is + * `true` if the bridge has been successfully used by the focus to + * allocate channels. It is reset to `false` when the focus fails + * to allocate channels, but it gets another chance when all currently + * working bridges go down and might eventually get elevated back to + * `true`. + */ + @Volatile + var isOperational = true + get() = + // To filter out intermittent failures, do not return operational + // until past the reset threshold since the last failure. + if (failureInstant != null && + Duration.between(failureInstant, clock.instant()).compareTo(config.failureResetThreshold) < 0 + ) { + false + } else { + field + } + set(isOperational) { + field = isOperational + if (!isOperational) { + // Remember when the bridge has last failed + failureInstant = clock.instant() + } + } + + /** Start out with the configured value, update if the bridge reports a value. */ + private var averageParticipantStress = config.averageParticipantStress + + /** Stores a boolean that indicates whether the bridge is in graceful shutdown mode. */ + var isInGracefulShutdown = false // we assume it is not shutting down + + /** Whether the bridge is in SHUTTING_DOWN mode. */ + var isShuttingDown = false + private set + + /** + * Stores a boolean that indicates whether the bridge is in drain mode. + */ + var isDraining = true // Default to true to prevent unwanted selection before reading actual state + private set + + /** + * The time when this instance has failed. + * + * Use `null` to represent "never" because calculating the duration from [Instant.MIN] is slow. + */ + private var failureInstant: Instant? = null + + /** @return the region of this [Bridge]. */ + var region: String? = null + private set + + /** @return the relay ID advertised by the bridge, or `null` if none was advertised. */ + var relayId: String? = null + private set + + /** + * If this [Bridge] has been removed from the list of bridges. Once removed, the metrics specific to this instance + * are cleared and no longer emitted. If the bridge re-connects, a new [Bridge] instance will be created. + */ + val removed = AtomicBoolean(false) + + /** + * The last instant at which we detected, based on restart requests from endpoints, that this bridge is failing ICE + */ + private var lastIceFailed = Instant.MIN + private val failingIce: Boolean + get() = Duration.between(lastIceFailed, clock.instant()) < config.iceFailureDetection.timeout + + private val logger: Logger = LoggerImpl(Bridge::class.java.name) + + init { + logger.addContext("jid", jid.toString()) + } + + private var lastPresenceReceived = Instant.MIN + + val timeSinceLastPresence: Duration + get() = Duration.between(lastPresenceReceived, clock.instant()) + + /** + * Notifies this instance that a new [ColibriStatsExtension] was + * received for this instance. + * @param stats the [ColibriStatsExtension] instance which was + * received. + */ + fun setStats(stats: ColibriStatsExtension?) { + if (stats == null) { + return + } + lastPresenceReceived = clock.instant() + val stressLevel = stats.getDouble("stress_level") + if (stressLevel != null) { + lastReportedStressLevel = stressLevel + } + val averageParticipantStress = stats.getDouble("average_participant_stress") + if (averageParticipantStress != null) { + this.averageParticipantStress = averageParticipantStress + } + if (java.lang.Boolean.parseBoolean( + stats.getValueAsString( + ColibriStatsExtension.SHUTDOWN_IN_PROGRESS + ) + ) + ) { + isInGracefulShutdown = true + } + if (java.lang.Boolean.parseBoolean(stats.getValueAsString("shutting_down"))) { + isShuttingDown = true + } + val drainStr = stats.getValueAsString(ColibriStatsExtension.DRAIN) + if (drainStr != null) { + isDraining = java.lang.Boolean.parseBoolean(drainStr) + } + val newVersion = stats.getValueAsString(ColibriStatsExtension.VERSION) + if (newVersion != null) { + version = newVersion + } + val newReleaseId = stats.getValueAsString(ColibriStatsExtension.RELEASE) + if (newReleaseId != null) { + releaseId = newReleaseId + } + val region = stats.getValueAsString(ColibriStatsExtension.REGION) + if (region != null) { + this.region = region + } + val relayId = stats.getValueAsString(ColibriStatsExtension.RELAY_ID) + if (relayId != null) { + this.relayId = relayId + } + val healthy = stats.getValueAsString("healthy") + if (healthy != null) { + isHealthy = java.lang.Boolean.parseBoolean(healthy) + } else if (config.usePresenceForHealth) { + logger.warn( + "Presence-based health checks are enabled, but presence did not include health status. Health " + + "checks for this bridge are effectively disabled." + ) + } + } + + /** + * Returns a negative number if this instance is more able to serve conferences than o. For details see + * [.compare]. + * + * @param other the other bridge instance + * + * @return a negative number if this instance is more able to serve conferences than o + */ + override fun compareTo(other: Bridge): Int = compare(this, other) + + /** Notifies this [Bridge] that it was used for a new endpoint. */ + fun endpointAdded() { + newEndpointsRate.update(1) + endpoints.incrementAndGet() + if (!removed.get()) { + BridgeMetrics.endpoints.set(endpoints.get().toLong(), listOf(jid.resourceOrEmpty.toString())) + } + } + + /** Updates the "endpoints moved" metric for this bridge. */ + fun endpointsMoved(count: Long) { + BridgeMetrics.endpointsMoved.add(count, listOf(jid.resourceOrEmpty.toString())) + } + fun endpointRemoved() = endpointsRemoved(1) + fun endpointsRemoved(count: Int) { + endpoints.addAndGet(-count) + if (!removed.get()) { + BridgeMetrics.endpoints.set(endpoints.get().toLong(), listOf(jid.resourceOrEmpty.toString())) + } + if (endpoints.get() < 0) { + logger.error("Removed more endpoints than were allocated. Resetting to 0.", Throwable()) + endpoints.set(0) + } + } + internal fun markRemoved() { + if (removed.compareAndSet(false, true)) { + BridgeMetrics.restartRequestsMetric.remove(listOf(jid.resourceOrEmpty.toString())) + BridgeMetrics.endpoints.remove(listOf(jid.resourceOrEmpty.toString())) + BridgeMetrics.failingIce.remove(listOf(jid.resourceOrEmpty.toString())) + BridgeMetrics.endpointsMoved.remove(listOf(jid.resourceOrEmpty.toString())) + } + } + internal fun updateMetrics() { + if (!removed.get()) { + BridgeMetrics.failingIce.set(failingIce, listOf(jid.resourceOrEmpty.toString())) + } + } + + fun endpointRequestedRestart() { + endpointRestartRequestRate.update(1) + if (!removed.get()) { + BridgeMetrics.restartRequestsMetric.inc(listOf(jid.resourceOrEmpty.toString())) + } + + if (config.iceFailureDetection.enabled) { + val restartCount = endpointRestartRequestRate.getAccumulatedCount() + val endpoints = endpoints.get() + if (endpoints >= config.iceFailureDetection.minEndpoints && + restartCount > endpoints * config.iceFailureDetection.threshold + ) { + // Reset the timeout regardless of the previous state, but only log if the state changed. + if (!failingIce) { + logger.info("Detected an ICE failing state.") + } + lastIceFailed = clock.instant() + } + } + } + + /** Returns the net number of video channels recently allocated or removed from this bridge. */ + private val recentlyAddedEndpointCount: Long + get() = newEndpointsRate.getAccumulatedCount() + + /** The version of this bridge (with embedded release ID, if available). */ + val fullVersion: String? + get() = if (version != null && releaseId != null) "$version-$releaseId" else version + + override fun toString(): String = String.format( + "Bridge[jid=%s, version=%s, relayId=%s, region=%s, correctedStress=%.2f]", + jid.toString(), + fullVersion, + relayId, + region, + correctedStress + ) + + /** + * Gets the "stress" of the bridge, represented as a double between 0 and 1 (though technically the value + * can exceed 1). + * @return this bridge's stress level + */ + val correctedStress: Double + get() { + // Correct for recently added endpoints. + // While a stress of 1 indicates a bridge is fully loaded, we allow larger values to keep sorting correctly. + val s = lastReportedStressLevel + recentlyAddedEndpointCount.coerceAtLeast(0) * averageParticipantStress + + // Correct for failing ICE. + return if (failingIce) max(s, config.stressThreshold + 0.01) else s + } + + /** @return true if the stress of the bridge is greater-than-or-equal to the threshold. */ + val isOverloaded: Boolean + get() = correctedStress >= config.stressThreshold + + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("corrected-stress", correctedStress) + put("drain", isDraining) + put("endpoints", endpoints.get()) + put("endpoint-restart-requests", endpointRestartRequestRate.getAccumulatedCount()) + put("failing-ice", failingIce) + put("graceful-shutdown", isInGracefulShutdown) + put("healthy", isHealthy) + put("operational", isOperational) + put("overloaded", isOverloaded) + put("region", region.toString()) + put("relay-id", relayId.toString()) + put("release", releaseId.toString()) + put("shutting-down", isShuttingDown) + put("stress", lastReportedStressLevel) + put("version", version.toString()) + } + + companion object { + + /** + * Returns a negative number if b1 is more able to serve conferences than b2. The computation is based on the + * following three comparisons + * + * operating bridges < non operating bridges + * not in graceful shutdown mode < bridges in graceful shutdown mode + * lower stress < higher stress + * + * @param b1 the 1st bridge instance + * @param b2 the 2nd bridge instance + * + * @return a negative number if b1 is more able to serve conferences than b2 + */ + fun compare(b1: Bridge, b2: Bridge): Int { + val myPriority = getPriority(b1) + val otherPriority = getPriority(b2) + return if (myPriority != otherPriority) { + myPriority - otherPriority + } else { + b1.correctedStress.compareTo(b2.correctedStress) + } + } + + private fun getPriority(b: Bridge): Int = if (b.isOperational) { + if (b.isInGracefulShutdown) 2 else 1 + } else { + 3 + } + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeConfig.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeConfig.kt new file mode 100644 index 0000000..f0760ac --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeConfig.kt @@ -0,0 +1,231 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import com.typesafe.config.ConfigList +import com.typesafe.config.ConfigObject +import com.typesafe.config.ConfigValue +import org.jitsi.config.JitsiConfig +import org.jitsi.jicofo.xmpp.XmppConnectionEnum +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.optionalconfig +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import java.time.Duration + +/** + * Config for classes in the org.jicofo.bridge package. + */ +class BridgeConfig private constructor() { + val maxBridgeParticipants: Int by config { + "org.jitsi.jicofo.BridgeSelector.MAX_PARTICIPANTS_PER_BRIDGE".from(JitsiConfig.legacyConfig) + "$BASE.max-bridge-participants".from(JitsiConfig.newConfig) + } + + /** + * The maximum number of participants that a single conference may add to a single bridge within + * [maxBridgeParticipantsInterval]. Use -1 to disable. + */ + val maxBridgeParticipantsPerInterval: Int by config { + "$BASE.max-bridge-participants-per-interval".from(JitsiConfig.newConfig) + } + + /** The interval over which [maxBridgeParticipantsPerInterval] is enforced. */ + val maxBridgeParticipantsInterval: Duration by config { + "$BASE.max-bridge-participants-interval".from(JitsiConfig.newConfig) + } + + val averageParticipantStress: Double by config { + "$BASE.average-participant-stress".from(JitsiConfig.newConfig) + } + + val stressThreshold: Double by config { "$BASE.stress-threshold".from(JitsiConfig.newConfig) } + + val includeBridgeNameInSessionId: Boolean by config { + "$BASE.include-bridge-name-in-session-id".from(JitsiConfig.newConfig) + } + + /** + * How long the "failed" state should be sticky for. Once a [Bridge] goes in a non-operational state (via + * [.setIsOperational]) it will be considered non-operational for at least this amount of time. + * See the tests for example behavior. + */ + val failureResetThreshold: Duration by config { + "org.jitsi.focus.BRIDGE_FAILURE_RESET_THRESHOLD".from(JitsiConfig.legacyConfig) + .convertFrom { Duration.ofMillis(it) } + "$BASE.failure-reset-threshold".from(JitsiConfig.newConfig) + } + + val participantRampupInterval: Duration by config { + "$BASE.participant-rampup-interval".from(JitsiConfig.newConfig) + } + + val selectionStrategy: BridgeSelectionStrategy by config { + "org.jitsi.jicofo.BridgeSelector.BRIDGE_SELECTION_STRATEGY".from(JitsiConfig.legacyConfig) + .convertFrom { createSelectionStrategy(it) } + "$BASE.selection-strategy".from(JitsiConfig.newConfig) + .convertFrom { createSelectionStrategy(it) } + } + + val participantSelectionStrategy: BridgeSelectionStrategy? by config { + "$BASE.participant-selection-strategy".from(JitsiConfig.newConfig) + .convertFrom { createSelectionStrategy(it) } + } + + val visitorSelectionStrategy: BridgeSelectionStrategy? by config { + "$BASE.visitor-selection-strategy".from(JitsiConfig.newConfig) + .convertFrom { createSelectionStrategy(it) } + } + + val topologyStrategy: TopologySelectionStrategy by config { + "$BASE.topology-strategy".from(JitsiConfig.newConfig) + .convertFrom { createTopologyStrategy(it) } + } + + private fun createClassInstance(className: String): T = try { + val clazz = Class.forName("${javaClass.getPackage().name}.$className") + clazz.getConstructor().newInstance() as T + } catch (e: Exception) { + val clazz = Class.forName(className) + clazz.getConstructor().newInstance() as T + } + + private fun createSelectionStrategy(className: String): BridgeSelectionStrategy = createClassInstance(className) + + private fun createTopologyStrategy(className: String): TopologySelectionStrategy = createClassInstance(className) + + val healthChecksEnabled: Boolean by config { + "org.jitsi.jicofo.HEALTH_CHECK_INTERVAL".from(JitsiConfig.legacyConfig) + .convertFrom { it > 0 } + "$BASE.health-checks.enabled".from(JitsiConfig.newConfig) + } + + val healthChecksInterval: Duration by config { + "org.jitsi.jicofo.HEALTH_CHECK_INTERVAL".from(JitsiConfig.legacyConfig) + .convertFrom { Duration.ofMillis(it) } + "$BASE.health-checks.interval".from(JitsiConfig.newConfig) + } + + val healthChecksRetryDelay: Duration by config { + "org.jitsi.jicofo.HEALTH_CHECK_2NDTRY_DELAY".from(JitsiConfig.legacyConfig) + .convertFrom { Duration.ofMillis(it) } + "$BASE.health-checks.retry-delay".from(JitsiConfig.newConfig) + "$BASE.health-checks.interval".from(JitsiConfig.newConfig) + .transformedBy { Duration.ofMillis(it.toMillis() / 2) } + } + + val usePresenceForHealth: Boolean by config { + "$BASE.health-checks.use-presence".from(JitsiConfig.newConfig) + } + + val presenceHealthTimeout: Duration by config { + "$BASE.health-checks.presence-timeout".from(JitsiConfig.newConfig) + } + + val breweryJid: EntityBareJid? by optionalconfig { + "org.jitsi.jicofo.BRIDGE_MUC".from(JitsiConfig.legacyConfig).convertFrom { + JidCreate.entityBareFrom(it) + } + "$BASE.brewery-jid".from(JitsiConfig.newConfig).convertFrom { + JidCreate.entityBareFrom(it) + } + } + + val xmppConnectionName: XmppConnectionEnum by config { + "jicofo.bridge.xmpp-connection-name".from(JitsiConfig.newConfig) + } + + val regionGroups: Map> by config { + "jicofo.bridge".from(JitsiConfig.newConfig).convertFrom { + val regionGroupsConfigList = it["region-groups"] as? ConfigList ?: emptyList() + val regionGroups = regionGroupsConfigList.map { regionsConfigList -> + (regionsConfigList as? ConfigList ?: emptyList()).map { region -> + region.unwrapped().toString() + }.toSet() + }.toSet() + mutableMapOf>().apply { + regionGroups.forEach { regionGroup -> + regionGroup.forEach { region -> + this[region] = regionGroup + } + } + } + } + } + + fun getRegionGroup(region: String?): Set = + if (region == null) emptySet() else regionGroups[region] ?: setOf(region) + + val iceFailureDetection = IceFailureDetectionConfig() + val loadRedistribution = LoadRedistributionConfig() + + companion object { + const val BASE = "jicofo.bridge" + + @JvmField + val config = BridgeConfig() + } +} + +class IceFailureDetectionConfig internal constructor() { + val enabled: Boolean by config { + "$BASE.enabled".from(JitsiConfig.newConfig) + } + val interval: Duration by config { + "$BASE.interval".from(JitsiConfig.newConfig) + } + val minEndpoints: Int by config { + "$BASE.min-endpoints".from(JitsiConfig.newConfig) + } + val threshold: Double by config { + "$BASE.threshold".from(JitsiConfig.newConfig) + } + val timeout: Duration by config { + "$BASE.timeout".from(JitsiConfig.newConfig) + } + + companion object { + const val BASE = "jicofo.bridge.ice-failure-detection" + } +} + +class LoadRedistributionConfig internal constructor() { + val enabled: Boolean by config { + "$BASE.enabled".from(JitsiConfig.newConfig) + } + val interval: Duration by config { + "$BASE.interval".from(JitsiConfig.newConfig) + } + val timeout: Duration by config { + "$BASE.timeout".from(JitsiConfig.newConfig) + } + val stressThreshold: Double by config { + "$BASE.stress-threshold".from(JitsiConfig.newConfig) + } + val endpoints: Int by config { + "$BASE.endpoints".from(JitsiConfig.newConfig) + } + + override fun toString(): String = + "LoadRedistributionConfig(enabled=$enabled, interval=$interval, timeout=$timeout, " + + "stressThreshold=$stressThreshold, endpoints=$endpoints)" + + companion object { + const val BASE = "jicofo.bridge.load-redistribution" + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeMetrics.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeMetrics.kt new file mode 100644 index 0000000..fe3c6f8 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeMetrics.kt @@ -0,0 +1,33 @@ +package org.jitsi.jicofo.bridge + +import org.jitsi.jicofo.metrics.JicofoMetricsContainer.Companion.instance as metricsContainer +class BridgeMetrics { + companion object { + /** Total number of participants that requested a restart for a specific bridge. */ + val restartRequestsMetric = metricsContainer.registerCounter( + "bridge_restart_requests_total", + "Total number of requests to restart a bridge", + labelNames = listOf("jvb") + ) + val endpoints = metricsContainer.registerLongGauge( + "bridge_endpoints", + "The number of endpoints on a bridge.", + labelNames = listOf("jvb") + ) + val failingIce = metricsContainer.registerBooleanMetric( + "bridge_failing_ice", + "Whether a bridge is currently in the failing ICE state.", + labelNames = listOf("jvb") + ) + val endpointsMoved = metricsContainer.registerCounter( + "bridge_endpoints_moved", + "Total number of endpoints moved away from a bridge for automatic load redistribution.", + labelNames = listOf("jvb") + ) + val rateLimited = metricsContainer.registerCounter( + "bridge_selection_rate_limited", + "Total number of times a bridge was considered overloaded for a conference because the conference had " + + "reached max-bridge-participants-per-interval on it." + ) + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeMucDetector.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeMucDetector.kt new file mode 100644 index 0000000..6981ea3 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeMucDetector.kt @@ -0,0 +1,54 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2018-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import org.jitsi.jicofo.xmpp.BaseBrewery +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jitsi.utils.logging2.LoggerImpl +import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.Jid + +/** + * Detects jitsi-videobridge instances through a MUC. + * + * @author Boris Grozev + */ +class BridgeMucDetector( + xmppProvider: XmppProvider, + /** + * The [BridgeSelector] instance which will be notified when new jitsi-videobridge instances are detected, or when + * they update their status. + */ + private val bridgeSelector: BridgeSelector, + breweryJid: EntityBareJid +) : BaseBrewery( + xmppProvider, + breweryJid, + ColibriStatsExtension.ELEMENT, + ColibriStatsExtension.NAMESPACE, + LoggerImpl(BridgeMucDetector::class.simpleName).apply { addContext("type", "bridge") } +) { + + override fun onInstanceStatusChanged(jid: EntityFullJid, stats: ColibriStatsExtension) { + bridgeSelector.addJvbAddress(jid, stats) + } + + override fun notifyInstanceOffline(jid: Jid) = bridgeSelector.removeJvbAddress(jid) +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeSelectionStrategy.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeSelectionStrategy.kt new file mode 100644 index 0000000..dadbeb3 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeSelectionStrategy.kt @@ -0,0 +1,364 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createLogger +import org.jitsi.jicofo.bridge.BridgeConfig.Companion.config as config + +/** + * Represents an algorithm for bridge selection. + */ +abstract class BridgeSelectionStrategy { + private val logger: Logger = createLogger() + + /** + * Selects a bridge to be used for a new participant in a conference. + * + * @param bridges the list of bridges to select from. + * @param conferenceBridges the bridges already in use by the conference + * for which for which a bridge is to be selected. + * @param participantRegion the region of the participant for which + * a bridge is to be selected. + * @return the selected bridge, or `null` if no bridge is + * available. + */ + open fun select( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + allowMultiBridge: Boolean + ): Bridge? { + logRateLimitedBridges(conferenceBridges) + return if (conferenceBridges.isEmpty()) { + val bridge = doSelect(bridges, conferenceBridges, participantProperties) + if (bridge != null) { + logger.info( + "Selected initial bridge $bridge with reported stress=${bridge.lastReportedStressLevel} " + + "for participantProperties=$participantProperties" + ) + } else { + logger.warn( + "Failed to select initial bridge for participantProperties=$participantProperties" + ) + } + bridge + } else { + val existingBridge = conferenceBridges.keys.first() + if (!allowMultiBridge || existingBridge.relayId == null) { + logger.info("Existing bridge does not have a relay, will not consider other bridges.") + return existingBridge + } + val bridge = doSelect(bridges, conferenceBridges, participantProperties) + if (bridge != null) { + logger.info( + "Selected bridge $bridge with stress=${bridge.lastReportedStressLevel} " + + "for participantProperties=$participantProperties" + ) + } else { + logger.warn("Failed to select bridge for participantProperties=$participantProperties") + } + bridge + } + } + + /** + * Finds the least loaded bridge in the participant's region that is not + * overloaded and that is already handling the conference. + * + * @param bridges the list of operational bridges, ordered by load. + * @param conferenceBridges the set of bridges that are already used in the conference. + * @param desiredRegion the selection strategy's desired region. Depending on the policy this + * may or may not be the same as the participant's region. + * + * @return a bridge that is not loaded and that + * is already in the conference and that is in the participant region, if one + * exists, or null + */ + fun notLoadedAlreadyInConferenceInRegion( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + desiredRegion: String? + ): Bridge? { + val result = bridges + .filterNot { it.isOverloaded(conferenceBridges) } + .intersect(conferenceBridges.keys) + .firstOrNull { desiredRegion != null && it.region.equals(desiredRegion) } + if (result != null) { + logSelection(result, conferenceBridges, participantProperties, desiredRegion) + } + return result + } + + fun notLoadedAlreadyInConferenceInRegionGroup( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + desiredRegion: String? + ): Bridge? { + val regionGroup = config.getRegionGroup(desiredRegion) + val result = bridges + .filterNot { it.isOverloaded(conferenceBridges) } + .intersect(conferenceBridges.keys) + .firstOrNull { regionGroup.contains(it.region) } + if (result != null) { + logSelection(result, conferenceBridges, participantProperties, desiredRegion) + } + return result + } + + private fun logSelection( + bridge: Bridge, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + desiredRegion: String? = null + ) { + val method = Thread.currentThread().stackTrace[2].methodName + logger.debug { + "Bridge selected: method=$method, desiredRegion=$desiredRegion, " + + "participantProperties=$participantProperties, bridge=$bridge, " + + "conference_bridges=${conferenceBridges.keys.joinToString()}" + } + } + + /** + * Finds the least loaded bridge in the desired region that is not + * overloaded. + * + * @param bridges the list of operational bridges, ordered by load. + * @param desiredRegion the selection strategy's desired region. Depending on the policy this + * may or may not be the same as the participant's region. + * + * @return a bridge that is not loaded and that is in the participant region, if one exists, or null + */ + fun notLoadedInRegion( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + desiredRegion: String? + ): Bridge? { + val result = bridges + .filterNot { it.isOverloaded(conferenceBridges) } + .firstOrNull { desiredRegion != null && it.region.equals(desiredRegion) } + if (result != null) { + logSelection(result, conferenceBridges, participantProperties, desiredRegion) + } + return result + } + + fun notLoadedInRegionGroup( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + desiredRegion: String? + ): Bridge? { + val regionGroup = config.getRegionGroup(desiredRegion) + val result = bridges + .filterNot { it.isOverloaded(conferenceBridges) } + .firstOrNull { regionGroup.contains(it.region) } + if (result != null) { + logSelection(result, conferenceBridges, participantProperties, desiredRegion) + } + return result + } + + fun notLoaded( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties + ): Bridge? { + val result = bridges.firstOrNull { !it.isOverloaded } + if (result != null) { + logSelection(result, conferenceBridges, participantProperties) + } + return result + } + + /** + * Finds the least loaded conference bridge in the desired region that + * is already handling the conference. + * + * @param bridges the list of operational bridges, ordered by load. + * @param conferenceBridges the set of bridges that are already used in the conference. + * @param desiredRegion the selection strategy's desired region. Depending on the policy this + * may or may not be the same as the participant's region. + * + * @return the least loaded bridge that is already + * in the conference and that is in the participant region, if it exists, or null + */ + fun leastLoadedAlreadyInConferenceInRegion( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + desiredRegion: String? + ): Bridge? { + val result = bridges + .intersect(conferenceBridges.keys) + .firstOrNull { desiredRegion != null && it.region.equals(desiredRegion) } + if (result != null) { + logSelection(result, conferenceBridges, participantProperties, desiredRegion) + } + return result + } + + fun leastLoadedNotMaxedAlreadyInConference( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties + ): Bridge? { + val result = bridges + .intersect(conferenceBridges.keys) + .firstOrNull { !it.hasMaxParticipantsInConference(conferenceBridges) } + if (result != null) { + logSelection(result, conferenceBridges, participantProperties) + } + return result + } + + /** + * Finds the least loaded bridge in a desired region. + * + * @param bridges the list of operational bridges, ordered by load. + * @param desiredRegion the selection strategy's desired region. Depending on the policy this + * may or may not be the same as the participant's region. + * + * @return the least loaded bridge in the participant's region, if it exists, or null. + */ + fun leastLoadedInRegion( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + desiredRegion: String? + ): Bridge? { + val result = bridges + .firstOrNull { desiredRegion != null && it.region.equals(desiredRegion) } + if (result != null) { + logSelection(result, conferenceBridges, participantProperties, desiredRegion) + } + return result + } + + /** + * Finds the least loaded non overloaded bridge that is already handling the conference. + * + * @param bridges the list of operational bridges, ordered by load. + * @param conferenceBridges the set of bridges that are already used in the + * conference. + * + * @return the least loaded bridge that is already in the conference, if it exists, or null + */ + fun notLoadedAlreadyInConference( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties + ): Bridge? { + val result = bridges + .filterNot { it.isOverloaded(conferenceBridges) } + .intersect(conferenceBridges.keys) + .firstOrNull() + if (result != null) { + logSelection(result, conferenceBridges, participantProperties) + } + return result + } + + /** + * Finds the least loaded bridge. + * + * @param bridges the list of operational bridges, ordered by load. + * + * @return the least loaded bridge, if it exists, or null. + */ + fun leastLoaded( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties + ): Bridge? { + val result = bridges.firstOrNull() + if (result != null) { + logSelection(result, conferenceBridges, participantProperties) + } + return result + } + + /** + * Selects a bridge to be used for a new participant in a conference. + * + * @param bridges the list of bridges to select from. + * @param conferenceBridges the list of bridges currently used by the + * conference. + * @param participantRegion the region of the participant for which a + * bridge is to be selected. + * @return the selected bridge, or `null` if no bridge is + * available. + */ + abstract fun doSelect( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties + ): Bridge? + + /** + * Checks whether a [Bridge] should be considered overloaded for a + * particular conference. + * @param bridge the bridge + * @param conferenceBridges the bridges in the conference + * @return `true` if the bridge should be considered overloaded. + */ + private fun Bridge.isOverloaded(conferenceBridges: Map): Boolean = + isOverloaded || + hasMaxParticipantsInConference(conferenceBridges) || + hasMaxRecentParticipantsInConference(conferenceBridges) + + private fun Bridge.hasMaxParticipantsInConference( + conferenceBridges: Map + ): Boolean = config.maxBridgeParticipants > 0 && + conferenceBridges.containsKey(this) && + conferenceBridges[this]!!.participantCount >= config.maxBridgeParticipants + + /** + * Whether the conference has recently added too many endpoints to this bridge, i.e. it is growing on this bridge + * faster than the bridge's stress reports can keep up with. Note that this is specific to the conference, and does + * not affect the way the bridge is treated for other conferences. + */ + private fun Bridge.hasMaxRecentParticipantsInConference( + conferenceBridges: Map + ): Boolean = config.maxBridgeParticipantsPerInterval > 0 && + (conferenceBridges[this]?.recentlyAddedParticipantCount ?: 0) >= + config.maxBridgeParticipantsPerInterval + + /** + * Log (and count) the bridges on which the conference has hit the [config.maxBridgeParticipantsPerInterval] limit. + * This is only for visibility, the limit itself is enforced in [hasMaxRecentParticipantsInConference]. + */ + private fun logRateLimitedBridges(conferenceBridges: Map) { + if (config.maxBridgeParticipantsPerInterval <= 0) return + val rateLimited = conferenceBridges.filterValues { + it.recentlyAddedParticipantCount >= config.maxBridgeParticipantsPerInterval + } + if (rateLimited.isNotEmpty()) { + BridgeMetrics.rateLimited.inc() + logger.info( + "The conference has recently added too many endpoints to these bridges, they will not be used for " + + "this participant: " + + rateLimited.entries.joinToString { + "${it.key}=${it.value.recentlyAddedParticipantCount}" + } + ) + } + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeSelector.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeSelector.kt new file mode 100644 index 0000000..4c5c2ca --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeSelector.kt @@ -0,0 +1,302 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.OctoConfig +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.utils.concurrent.CustomizableThreadFactory +import org.jitsi.utils.event.AsyncEventEmitter +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension +import org.jxmpp.jid.Jid +import java.time.Clock +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors + +/** + * Class exposes methods for selecting best videobridge from all currently + * available. + * + * @author Pawel Domas + * @author Boris Grozev + */ +class BridgeSelector @JvmOverloads constructor( + private val clock: Clock = Clock.systemUTC() +) : HealthCheckListener { + private val logger = createLogger() + + /** + * TODO: Refactor to use a common executor. + */ + private val eventEmitterExecutor = + Executors.newSingleThreadExecutor(CustomizableThreadFactory("BridgeSelector-AsyncEventEmitter", false)) + private val eventEmitter = AsyncEventEmitter(eventEmitterExecutor) + fun addHandler(eventHandler: EventHandler) = eventEmitter.addHandler(eventHandler) + fun removeHandler(eventHandler: EventHandler) = eventEmitter.removeHandler(eventHandler) + + /** The bridge selection strategy. */ + private val bridgeSelectionStrategy = BridgeConfig.config.selectionStrategy.also { + logger.info("Using ${it.javaClass.name}") + } + + /** The map of bridge JID to Bridge. */ + private val bridges: MutableMap = ConcurrentHashMap() + + /** Get the [Bridge] with a specific JID or null */ + fun get(jid: Jid) = bridges[jid] + + init { + JicofoMetricsContainer.instance.metricsUpdater.addUpdateTask { updateMetrics() } + } + + fun hasNonOverloadedBridge(): Boolean = bridges.values.any { !it.isOverloaded } + fun getAll(): List = bridges.values.toList() + + val operationalBridgeCount: Int + @Synchronized + get() = bridges.values.count { it.isOperational && !it.isInGracefulShutdown } + + /** + * Adds a bridge to this selector, or if a bridge with the given JID + * already exists updates its stats. + * + * @param bridgeJid the JID of videobridge to be added to this selector's + * set of videobridges. + * @param stats the last reported statistics + * @return the [Bridge] instance for the given JID. + */ + @JvmOverloads + @Synchronized + fun addJvbAddress(bridgeJid: Jid, stats: ColibriStatsExtension? = null): Bridge = bridges[bridgeJid]?.let { + val wasShutingDown = it.isShuttingDown + it.setStats(stats) + if (!wasShutingDown && it.isShuttingDown) { + logger.info("${it.jid} entered SHUTTING_DOWN") + eventEmitter.fireEvent { bridgeIsShuttingDown(it) } + } + return it + } ?: Bridge(bridgeJid, clock).also { newBridge -> + if (stats != null) { + newBridge.setStats(stats) + } + logger.info("Added new videobridge: $newBridge") + bridges[bridgeJid] = newBridge + bridgeCount.inc() + eventEmitter.fireEvent { bridgeAdded(newBridge) } + } + + /** + * Removes a [Bridge] with a specific JID from the list of videobridge instances. + * + * @param bridgeJid the JID of bridge to remove. + */ + @Synchronized + fun removeJvbAddress(bridgeJid: Jid) { + logger.info("Removing JVB: $bridgeJid") + bridges.remove(bridgeJid)?.let { + if (!it.isInGracefulShutdown && !it.isShuttingDown) { + logger.warn("Lost a bridge: $bridgeJid") + lostBridges.inc() + } + it.markRemoved() + bridgeCount.dec() + eventEmitter.fireEvent { bridgeRemoved(it) } + } + } + + override fun healthCheckPassed(bridgeJid: Jid) { + bridges[bridgeJid]?.isOperational = true + } + + override fun healthCheckFailed(bridgeJid: Jid) = bridges[bridgeJid]?.let { + // When a bridge returns a non-healthy status, we mark it as non-operational AND we move all conferences + // away from it. + it.isOperational = false + eventEmitter.fireEvent { bridgeFailedHealthCheck(it) } + } ?: Unit + + override fun healthCheckTimedOut(bridgeJid: Jid) = bridges[bridgeJid]?.let { + // We are more lenient when a health check times out as opposed to failing with an error. We mark it as + // non-operational to prevent new conferences being allocated there, but do not move existing conferences + // away from it (which is what `notifyBridgeDown` would trigger). + // + // The reason for this is to better handle the case of an intermittent network failure between the bridge + // and jicofo that does not affect the endpoints. In this case a conference will be moved away from the + // bridge if and when a request for that conference fails or times out. This prevents unnecessary moves when + // the bridge eventually recovers (the XMPP/MUC disconnect takes much longer than a health check timing + // out), and prevents a burst of requests due to all conferences being moved together (this is especially + // bad when multiple bridges experience network problems, and conference from one failing bridge are + // attempted to be moved to another failing bridge). + // The other possible case is that the bridge is not responding to jicofo, and is also unavailable to + // endpoints. In this case we rely on endpoints reporting ICE failures to jicofo, which then trigger a move. + it.isOperational = false + } ?: Unit + + /** + * Selects a bridge to be used for a specific new [Participant] of + * a specific [JitsiMeetConference]. + * + * @return the selected bridge, represented by its [Bridge]. + * @param conferenceBridges the bridges in the conference mapped to the number of participants on each. + * @param participantRegion the region of the participant for which a + * bridge is to be selected. + */ + @Synchronized + @JvmOverloads + fun selectBridge( + conferenceBridges: Map = emptyMap(), + participantProperties: ParticipantProperties = ParticipantProperties(), + /** + * A specific jitsi-videobridge version to use, or null to use any version. If conferenceBridges is non-empty + * the version needs to match the version of the existing bridges. + * */ + version: String? = null + ): Bridge? { + var v = conferenceBridges.keys.firstOrNull()?.fullVersion + if (v == null) { + v = version + } else if (version != null && version != v) { + logger.warn("An inconsistent version was requested: $version. Conference is using version: $v") + return null + } + + // the list of all known videobridges JIDs ordered by load and *operational* status. + val prioritizedBridges = synchronized(this) { ArrayList(bridges.values) } + prioritizedBridges.sort() + + var candidateBridges = prioritizedBridges.filter { it.isOperational }.toList() + if (candidateBridges.isEmpty()) { + logger.warn("There are no operational bridges.") + return null + } + + candidateBridges = candidateBridges.filter { !it.isShuttingDown }.toList() + if (candidateBridges.isEmpty()) { + logger.warn("All operational bridges are SHUTTING_DOWN") + return null + } + + if (v != null && !OctoConfig.config.allowMixedVersions) { + candidateBridges = candidateBridges.filter { it.fullVersion == v } + if (candidateBridges.isEmpty()) { + logger.warn("There are no bridges with the required version: $v") + return null + } + } + + // If there are active bridges, prefer those. + val activeBridges = candidateBridges.filter { !it.isDraining }.toList() + if (!activeBridges.isEmpty()) { + candidateBridges = activeBridges + } + + // If there are bridges not shutting down, prefer those. + val runningBridges = candidateBridges.filter { !it.isInGracefulShutdown }.toList() + if (!runningBridges.isEmpty()) { + candidateBridges = runningBridges + } + + return bridgeSelectionStrategy.select( + candidateBridges, + conferenceBridges, + participantProperties, + OctoConfig.config.enabled + ) + } + + val stats: ObjectNode + @Synchronized + get() = JsonNodeFactory.instance.objectNode().apply { + // We want to avoid exposing unnecessary hierarchy levels in the stats, + // so we'll merge stats from different "child" objects here. + put("bridge_count", bridgeCount.get()) + put("operational_bridge_count", operationalBridgeCountMetric.get()) + put("in_shutdown_bridge_count", inShutdownBridgeCountMetric.get()) + put("lost_bridges", lostBridges.get()) + put("bridge_version_count", bridgeVersionCount.get()) + } + + val debugState: ObjectNode + @Synchronized + get() = JsonNodeFactory.instance.objectNode().apply { + put("strategy", bridgeSelectionStrategy.javaClass.simpleName) + set( + "bridge", + JsonNodeFactory.instance.objectNode().apply { + bridges.values.forEach { set(it.jid.toString(), it.debugState) } + } + ) + } + + fun updateMetrics() { + inShutdownBridgeCountMetric.set(bridges.values.count { it.isInGracefulShutdown }.toLong()) + operationalBridgeCountMetric.set(bridges.values.count { it.isOperational }.toLong()) + bridgeVersionCount.set(bridges.values.map { it.fullVersion }.toSet().size.toLong()) + bridges.values.forEach { it.updateMetrics() } + } + + companion object { + @JvmField + val lostBridges = JicofoMetricsContainer.instance.registerCounter( + "bridge_selector_lost_bridges", + "Number of bridges which disconnected unexpectedly." + ) + + @JvmField + val bridgeCount = JicofoMetricsContainer.instance.registerLongGauge( + "bridge_selector_bridge_count", + "The current number of bridges" + ) + val operationalBridgeCountMetric = JicofoMetricsContainer.instance.registerLongGauge( + "bridge_selector_bridge_count_operational", + "The current number of operational bridges" + ) + val inShutdownBridgeCountMetric = JicofoMetricsContainer.instance.registerLongGauge( + "bridge_selector_bridge_count_in_shutdown", + "The current number of bridges in graceful shutdown" + ) + val bridgeVersionCount = JicofoMetricsContainer.instance.registerLongGauge( + "bridge_selector_bridge_version_count", + "The current number of different bridge versions" + ) + } + + interface EventHandler { + fun bridgeRemoved(bridge: Bridge) + fun bridgeAdded(bridge: Bridge) + fun bridgeFailedHealthCheck(bridge: Bridge) + fun bridgeIsShuttingDown(bridge: Bridge) {} + } +} + +data class ConferenceBridgeProperties( + val participantCount: Int, + val visitor: Boolean = false, + /** + * The number of endpoints that this conference has added to this bridge within + * [BridgeConfig.maxBridgeParticipantsInterval]. + */ + val recentlyAddedParticipantCount: Long = 0 +) + +data class ParticipantProperties( + val region: String? = null, + val visitor: Boolean = false +) diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/Cascade.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/Cascade.kt new file mode 100644 index 0000000..dc781b8 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/Cascade.kt @@ -0,0 +1,326 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jicofo.bridge + +import kotlin.collections.HashSet + +/** + * A representation of a cascade of bridges. + */ +interface Cascade, L : CascadeLink> { + val sessions: MutableMap + fun addLinkBetween(session: N, otherSession: N, meshId: String) + fun removeLinkTo(session: N, otherSession: N) +} + +/** + * A representation of a single bridge in a cascade + */ +interface CascadeNode, L : CascadeLink> { + val relayId: String? + val relays: MutableMap +} + +/** + * A representation of a link between bridges in a cascade + */ +interface CascadeLink { + val relayId: String? + val meshId: String? +} + +data class CascadeRepair, L : CascadeLink>( + val node: N, + val other: N, + val meshId: String +) + +fun , L : CascadeLink> Cascade.containsNode(node: N) = sessions[node.relayId] === node + +fun , L : CascadeLink> N.hasMesh(meshId: String?): Boolean = + relays.values.any { it.meshId == meshId } + +fun , L : CascadeLink> Cascade.hasMesh(meshId: String?): Boolean = + sessions.values.any { it.hasMesh(meshId) } + +fun , L : CascadeLink> Cascade.getMeshNodes(meshId: String?): List = + sessions.values.filter { it.hasMesh(meshId) } + +fun , L : CascadeLink> Cascade.addNodeToMesh( + newNode: N, + meshId: String, + existingNode: N? = null +) { + require(!containsNode(newNode)) { + "Cascade $this already contains node $newNode" + } + + if (sessions.isEmpty()) { + sessions[newNode.relayId] = newNode + return + } else if (sessions.size == 1) { + val onlyNode = sessions.values.first() + if (existingNode != null) { + require(existingNode == onlyNode) { + "Cascade $this does not contain node $existingNode" + } + } + addLinkBetween(onlyNode, newNode, meshId) + sessions[newNode.relayId] = newNode + return + } + + val meshNodes = getMeshNodes(meshId) + + if (meshNodes.isEmpty()) { + require(existingNode != null) { + "An existing node must be specified (non-null) when adding a new mesh $meshId to cascade $this" + } + require(containsNode(existingNode)) { + "Cascade $this does not contain node $existingNode" + } + addLinkBetween(existingNode, newNode, meshId) + sessions[newNode.relayId] = newNode + } else { + // ?? Should we check if existingNode, if not null, is a member of meshNodes? + meshNodes.forEach { node -> addLinkBetween(node, newNode, meshId) } + sessions[newNode.relayId] = newNode + } +} + +fun , N : CascadeNode, L : CascadeLink> C.removeNode( + node: N, + repairFn: (C, Set>) -> Set> +) { + if (!containsNode(node)) { + return; // Or should this be an exception. i.e. `require`? + } + check(sessions[node.relayId] === node) { + "Bridge entry for ${node.relayId} is not $node" + } + sessions.remove(node.relayId) + + node.relays.keys.forEach { key -> + val other = sessions[key] + checkNotNull(other) { + "Cascade does not contain node for $key" + } + val backLink = other.relays[node.relayId] + checkNotNull(backLink) { + "Node $other does not have backlink to $node" + } + check(backLink.relayId == node.relayId) { + "Backlink from $other to $node points to ${backLink.relayId}" + } + other.relays.remove(node.relayId) + removeLinkTo(other, node) + // The reverse-path links do not need to be removed, because the whole node is removed. + } + + val meshes = node.relays.values.map { it.meshId }.toSet() + + if (meshes.size > 1) { + /* The removed node was a bridge between two or more meshes - we need to repair the cascade. */ + val disconnected = node.relays.values.groupBy { it.meshId }.values.map { + it.flatMap { getNodesBehind(node, it.relayId!!) }.toSet() + }.toSet() + val newLinks = repairFn(this, disconnected) + newLinks.forEach { (node, other, mesh) -> + addLinkBetween(node, other, mesh) + } + /* TODO: validate that the newly-added links have left us with a valid cascade? */ + } +} + +/** Return a set of all nodes "behind" a given node link. */ +/* TODO: would this be better as an iterator? */ +fun , L : CascadeLink> Cascade.getNodesBehind(from: N, toward: N): Set { + val nodes = HashSet() + val link = requireNotNull(from.relays[toward.relayId]) { + "$from does not have a link to $toward" + } + getNodesBehind(link, toward, nodes) + return nodes +} + +fun , L : CascadeLink> Cascade.getNodesBehind(from: N, towardId: String): Set { + val toward = requireNotNull(sessions[towardId]) { + "$towardId not found in sessions" + } + return getNodesBehind(from, toward) +} + +fun , L : CascadeLink> Cascade.getNodesBehind(fromMesh: String, toward: N): Set { + val nodes = HashSet() + nodes.add(toward) + toward.relays.values.filter { it.meshId != fromMesh }.forEach { + val next = requireNotNull(sessions[it.relayId]) + getNodesBehind(it, next, nodes) + } + + return nodes +} + +private fun , L : CascadeLink> Cascade.getNodesBehind( + link: L, + toward: N, + nodes: MutableSet +) { + nodes.add(toward) + toward.relays.values.filter { it.meshId != link.meshId }.forEach { + val next = checkNotNull(sessions[it.relayId]) + getNodesBehind(it, next, nodes) + } +} + +/* Traverse the graph from a node; for each other node, indicate the node from which it was reached. */ +fun , N : CascadeNode, L : CascadeLink> C.getPathsFrom(node: N, pathFn: (C, N, N?) -> Unit) { + pathFn(this, node, null) + node.relays.values.forEach { + getPathsFrom(it, node, pathFn) + } +} + +private fun , N : CascadeNode, L : CascadeLink> C.getPathsFrom( + link: L, + from: N, + pathFn: (C, N, N?) -> Unit +) { + val node = checkNotNull(sessions[link.relayId]) + pathFn(this, node, from) + node.relays.values.filter { it.meshId != link.meshId }.forEach { + getPathsFrom(it, node, pathFn) + } +} + +/** Validate a node, or throw IllegalStateException. */ +private fun , L : CascadeLink> Cascade.validateNode(node: N) { + node.relays.entries.forEach { (key, link) -> + check(key != node.relayId) { + "$node has a link to itself" + } + check(key == link.relayId) { + "$node link indexed by $key links to $link" + } + val other = sessions[key] + checkNotNull(other) { + "$node has link to $key not found in cascade" + } + val backLink = other.relays[node.relayId] + checkNotNull(backLink) { + "$node has link to $other, but $other has no link to $node" + } + check(link.meshId == backLink.meshId) { + "$node links to $other in mesh ${link.meshId}, but backlink has mesh ${backLink.meshId}" + } + } +} + +/** Validate a mesh, or throw IllegalStateException. */ +fun , L : CascadeLink> Cascade.validateMesh(meshId: String?) { + val meshNodes = getMeshNodes(meshId) + + meshNodes.forEach { node -> + meshNodes.forEach { other -> + if (other != node) { + check(node.relays.contains(other.relayId)) { + "$node has no link to $other in mesh $meshId" + } + val link = node.relays[other.relayId] + check(link!!.meshId == meshId) { + "link from $node to $other has meshId ${link.meshId}, not expected $meshId" + } + } + } + } +} + +private fun , L : CascadeLink> Cascade.visitNodeForValidation( + node: N, + parent: N?, + visitedNodes: MutableSet, + validatedMeshes: MutableSet +) { + validateNode(node) + visitedNodes.add(node.relayId) + + node.relays.values.forEach { + if (!visitedNodes.contains(it.relayId)) { + if (!validatedMeshes.contains(it.meshId)) { + validateMesh(it.meshId) + validatedMeshes.add(it.meshId) + } + val linkedNode = sessions[it.relayId] + checkNotNull(linkedNode) { + "$node has link to node ${it.relayId} not found in cascade" + } + visitNodeForValidation(linkedNode, node, visitedNodes, validatedMeshes) + } else { + check(it.relayId == parent?.relayId || validatedMeshes.contains(it.meshId)) { + "Multiple paths found to ${sessions[it.relayId]}" + } + } + } +} + +/** Validate a cascade, or throw IllegalStateException */ +fun , L : CascadeLink> Cascade.validate() { + if (sessions.isEmpty()) { + /* Empty cascade is trivially valid */ + return + } + val firstNode = sessions.values.first() + + val visitedNodes = HashSet() + val validatedMeshes = HashSet() + + visitNodeForValidation(firstNode, null, visitedNodes, validatedMeshes) + + check(visitedNodes.size == sessions.size) { + val unvisitedNodes = sessions.keys.subtract(visitedNodes) + "Nodes ${unvisitedNodes.joinToString()} not reachable from initial node $firstNode" + } +} + +/* Get the distance, in hops in the cascade, from one node to some node satisfying a property; + * return [Int.MAX_VALUE] if no path found. + * Note this may not necessarily be the shortest such path, if more than one path is possible to a node satisfying + * the predicate. + */ +fun , L : CascadeLink> Cascade.getDistanceFrom(node: N, pred: (N) -> Boolean): Int { + if (pred(node)) return 0 + + node.relays.values.forEach { + val distance = getDistanceFrom(it, pred) + if (distance != Int.MAX_VALUE) return distance + 1 + } + return Int.MAX_VALUE +} + +private fun , L : CascadeLink> Cascade.getDistanceFrom(link: L, pred: (N) -> Boolean): Int { + val node = checkNotNull(sessions[link.relayId]) + + if (pred(node)) return 0 + + node.relays.values.filter { it.meshId != link.meshId }.forEach { + val distance = getDistanceFrom(it, pred) + if (distance != Int.MAX_VALUE) return distance + 1 + } + return Int.MAX_VALUE +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/HealthCheckListener.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/HealthCheckListener.kt new file mode 100644 index 0000000..154d7bc --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/HealthCheckListener.kt @@ -0,0 +1,26 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import org.jxmpp.jid.Jid + +interface HealthCheckListener { + fun healthCheckPassed(bridgeJid: Jid) + fun healthCheckFailed(bridgeJid: Jid) + fun healthCheckTimedOut(bridgeJid: Jid) +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/IntraRegionBridgeSelectionStrategy.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/IntraRegionBridgeSelectionStrategy.kt new file mode 100644 index 0000000..cb3fe12 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/IntraRegionBridgeSelectionStrategy.kt @@ -0,0 +1,47 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +/** + * Implements a [BridgeSelectionStrategy] which selects bridges in a single + * region, but uses multiple bridges for load balancing. + */ +class IntraRegionBridgeSelectionStrategy : BridgeSelectionStrategy() { + override fun doSelect( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + ): Bridge? { + val participantRegion = participantProperties.region + if (bridges.isEmpty()) { + return null + } + if (conferenceBridges.isEmpty()) { + // Try to match the participant region for the initial selection + return notLoadedInRegion(bridges, conferenceBridges, participantProperties, participantRegion) + ?: leastLoaded(bridges, conferenceBridges, participantProperties) + } + val conferenceRegion = conferenceBridges.keys.first().region + return notLoadedAlreadyInConferenceInRegion(bridges, conferenceBridges, participantProperties, conferenceRegion) + ?: notLoadedInRegion(bridges, conferenceBridges, participantProperties, conferenceRegion) + ?: leastLoadedAlreadyInConferenceInRegion( + bridges, + conferenceBridges, + participantProperties, + conferenceRegion + ) + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/RegionBasedBridgeSelectionStrategy.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/RegionBasedBridgeSelectionStrategy.kt new file mode 100644 index 0000000..0181f27 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/RegionBasedBridgeSelectionStrategy.kt @@ -0,0 +1,123 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import org.jitsi.jicofo.JicofoConfig +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.LoggerImpl + +/** + * Implements a [BridgeSelectionStrategy] which attempts to select bridges in the participant's region. + * + * Specifically, it implements the following algorithm: + * + * (Happy case 1): If there is a non-overloaded bridge in the conference + * and in the region: use the least loaded of them. + * (Happy case 1A): If there is a non-overloaded bridge in the conference + * and in the region's group: use the least loaded of them. + * (Happy case 2): If there is a non-overloaded bridge in the region, + * and the conference has no bridges in the region: use the least loaded bridge + * in the region. + * (Happy case 2A): If there is a non-overloaded bridge in the region's group, + * and the conference has no bridges in the region's group: use the least loaded bridge + * in the region's group. + * + * (Split case 1): If there is a non-overloaded bridge in the region, the + * conference has bridges in the region but all are overloaded: Use the least + * loaded of the bridges in the region. + * (Split case 1A): If there is a non-overloaded bridge in the region's group, the + * conference has bridges in the region's group but all are overloaded: Use the least + * loaded of the bridges in the region. + * + * (Overload case 1): If all bridges in the region's group are overloaded, and the + * conference has a bridge in the region's group: use the least loaded conference + * bridge in the region's group. + * (Overload case 2): If all bridges in the region's group are overloaded, and the + * conference has no bridges in the region's group: use the least loaded bridge in + * the region's group. + * + * (No-region-match case 1): If there are no bridges in the region's group, and the + * conference has a non-overloaded bridge: use the least loaded conference bridge. + * (No-region-match case 2): If there are no bridges in the region's group and all + * conference bridges are overloaded: use the least loaded bridge. + * + */ +class RegionBasedBridgeSelectionStrategy : BridgeSelectionStrategy() { + /** + * Map a region to the set of all regions in its group (including itself). + */ + val localRegion = JicofoConfig.config.localRegion() + + override fun doSelect( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties + ): Bridge? { + if (bridges.isEmpty()) { + return null + } + val participantRegion = participantProperties.region + var region = participantRegion ?: localRegion + if (localRegion != null) { + val regionGroup = BridgeConfig.config.getRegionGroup(region) + if (conferenceBridges.isEmpty() && region != localRegion) { + // Selecting an initial bridge for a participant not in the local region. This is most likely because + // exactly one of the first two participants in the conference is not in the local region, and we're + // selecting for it first. I.e. there is another participant in the local region which will be + // subsequently invited. + if (regionGroup.contains(localRegion)) { + // With the above assumption, there are two participants in the local region group. Therefore, + // they will use the same bridge. Prefer to use a bridge in the local region. + region = localRegion + } + } + + // If there are no bridges in the participant region or region group, select from the local region instead. + if (bridges.none { regionGroup.contains(it.region) }) { + region = localRegion + } + } + return notLoadedAlreadyInConferenceInRegion(bridges, conferenceBridges, participantProperties, region) + ?: notLoadedAlreadyInConferenceInRegionGroup(bridges, conferenceBridges, participantProperties, region) + ?: notLoadedInRegion(bridges, conferenceBridges, participantProperties, region) + ?: notLoadedInRegionGroup(bridges, conferenceBridges, participantProperties, region) + ?: notLoadedAlreadyInConference(bridges, conferenceBridges, participantProperties) + ?: notLoaded(bridges, conferenceBridges, participantProperties) + ?: leastLoadedNotMaxedAlreadyInConference(bridges, conferenceBridges, participantProperties) + ?: leastLoaded(bridges, conferenceBridges, participantProperties) + } + + override fun toString(): String = "${javaClass.simpleName} with region groups: ${BridgeConfig.config.regionGroups}" + + override fun select( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + allowMultiBridge: Boolean + ): Bridge? { + if (!allowMultiBridge) { + logger.warn( + "Octo is disabled, but the selection strategy is RegionBased. Enable octo in jicofo.conf to " + + "allow use of multiple bridges in a conference." + ) + } + return super.select(bridges, conferenceBridges, participantProperties, allowMultiBridge) + } + + companion object { + private val logger: Logger = LoggerImpl(RegionBasedBridgeSelectionStrategy::class.java.name) + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/SingleBridgeSelectionStrategy.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/SingleBridgeSelectionStrategy.kt new file mode 100644 index 0000000..56fb182 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/SingleBridgeSelectionStrategy.kt @@ -0,0 +1,57 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.LoggerImpl + +/** + * A [BridgeSelectionStrategy] implementation which keeps all + * participants in a conference on the same bridge. + */ +class SingleBridgeSelectionStrategy : BridgeSelectionStrategy() { + /** + * {@inheritDoc} + * + * Always selects the bridge already used by the conference. + */ + override fun doSelect( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties + ): Bridge? { + if (conferenceBridges.isEmpty()) { + return leastLoadedInRegion(bridges, emptyMap(), participantProperties, participantProperties.region) + ?: leastLoaded(bridges, emptyMap(), participantProperties) + } else if (conferenceBridges.size != 1) { + logger.error("Unexpected number of bridges with SingleBridgeSelectionStrategy: ${conferenceBridges.size}") + return null + } + val bridge = conferenceBridges.keys.first() + if (!bridge.isOperational) { + logger.error("The conference already has a bridge, but it is not operational: bridge=$bridge") + return null + } + return bridge + } + + companion object { + /** + * The logger. + */ + private val logger: Logger = LoggerImpl(SingleBridgeSelectionStrategy::class.java.name) + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/SingleMeshTopologyStrategy.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/SingleMeshTopologyStrategy.kt new file mode 100644 index 0000000..8d7e58d --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/SingleMeshTopologyStrategy.kt @@ -0,0 +1,36 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import org.jitsi.jicofo.bridge.colibri.Colibri2CascadeRepair +import org.jitsi.jicofo.bridge.colibri.Colibri2Session +import org.jitsi.jicofo.bridge.colibri.ColibriV2SessionManager + +/** Put all bridge nodes into a single mesh, named "0". */ +class SingleMeshTopologyStrategy : TopologySelectionStrategy() { + override fun connectNode(cascade: ColibriV2SessionManager, node: Colibri2Session): TopologySelectionResult = + TopologySelectionResult(cascade.sessions.values.firstOrNull(), "0") + + override fun repairMesh( + cascade: ColibriV2SessionManager, + disconnected: Set> + ): Set { + assert(false) { + "Single Mesh policy should never result in disconnected meshes" + } + return setOf() + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/SplitBridgeSelectionStrategy.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/SplitBridgeSelectionStrategy.kt new file mode 100644 index 0000000..aedfe02 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/SplitBridgeSelectionStrategy.kt @@ -0,0 +1,63 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.LoggerImpl + +/** + * Implements a [BridgeSelectionStrategy] which "splits" a conference to as many bridges as possible (always + * selects a bridge not in the conference or with the fewest endpoints from that conference). For testing purposes only. + */ +class SplitBridgeSelectionStrategy : BridgeSelectionStrategy() { + /** + * {@inheritDoc} + * + * Always selects the bridge already used by the conference. + */ + override fun doSelect( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties + ): Bridge? { + // If there's any bridge not yet in this conference, use that; otherwise + // find the bridge with the fewest participants + val bridgeNotYetInConf = bridges.firstOrNull { !conferenceBridges.containsKey(it) } + + return bridgeNotYetInConf ?: conferenceBridges.entries + .filter { bridges.contains(it.key) } + .minByOrNull { it.value.participantCount }?.key + } + + override fun select( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties, + allowMultiBridge: Boolean + ): Bridge? { + if (!allowMultiBridge) { + logger.warn( + "Force-enabling octo for SplitBridgeSelectionStrategy. To suppress this warning, enable octo" + + " in jicofo.conf." + ) + } + return super.select(bridges, conferenceBridges, participantProperties, true) + } + + companion object { + private val logger: Logger = LoggerImpl(SplitBridgeSelectionStrategy::class.java.name) + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/TopologySelectionStrategy.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/TopologySelectionStrategy.kt new file mode 100644 index 0000000..cc9953f --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/TopologySelectionStrategy.kt @@ -0,0 +1,36 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import org.jitsi.jicofo.bridge.colibri.Colibri2CascadeRepair +import org.jitsi.jicofo.bridge.colibri.Colibri2Session +import org.jitsi.jicofo.bridge.colibri.ColibriV2SessionManager + +/** + * Represents a strategy for selecting bridge topologies. + */ +abstract class TopologySelectionStrategy { + abstract fun connectNode(cascade: ColibriV2SessionManager, node: Colibri2Session): TopologySelectionResult + abstract fun repairMesh( + cascade: ColibriV2SessionManager, + disconnected: Set> + ): Set +} + +data class TopologySelectionResult( + val existingNode: Colibri2Session?, + val meshId: String +) diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/Util.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/Util.kt new file mode 100644 index 0000000..143fd72 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/Util.kt @@ -0,0 +1,24 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension + +fun ColibriStatsExtension.getDouble(name: String): Double? = try { + getValueAsString(name)?.toDouble() +} catch (e: Exception) { + null +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/VisitorSelectionStrategy.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/VisitorSelectionStrategy.kt new file mode 100644 index 0000000..29ff44b --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/VisitorSelectionStrategy.kt @@ -0,0 +1,54 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jicofo.bridge + +/** + * A bridge selection strategy that selects distinct sets of bridges for visitors and participants, when possible. + * The selection strategy for each category of participant is specified independently and invoked recursively. + */ +class VisitorSelectionStrategy : BridgeSelectionStrategy() { + private val participantSelectionStrategy = checkNotNull(BridgeConfig.config.participantSelectionStrategy) { + "participant-selection-strategy must be set when VisitorSelectionStrategy is used" + } + + private val visitorSelectionStrategy = checkNotNull(BridgeConfig.config.visitorSelectionStrategy) { + "visitor-selection-strategy must be set when VisitorSelectionStrategy is used" + } + + override fun doSelect( + bridges: List, + conferenceBridges: Map, + participantProperties: ParticipantProperties + ): Bridge? { + val eligibleBridges = bridges.filter { + val conferenceBridge = conferenceBridges[it] + // The bridge is not in the conference, or it's used for the same type of endpoint + conferenceBridge == null || conferenceBridge.visitor == participantProperties.visitor + } + val conferenceBridgesOfType = conferenceBridges.filter { it.value.visitor == participantProperties.visitor } + + val strategy = if (participantProperties.visitor) { + visitorSelectionStrategy + } else { + participantSelectionStrategy + } + + return strategy.doSelect(eligibleBridges, conferenceBridgesOfType, participantProperties) + /* If there are no available bridges filtered by participant type, allow mixing. */ + ?: strategy.doSelect(bridges, conferenceBridges, participantProperties) + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/VisitorTopologyStrategy.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/VisitorTopologyStrategy.kt new file mode 100644 index 0000000..85f34ad --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/VisitorTopologyStrategy.kt @@ -0,0 +1,99 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import org.jitsi.jicofo.bridge.colibri.Colibri2CascadeRepair +import org.jitsi.jicofo.bridge.colibri.Colibri2Session +import org.jitsi.jicofo.bridge.colibri.ColibriV2SessionManager + +/** Put participant bridges in a core mesh, and visitor bridges in region-based satellite trees. */ +class VisitorTopologyStrategy : TopologySelectionStrategy() { + private var meshCounter = 1 + + private fun nextMesh(): String = meshCounter.toString().also { meshCounter++ } + + /* Pick the best node to connect a node to from a set of existing nodes. */ + private fun pickConnectionNode( + cascade: ColibriV2SessionManager, + node: Colibri2Session, + existingNodes: Collection + ): Colibri2Session { + val nodesWithDistance = existingNodes.associateWith { + cascade.getDistanceFrom(it) { node -> !node.visitor } + } + + val sortedNodes = nodesWithDistance.entries.sortedWith( + compareBy({ it.value }, { it.key.bridge.correctedStress }) + ).map { it.key } + + /* TODO: this logic looks a lot like bridge selection. Do we want to try to share logic with that code? */ + val nonOverloadedInRegion = sortedNodes.filter { + !it.bridge.isOverloaded && it.bridge.region == node.bridge.region + } + /* Is this the right tradeoff - or do we want to pick overloaded nodes in the region first? */ + val nonOverloaded = sortedNodes.filter { + !it.bridge.isOverloaded + } + val inRegion = sortedNodes.filter { it.bridge.region == node.bridge.region } + return nonOverloadedInRegion.firstOrNull() + ?: nonOverloaded.firstOrNull() + ?: inRegion.firstOrNull() + ?: existingNodes.first() + } + + override fun connectNode(cascade: ColibriV2SessionManager, node: Colibri2Session): TopologySelectionResult { + val existingNodes = cascade.sessions.values + if (!node.visitor) { + return TopologySelectionResult(existingNodes.firstOrNull(), CORE_MESH) + } + + if (existingNodes.isEmpty()) { + /* This is the first bridge, the value doesn't matter. */ + /* TODO: if the first bridge is a visitor, do we want to add a core bridge too? */ + return TopologySelectionResult(null, nextMesh()) + } + + val best = pickConnectionNode(cascade, node, existingNodes) + return TopologySelectionResult(best, nextMesh()) + } + + override fun repairMesh( + cascade: ColibriV2SessionManager, + disconnected: Set> + ): Set { + // Figure out which part of the disconnected topology contains the core. + + val ret = HashSet() + + val core = disconnected.firstOrNull { set -> set.any { !it.visitor } } + /* We don't have a core. TODO: add one? */ + /* Just treat the first disconnected block as though it were the core. */ + ?: disconnected.first() + + val others = disconnected.subtract(setOf(core)) + others.forEach { + val connect = it.first() // Should I try to be smarter here? + val best = pickConnectionNode(cascade, connect, core) + ret.add(Colibri2CascadeRepair(connect, best, nextMesh())) + } + + return ret + } + + companion object { + private const val CORE_MESH = "0" + } +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/Colibri2Session.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/Colibri2Session.kt new file mode 100644 index 0000000..0d28d20 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/Colibri2Session.kt @@ -0,0 +1,702 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import io.opentelemetry.api.trace.Span +import io.opentelemetry.context.Context +import org.jitsi.jicofo.OctoConfig +import org.jitsi.jicofo.bridge.Bridge +import org.jitsi.jicofo.bridge.BridgeConfig +import org.jitsi.jicofo.bridge.CascadeLink +import org.jitsi.jicofo.bridge.CascadeNode +import org.jitsi.jicofo.codec.CodecUtil +import org.jitsi.jicofo.codec.Config +import org.jitsi.jicofo.conference.source.ConferenceSourceMap +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.utils.MediaType +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import org.jitsi.utils.stats.RateTracker +import org.jitsi.xmpp.extensions.TraceParent +import org.jitsi.xmpp.extensions.colibri.WebSocketPacketExtension +import org.jitsi.xmpp.extensions.colibri2.Colibri2Endpoint +import org.jitsi.xmpp.extensions.colibri2.Colibri2Error +import org.jitsi.xmpp.extensions.colibri2.Colibri2Relay +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifiedIQ +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ +import org.jitsi.xmpp.extensions.colibri2.Endpoints +import org.jitsi.xmpp.extensions.colibri2.InitialLastN +import org.jitsi.xmpp.extensions.colibri2.Media +import org.jitsi.xmpp.extensions.colibri2.Sctp +import org.jitsi.xmpp.extensions.colibri2.Transport +import org.jitsi.xmpp.extensions.jingle.DtlsFingerprintPacketExtension +import org.jitsi.xmpp.extensions.jingle.ExtmapAllowMixedPacketExtension +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension +import org.jivesoftware.smack.StanzaCollector +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smackx.muc.MUCRole +import java.time.Duration +import java.util.Collections.singletonList +import java.util.UUID +import kotlin.math.ceil + +/** The size of the buckets used to track the rate at which endpoints are added to a session. */ +private const val BUCKET_SIZE_MS = 100L + +/** Represents a colibri2 session with one specific bridge. */ +class Colibri2Session( + val colibriSessionManager: ColibriV2SessionManager, + val bridge: Bridge, + // Whether the session was constructed for the purpose of visitor nodes + val visitor: Boolean, + parentLogger: Logger +) : CascadeNode { + private val logger = createChildLogger(parentLogger).apply { + bridge.jid.resourceOrNull?.toString()?.let { addContext("bridge", it) } + } + private val xmppConnection = colibriSessionManager.xmppConnection + val id = if (BridgeConfig.config.includeBridgeNameInSessionId) { + "${bridge.jid.resourceOrEmpty}-${UUID.randomUUID()}" + } else { + UUID.randomUUID().toString() + } + + /** + * Keep track of the endpoints that this conference recently added to this bridge. Note that this is + * per-(conference, bridge), as opposed to [Bridge]'s own tracker which is per-bridge (across all conferences). + * + * We use the number-of-buckets constructor (as opposed to the window-size one) so that an interval which is not a + * multiple of the bucket size is rounded up instead of failing at runtime. + */ + private val newEndpointsRate = RateTracker( + numBuckets = ceil( + BridgeConfig.config.maxBridgeParticipantsInterval.toMillis().toDouble() / BUCKET_SIZE_MS + ).toInt().coerceAtLeast(1), + bucketSize = Duration.ofMillis(BUCKET_SIZE_MS), + clock = colibriSessionManager.clock + ) + + /** The number of endpoints that this conference recently added to this bridge. */ + internal val recentlyAddedEndpointCount: Long + get() = newEndpointsRate.getAccumulatedCount() + + /** + * Notifies this session that it was used for a new endpoint. Note that endpoints leaving are intentionally not + * subtracted (this models the rate at which endpoints were *added*, matching [Bridge.endpointAdded]). + */ + internal fun endpointAdded() { + newEndpointsRate.update(1) + bridge.endpointAdded() + } + + /** + * The colibri2 ``s currently active on this session, by id (the last set signaled to the bridge). + * Managed via [setInitialConnects] (for the create request) and [setConnects] (delta updates). + */ + private val connects = LinkedHashMap() + + /** + * Save the relay ID locally since it is possible for the relay ID of the Bridge to change and we don't want it to + * change in the context of a session. We maintain the invariant that whenever a conference has multiple sessions, + * they all have non-null relay IDs. + */ + override val relayId: String? = bridge.relayId + + /** + * Whether the colibri2 conference has been created. It is created with the first endpoint allocation request + * ([sendAllocationRequest]). + */ + var created = false + + /** + * The sources advertised by the bridge, read from the response of the initial request to create a conference. + */ + internal var feedbackSources: ConferenceSourceMap = ConferenceSourceMap() + + /** The set of (octo) relays for the session, mapped by their ID (i.e. the relayId of the remote bridge). */ + override val relays = mutableMapOf() + + /** Creates and sends a request to allocate a new endpoint. Returns a [StanzaCollector] for the response. */ + internal fun sendAllocationRequest(participant: ParticipantInfo): StanzaCollector { + val request = createRequest(!created) + val endpoint = participant.toEndpoint(create = true, expire = false).apply { + if (participant.audioMuted || participant.videoMuted) { + setForceMute(participant.audioMuted, participant.videoMuted) + } + if (participant.visitor) { + setMucRole(MUCRole.visitor) + } + setTransport( + Transport.getBuilder().apply { + // TODO: we're hard-coding the role here, and it must be consistent with the role signaled to the + // client. Signaling inconsistent roles leads to hard to debug issues (e.g. sporadic ICE/DTLS + // failures with firefox but not chrome). + setIceControlling(true) + if (participant.useSctp) { + setSctp(Sctp.Builder().build()) + } + }.build() + ) + } + participant.medias.forEach { endpoint.addMedia(it) } + request.addEndpoint(endpoint.build()) + + logger.trace { "Sending allocation request for ${participant.id}: ${request.build().toXML()}" } + created = true + return xmppConnection.createStanzaCollectorAndSend(request.build()) + } + + /** Updates the transport info and/or sources for an existing endpoint. */ + internal fun updateParticipant( + participant: ParticipantInfo, + /** The transport info to set for the colibri2 endpoint, or null if it is not to be modified. */ + transport: IceUdpTransportPacketExtension?, + /** The sources to set for the colibri2 endpoint, or null if the sources are not to be modified. */ + sources: EndpointSourceSet?, + initialLastN: InitialLastN? + ) { + if (transport == null && sources == null && initialLastN == null) { + logger.info("Nothing to update.") + return + } + + val request = createRequest() + val endpoint = Colibri2Endpoint.getBuilder().apply { + setId(participant.id) + setStatsId(participant.statsId) + } + + if (transport != null) { + endpoint.setTransport(Transport.getBuilder().setIceUdpExtension(transport).build()) + } + + if (sources != null) { + endpoint.setSources(sources.toColibriMediaSources(participant.id)) + } + + initialLastN?.let { + endpoint.setInitialLastN(it) + } + + request.addEndpoint(endpoint.build()) + sendRequest(request.build(), "updateParticipant") + } + + /** + * Ask the bridge to perform an in-place ICE restart for an endpoint: it creates a new ICE agent with fresh + * credentials while the existing one keeps carrying media, and answers with the new transport. + * + * The bridge's new credentials are relayed on to the endpoint (via + * [ColibriV2SessionManager.endpointIceRestarted]), which addresses its connectivity checks to them. + */ + internal fun restartIce(participant: ParticipantInfo) { + val request = createRequest() + request.addEndpoint( + Colibri2Endpoint.getBuilder().apply { + setId(participant.id) + setStatsId(participant.statsId) + setTransport(Transport.getBuilder().setIceRestart(true).build()) + }.build() + ) + + logger.info("Requesting an ICE restart for ${participant.id}") + sendRequest(request.build(), "restartIce") { response -> + val bridgeTransport = response.endpoints.find { it.id == participant.id }?.transport?.iceUdpTransport + if (bridgeTransport == null) { + // The bridge can not restart ICE at all: restarts are disabled or misconfigured, the transport + // is stopped (the endpoint is going away), or it failed to create the new Agent. It says so by + // omitting the transport rather than by returning an error, so that one endpoint's request does + // not fail the whole conference-modify. + logger.warn("No transport in the response to an ICE restart request for ${participant.id}") + colibriSessionManager.endpointIceRestartFailed(participant.id) + } else { + // Either the transport of the new Agent, tagged with its ice-generation, or -- when the bridge + // did not need to restart, because its transport is not established yet -- the transport of the + // Agent it already has, with no generation. See endpointIceRestarted. + colibriSessionManager.endpointIceRestarted(participant.id, bridgeTransport) + } + } + } + + internal fun updateForceMute(participants: Set) { + val request = createRequest() + participants.forEach { participant -> + request.addEndpoint( + Colibri2Endpoint.getBuilder().apply { + setId(participant.id) + setForceMute(participant.audioMuted, participant.videoMuted) + }.build() + ) + } + + sendRequest(request.build(), "updateForceMute") + } + + /** Expire the entire conference. */ + internal fun expire() { + relays.clear() + val request = createRequest().setExpire(true) + sendRequest(request.build(), "expire") + } + + /** Expire the colibri2 endpoint for a specific participant */ + internal fun expire(participantToExpire: ParticipantInfo) = expire(singletonList(participantToExpire)) + + /** Expire the colibri2 endpoints for a set of participants. */ + internal fun expire(participantsToExpire: List) { + if (participantsToExpire.isEmpty()) { + logger.debug { "No participants to expire." } + return + } + val request = createRequest() + participantsToExpire.forEach { request.addExpire(it.id) } + + logger.debug { "Expiring endpoint: ${participantsToExpire.map { it.id }}" } + sendRequest(request.build(), "expire(participantsToExpire)") + } + + private fun createRequest(create: Boolean = false) = ConferenceModifyIQ.builder(xmppConnection).apply { + to(bridge.jid) + setMeetingId(colibriSessionManager.meetingId) + + Span.fromContextOrNull(Context.current())?.let { + addExtension( + TraceParent( + it.spanContext.traceId, + it.spanContext.spanId, + it.spanContext.traceFlags.asHex() + ) + ) + } + + if (create) { + setCreate(true) + setConferenceName(colibriSessionManager.conferenceName) + setRtcstatsEnabled(colibriSessionManager.rtcStatsEnabled) + // Include the session's connects in the create request so they don't need a separate update IQ. + connects.values.forEach { addConnect(it.toConnect(create = true)) } + } + } + + /** + * Pre-populate the set of connects to include in this session's create request, without sending anything. Used + * when a session is created while connects are already desired for it. + */ + fun setInitialConnects(specs: List) { + connects.clear() + specs.forEach { connects[it.id] = it } + } + + /** + * Apply [desired] as the full set of connects for this session, signaling only the delta to the bridge: + * connects with a new id are added with create=true, connects no longer present are expired, and connects whose + * parameters changed are re-signaled as an update. Connects whose parameters are unchanged are left alone. + */ + fun setConnects(desired: List) { + val desiredById = desired.associateBy { it.id } + val request = createRequest() + var hasDelta = false + + (connects.keys - desiredById.keys).toList().forEach { id -> + logger.info("Expiring connect id=$id") + request.addConnect(connects.getValue(id).toConnect(expire = true)) + hasDelta = true + } + desiredById.forEach { (id, spec) -> + val current = connects[id] + when { + current == null -> { + logger.info("Adding connect id=$id") + request.addConnect(spec.toConnect(create = true)) + hasDelta = true + } + + !current.sameAs(spec) -> { + logger.info("Updating connect id=$id") + request.addConnect(spec.toConnect()) + hasDelta = true + } + } + } + + connects.clear() + connects.putAll(desiredById) + if (hasDelta) { + sendRequest(request.build(), "setConnects") + } + } + + /** + * Create a new colibri2 relay. This sends an allocation request and submits an IO task to wait for and handle the + * response. + * This assumes that the colibri2 conference has already been created, i.e. [createRelay] must be called after at + * least one call to [sendAllocationRequest]. + */ + internal fun createRelay( + /** The ID of the relay (i.e. the relayId of the remote [Bridge]). */ + relayId: String, + /** Initial remote endpoints to be included in the relay. */ + initialParticipants: List, + /** + * The single flag used internally to determine the ICE/DTLS/WS roles of the relay. The two sides in a relay + * connection should have different values for [initiator]. + */ + initiator: Boolean, + /** The mesh ID of this relay connection */ + meshId: String? + ) { + logger.info( + "Creating relay $relayId (initiator=$initiator), initial participants: ${initialParticipants.map { it.id }}" + ) + if (relays.containsKey(relayId)) { + throw IllegalStateException("Relay $relayId already exists (bridge=${this.relayId}") + } + + val relay = Relay(relayId, initiator, meshId) + relays[relayId] = relay + relay.start(initialParticipants) + } + + /** + * Updates the sources for a specific endpoint on a specific relay. Also used to create a new remote endpoint in + * the relay (when [create] is true). + */ + internal fun updateRemoteParticipant( + participantInfo: ParticipantInfo, + /** The ID of the relay on which to add/update an endpoint. */ + relayId: String, + /** Whether a new relay endpoint should be created, or an existing one updated. */ + create: Boolean + ) { + logger.debug { "Updating remote participant ${participantInfo.id} on $relayId" } + relays[relayId]?.updateParticipant(participantInfo, create) + ?: throw IllegalStateException("Relay $relayId doesn't exist (bridge=${this.relayId})") + } + + /** Expires a set of endpoints on a specific relay. */ + internal fun expireRemoteParticipants(participants: List, relayId: String) { + logger.debug { "Expiring remote participants on $relayId: ${participants.map { it.id }}" } + relays[relayId]?.expireParticipants(participants) + ?: throw IllegalStateException("Relay $relayId doesn't exist (bridge=${this.relayId})") + } + + /** Sets the remote side transport information for a specific relay. */ + internal fun setRelayTransport( + /** The transport information of the other bridge. */ + transport: IceUdpTransportPacketExtension, + relayId: String + ) { + logger.info("Setting relay transport for $relayId") + logger.debug { "Setting relay transport for $relayId: ${transport.toXML()}" } + relays[relayId]?.setTransport(transport) + ?: throw IllegalStateException("Relay $relayId doesn't exist (bridge=${this.relayId}") + } + + internal fun expireAllRelays() = expireRelays(relays.keys.toList()) + internal fun expireRelay(relayId: String) = expireRelays(listOf(relayId)) + private fun expireRelays(relayIds: List) { + if (relayIds.isEmpty()) { + logger.debug("No relays to expire.") + return + } + logger.info("Expiring relays: $relayIds") + val request = createRequest() + relayIds.forEach { + request.addRelay( + Colibri2Relay.getBuilder().apply { + setId(it) + setExpire(true) + }.build() + ) + } + + relayIds.forEach { relays.remove(it) } + + sendRequest(request.build(), "expireRelays") + } + + /** + * Send an IQ async, and handle timeouts and errors: timeouts are just logged, while errors trigger a session + * failure. + * + * @param onSuccess invoked with the response when the bridge answered successfully. Note that this runs on + * [org.jitsi.jicofo.util.TaskPools.ioPool], so responses to two requests sent in order may be handled out of + * order — anything order-sensitive needs its own guard. + */ + private fun sendRequest(iq: IQ, name: String, onSuccess: ((ConferenceModifiedIQ) -> Unit)? = null) { + logger.debug { "Sending $name request: ${iq.toXML()}" } + xmppConnection.sendIqAndHandleResponseAsync(iq) { response -> + if (response == null) { + logger.info("$name request timed out. Ignoring.") + return@sendIqAndHandleResponseAsync + } + + response.error?.let { error -> + val reason = error.getExtension( + Colibri2Error.ELEMENT, + Colibri2Error.NAMESPACE + )?.reason + val endpointId = error.getExtension( + Colibri2Endpoint.ELEMENT, + Colibri2Endpoint.NAMESPACE + )?.id + // If colibri2 error extension is present then the message came from + // a jitsi-videobridge instance. Otherwise, it might come from another component + // (e.g. the XMPP server or MUC component). + val reInvite = reason == Colibri2Error.Reason.UNKNOWN_ENDPOINT && endpointId != null + if (reInvite) { + logger.warn( + "Endpoint [$endpointId] is not found, session failed: ${error.toXML()}, " + + "request was: ${iq.toXML()}" + ) + colibriSessionManager.endpointFailed(endpointId!!) + } else { + logger.error("Received error response for $name, session failed: ${error.toXML()}") + colibriSessionManager.sessionFailed(this@Colibri2Session) + } + return@sendIqAndHandleResponseAsync + } + + if (response !is ConferenceModifiedIQ) { + logger.error("Received response with unexpected type ${response.javaClass.name}") + colibriSessionManager.sessionFailed(this@Colibri2Session) + } else { + logger.debug { "Received $name response: ${response.toXML()}" } + onSuccess?.invoke(response) + } + } + } + + override fun toString() = "Colibri2Session[bridge=${bridge.jid.resourceOrNull}, id=$id]" + + fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + set("bridge", bridge.debugState) + put("id", id) + set("feedback_sources", feedbackSources.toJson()) + put("created", created) + put("recently_added_endpoints", recentlyAddedEndpointCount) + set( + "relays", + JsonNodeFactory.instance.objectNode().apply { + relays.values.forEach { set(it.relayId, it.toJson()) } + } + ) + } + + /** + * Represents a colibri2 relay connection to another bridge. + */ + inner class Relay( + /** The relayId of the remote bridge. */ + override val relayId: String, + /** + * A flag used to determine the roles. The associated [Relay] for the remote side should have the opposite + * value. + */ + initiator: Boolean, + override val meshId: String? = null + ) : CascadeLink { + // TODO: One of the ways to select the ICE/DTLS roles might save an RTT. Which one? + private val useUniquePort = initiator + private val iceControlling = initiator + private val dtlsSetup = if (initiator) "active" else "passive" + private val bridgeChannelActive = initiator + private val sctpBridgeChannel = OctoConfig.config.sctpDatachannels + + private val logger = createChildLogger(this@Colibri2Session.logger).apply { addContext("relay", relayId) } + + /** Send a request to allocate a new relay, and submit a task to wait for a response. */ + internal fun start(initialParticipants: List) { + val request = buildCreateRelayRequest(initialParticipants) + logger.trace { "Sending create relay: ${request.toXML()}" } + + xmppConnection.sendIqAndHandleResponseAsync(request) { response -> + // Wait for a response to the relay allocation request. When a response is received, parse the contained + // transport and forward it to the associated [Relay] for the remote side via [colibriSessionManager] + logger.trace { "Received response: ${response?.toXML()}" } + if (response !is ConferenceModifiedIQ) { + logger.error("Received error: ${response?.toXML() ?: "timeout"}") + colibriSessionManager.sessionFailed(this@Colibri2Session) + return@sendIqAndHandleResponseAsync + } + + // TODO: We just assume that the response has a single [Colibri2Relay]. + val transport = response.relays.firstOrNull()?.transport + ?: run { + logger.error("No transport in response: ${response.toXML()}") + colibriSessionManager.sessionFailed(this@Colibri2Session) + return@sendIqAndHandleResponseAsync + } + val iceUdpTransport = transport.iceUdpTransport + if (iceUdpTransport == null) { + logger.error("Response has no iceUdpTransport") + colibriSessionManager.sessionFailed(this@Colibri2Session) + return@sendIqAndHandleResponseAsync + } + + // Forward the response to the corresponding [Colibri2Session] + colibriSessionManager.setRelayTransport(this@Colibri2Session, iceUdpTransport, relayId) + } + } + + /** Sends a colibri2 message setting/updating the remote-side transport of this relay. */ + fun setTransport( + /** + * The transport info as received by the remote side. We update some of the fields as required based on the + * ICE/DTLS/WS roles. + */ + transport: IceUdpTransportPacketExtension + ) { + // We always expect the bridge to advertise its DTLS setup as "actpass". Here we override it to set one + // side as "active" and the other as "passive". + transport.getChildExtensionsOfType(DtlsFingerprintPacketExtension::class.java).forEach { + if (it.setup != "actpass") { + logger.error("Response has an unexpected dtls setup field: ${it.setup}") + return + } + logger.info("Setting setup=$dtlsSetup for $relayId") + it.setup = dtlsSetup + } + // The bridge always advertises a websocket if we're not using SCTP, but we want only one side to act as a + // client. + // The bridge will always act as a client if the signaled transport includes a websocket, so here we make + // sure it is only included to one of the sides. + if (sctpBridgeChannel || !bridgeChannelActive) { + transport.getChildExtensionsOfType(WebSocketPacketExtension::class.java).forEach { + transport.removeChildExtension(it) + } + } + + val request = createRequest() + val relay = Colibri2Relay.getBuilder().apply { + setId(relayId) + setCreate(false) + } + relay.setTransport(Transport.getBuilder().apply { setIceUdpExtension(transport) }.build()) + request.addRelay(relay.build()) + + sendRequest(request.build(), "Relay.setTransport") + } + + fun toJson() = JsonNodeFactory.instance.objectNode().apply { + put("id", relayId) + put("use_unique_port", useUniquePort) + put("ice_controlling", iceControlling) + put("dtls_setup", dtlsSetup) + put("bridge_channel_active", bridgeChannelActive) + put("sctp_bridge_channel", sctpBridgeChannel) + meshId?.let { put("mesh_id", it) } + } + + /** Update or create a relay endpoint for a specific participant. */ + fun updateParticipant(participant: ParticipantInfo, create: Boolean) { + val request = createRequest() + val relay = Colibri2Relay.getBuilder().apply { + setId(relayId) + } + val endpoints = Endpoints.getBuilder() + endpoints.addEndpoint(participant.toEndpoint(create = create, expire = false).build()) + relay.setEndpoints(endpoints.build()) + request.addRelay(relay.build()) + sendRequest(request.build(), "Relay.updateParticipant") + } + + /** Expire relay endpoints for a set of participants. */ + fun expireParticipants(participants: List) { + if (participants.all { it.visitor }) { + return + } + val request = createRequest() + val relay = Colibri2Relay.getBuilder().apply { setId(relayId) } + val endpoints = Endpoints.getBuilder() + + participants.forEach { + if (!it.visitor) { + endpoints.addEndpoint(it.toEndpoint(create = false, expire = true).build()) + } + } + + relay.setEndpoints(endpoints.build()) + request.addRelay(relay.build()) + + sendRequest(request.build(), "Relay.expireParticipants") + } + + /** Create a request to create a relay (this is just the initial request). */ + private fun buildCreateRelayRequest(participants: Collection): ConferenceModifyIQ { + val request = createRequest() + val relay = Colibri2Relay.getBuilder().apply { + setId(relayId) + meshId?.let { setMeshId(it) } + setCreate(true) + } + + relay.addMedia( + Media.getBuilder().apply { + setType(MediaType.AUDIO) + CodecUtil.createAudioPayloadTypeExtensions().forEach { addPayloadType(it) } + CodecUtil.createAudioRtpHdrExtExtensions().forEach { addRtpHdrExt(it) } + if (Config.Companion.config.extmapAllowMixed) { + setExtmapAllowMixed(ExtmapAllowMixedPacketExtension()) + } + }.build() + ) + relay.addMedia( + Media.getBuilder().apply { + setType(MediaType.VIDEO) + CodecUtil.createVideoPayloadTypeExtensions().forEach { addPayloadType(it) } + CodecUtil.createVideoRtpHdrExtExtensions().forEach { addRtpHdrExt(it) } + if (Config.Companion.config.extmapAllowMixed) { + setExtmapAllowMixed(ExtmapAllowMixedPacketExtension()) + } + }.build() + ) + + val endpoints = Endpoints.getBuilder() + participants.filter { !it.visitor }.forEach { + endpoints.addEndpoint(it.toEndpoint(create = true, expire = false).build()) + } + relay.setEndpoints(endpoints.build()) + + relay.setTransport( + Transport.getBuilder().apply { + setUseUniquePort(useUniquePort) + setIceControlling(iceControlling) + if (sctpBridgeChannel) { + val sctp = Sctp.Builder() + sctp.setRole(if (bridgeChannelActive) Sctp.Role.CLIENT else Sctp.Role.SERVER) + setSctp(sctp.build()) + } + }.build() + ) + + request.addRelay(relay.build()) + + return request.build() + } + } +} + +private fun ConferenceModifyIQ.Builder.addExpire(endpointId: String) = addEndpoint( + Colibri2Endpoint.getBuilder().apply { + setId(endpointId) + setExpire(true) + }.build() +) diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriAllocation.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriAllocation.kt new file mode 100644 index 0000000..e676986 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriAllocation.kt @@ -0,0 +1,35 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import org.jitsi.jicofo.conference.source.ConferenceSourceMap +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension + +/** + * Describes the resources allocated on a bridge for a single endpoint. + */ +data class ColibriAllocation( + /** The sources advertised by the bridge. */ + val sources: ConferenceSourceMap, + /** Encodes the bridge-side transport information (ICE candidates, DTLS fingerprint, etc.). */ + val transport: IceUdpTransportPacketExtension, + /** The region of the bridge */ + val region: String?, + /** An identifier for the session */ + val bridgeSessionId: String?, + /** The SCTP port (used for both local and remote port) advertised by the bridge, if any */ + val sctpPort: Int? +) diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriSessionManager.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriSessionManager.kt new file mode 100644 index 0000000..c84bce6 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriSessionManager.kt @@ -0,0 +1,171 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.MediaType +import org.jitsi.jicofo.bridge.Bridge +import org.jitsi.jicofo.bridge.ConferenceBridgeProperties +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.utils.TemplatedUrl +import org.jitsi.xmpp.extensions.colibri2.InitialLastN +import org.jitsi.xmpp.extensions.colibri2.Media +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension + +interface ColibriSessionManager { + fun addListener(listener: Listener) + fun removeListener(listener: Listener) + + /** Expire all colibri sessions. */ + fun expire() + + /** Remove a participant, expiring all resources allocated for it */ + fun removeParticipant(participantId: String) + + fun mute(participantId: String, doMute: Boolean, mediaType: MediaType): Boolean = + mute(setOf(participantId), doMute, mediaType) + fun mute(participantIds: Set, doMute: Boolean, mediaType: MediaType): Boolean + val bridgeCount: Int + val bridgeRegions: Set + + /** Get the list of participant IDs that are currently allocated on a specific [Bridge]. */ + fun getParticipants(bridge: Bridge): List + + @Throws(ColibriAllocationFailedException::class, BridgeSelectionFailedException::class) + fun allocate(participant: ParticipantAllocationParameters): ColibriAllocation + fun getBridges(): Map + + fun updateParticipant( + participantId: String, + transport: IceUdpTransportPacketExtension? = null, + sources: EndpointSourceSet? = null, + initialLastN: InitialLastN? = null, + suppressLocalBridgeUpdate: Boolean = false + ) + + /** + * Ask the bridge to perform an in-place ICE restart for a participant. The bridge creates a new ICE agent with + * fresh credentials (while the existing one keeps carrying media) and answers with its new transport, which is + * relayed back to the participant via [Listener.endpointIceRestarted]. + */ + fun restartIce(participantId: String) + + fun getBridgeSessionId(participantId: String): Pair + + fun setTranscriberUrl( + url: TemplatedUrl?, + customHeaders: Map? = null, + urlParams: Map? = null + ) + + /** + * Set the languages the transcriber should translate its transcripts into (text translation, not the + * speech-to-speech translation that [setTranslator] controls). + * + * The languages are signaled as the `requests` of the transcriber connect, which the bridge forwards to the + * transcriber. [languages] is the full set, so an empty set stops all translation. Calling this before the + * transcriber connect exists is fine: the languages are kept and applied when it is created. + */ + fun setTextTranslationLanguages(languages: Set) + + /** + * Enable, update, or disable live translation. + * + * @param url the websocket URL template the bridge(s) should connect to, or null to disable translation. + * @param requests the per-sender translation requests. How these are placed on bridges depends on the configured + * [org.jitsi.jicofo.TranslationConfig.mode]. + */ + fun setTranslator( + url: TemplatedUrl?, + requests: List = emptyList(), + customHeaders: Map? = null + ) + + /** + * Stop using [bridge], expiring all endpoints on it (e.g. because it was detected to have failed). + * @return the list of participant IDs which were on the removed bridge and now need to be re-invited. + */ + fun removeBridge(bridge: Bridge): List + + val debugState: ObjectNode + + /** + * Interface for events fired by [ColibriSessionManager]. + */ + interface Listener { + /** The number of bridges changed. */ + fun bridgeCountChanged(bridgeCount: Int) + + fun bridgeSelectionFailed() {} + fun bridgeSelectionSucceeded() {} + + /** A bridge was removed from the conference due to a failure. **/ + fun bridgeRemoved( + /** The bridge that was removed. */ + bridge: Bridge, + /** The list of participant IDs which were on the removed bridge. **/ + participantIds: List + ) + + /** Endpoint removed due to a failure e.g. unknown endpoint */ + fun endpointRemoved(endpointId: String) + + /** + * The bridge performed an ICE restart for an endpoint and rotated its own ICE credentials. The new + * transport has to be signalled to the endpoint, whose connectivity checks are addressed to it. + */ + fun endpointIceRestarted(endpointId: String, transport: IceUdpTransportPacketExtension) {} + + /** + * The bridge did not perform the ICE restart that was requested for an endpoint. The bridge signals this + * by answering with no transport rather than with an error (so that one endpoint can not fail a + * conference-modify carrying updates for others), which leaves the endpoint waiting for a restart that + * will never arrive. Recover it the way it would have been recovered without an in-place restart. + */ + fun endpointIceRestartFailed(endpointId: String) {} + } +} + +data class ParticipantAllocationParameters( + val id: String, + val statsId: String?, + val region: String?, + val sources: EndpointSourceSet, + val useSsrcRewriting: Boolean, + val useRtpMidDemux: Boolean, + val forceMuteAudio: Boolean, + val forceMuteVideo: Boolean, + val useSctp: Boolean, + val visitor: Boolean, + val supportsPrivateAddresses: Boolean, + val diarize: Boolean, + val medias: Set +) + +/** + * A request to translate one sender's audio into one or more languages. + * + * @param senderEndpointId the endpoint id of the sender whose audio is translated (used to find its local bridge). + * @param sourceName the sender's base audio source name, exported to the translator. + * @param syntheticSourceNames the synthetic, language-encoded source names requested back from the translator. + */ +data class TranslationRequest( + val senderEndpointId: String, + val sourceName: String, + val syntheticSourceNames: List +) diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriV2SessionManager.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriV2SessionManager.kt new file mode 100644 index 0000000..c437882 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriV2SessionManager.kt @@ -0,0 +1,1087 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jicofo.bridge.colibri + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.StatusCode +import org.jitsi.jicofo.MediaType +import org.jitsi.jicofo.OctoConfig +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.TranscriptionConfig +import org.jitsi.jicofo.TranslationConfig +import org.jitsi.jicofo.bridge.Bridge +import org.jitsi.jicofo.bridge.BridgeConfig.Companion.config +import org.jitsi.jicofo.bridge.BridgeSelector +import org.jitsi.jicofo.bridge.Cascade +import org.jitsi.jicofo.bridge.CascadeRepair +import org.jitsi.jicofo.bridge.ConferenceBridgeProperties +import org.jitsi.jicofo.bridge.ParticipantProperties +import org.jitsi.jicofo.bridge.addNodeToMesh +import org.jitsi.jicofo.bridge.getNodesBehind +import org.jitsi.jicofo.bridge.getPathsFrom +import org.jitsi.jicofo.bridge.removeNode +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.metrics.IceRestartMetrics +import org.jitsi.tracing.TracingGlobal +import org.jitsi.utils.TemplatedUrl +import org.jitsi.utils.event.AsyncEventEmitter +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import org.jitsi.xmpp.extensions.colibri2.Colibri2Error +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifiedIQ +import org.jitsi.xmpp.extensions.colibri2.Connect +import org.jitsi.xmpp.extensions.colibri2.InitialLastN +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.StanzaCollector +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError.Condition.bad_request +import org.jivesoftware.smack.packet.StanzaError.Condition.conflict +import org.jivesoftware.smack.packet.StanzaError.Condition.item_not_found +import org.jivesoftware.smack.packet.StanzaError.Condition.service_unavailable +import java.net.URI +import java.net.URLEncoder +import java.time.Clock +import java.util.Collections.singletonList + +/** The fixed connect id used for the (single) transcriber connect. */ +private const val TRANSCRIBER_CONNECT_ID = "transcriber" + +/** The connect id used for the single-bridge translator connect. */ +private const val TRANSLATOR_CONNECT_ID = "translator" + +/** + * The per-source translator connect specs for a single [request]: one connect per chunk of at most + * [maxLanguagesPerConnect] requested languages, each exporting the sender's source and requesting its chunk of + * synthetic outputs. Connect ids are "translator--". The source name (not the endpoint id) keys + * the connect so that an endpoint with more than one translated audio source does not collide. + * + * Note: the chunking is positional, so when [maxLanguagesPerConnect] is exceeded and a language in an earlier chunk is + * removed, the first language of the next chunk shifts into the previous connect (a brief glitch for that language). A + * stable mapping would require persisting the language->connect assignment; this is not worth it for the rare case of a + * single source translated into more than [maxLanguagesPerConnect] languages. See [buildPerSourceTranslatorSpecs]. + */ +internal fun perSourceTranslatorSpecs( + request: TranslationRequest, + url: URI, + maxLanguagesPerConnect: Int, + httpHeaders: Map, + ping: ConnectSpec.Ping? = null +): List = request.syntheticSourceNames.chunked(maxLanguagesPerConnect).mapIndexed { index, chunk -> + ConnectSpec( + id = "$TRANSLATOR_CONNECT_ID-${request.sourceName}-$index", + url = url, + type = Connect.Types.TRANSLATOR, + exports = listOf(request.sourceName), + requests = chunk, + httpHeaders = httpHeaders, + ping = ping + ) +} + +/** + * Implements [ColibriSessionManager] using colibri2. + */ +@SuppressFBWarnings("BC_IMPOSSIBLE_INSTANCEOF") +class ColibriV2SessionManager @JvmOverloads constructor( + internal val xmppConnection: AbstractXMPPConnection, + private val bridgeSelector: BridgeSelector, + internal val conferenceName: String, + /** + * A function which returns the meeting ID associated with the conference. Needed because it is not always known + * at the time this constructor is called. + */ + internal val meetingId: String, + internal val rtcStatsEnabled: Boolean, + private val bridgeVersion: String?, + parentLogger: Logger, + internal val clock: Clock = Clock.systemUTC() +) : ColibriSessionManager, Cascade { + private val logger = createChildLogger(parentLogger) + private val tracer = TracingGlobal.sdk.getTracer("org.jitsi.jicofo.colibri") + + private val eventEmitter = AsyncEventEmitter(TaskPools.ioPool) + override fun addListener(listener: ColibriSessionManager.Listener) = eventEmitter.addHandler(listener) + override fun removeListener(listener: ColibriSessionManager.Listener) = eventEmitter.removeHandler(listener) + + /** + * The single session that currently hosts the transcriber and/or translator connects. Selected as the first + * available session and reselected if it is removed. (The per-source translation mode signals connects to each + * source's own session instead; see [updateConnects].) + */ + private var connectSession: Colibri2Session? = null + + /** The transcriber URL template */ + private var transcriberUrl: TemplatedUrl? = null + + /** Custom HTTP headers for transcription */ + private var transcriberCustomHeaders: Map? = null + + /** Custom URL parameters for transcription */ + private var transcriberUrlParams: Map? = null + + /** + * The languages the transcriber should translate its transcripts into (text translation). Signaled as the + * `requests` of the transcriber connect. Kept sorted so the same set always produces the same connect and + * [ConnectSpec.sameAs] does not see a spurious change. + */ + private var textTranslationLanguages: List = emptyList() + + /** The translator URL template */ + private var translatorUrl: TemplatedUrl? = null + + /** The current per-sender translation requests. */ + private var translatorRequests: List = emptyList() + + /** Per-room translator connect headers (merged config + room metadata), or null to use config headers. */ + private var translatorCustomHeaders: Map? = null + + /** + * The colibri2 sessions that are currently active, mapped by the relayId of the [Bridge] that they use. + */ + override val sessions = mutableMapOf() + + /** + * The set of participants that have associated colibri2 endpoints allocated, mapped by their ID. A participant is + * represented by a [ParticipantInfo] instance. Needs to be kept in sync with [participantsBySession]. + */ + private val participants = mutableMapOf() + + override fun getParticipants(bridge: Bridge): List = synchronized(syncRoot) { + val session = sessions[bridge.relayId] ?: return emptyList() + return getSessionParticipants(session).map { it.id } + } + + /** + * Maintains the same set as [participants], but organized by their session. Needs to be kept in sync with + * [participants] (see [add], [remove], [clear]). + */ + private val participantsBySession = mutableMapOf>() + + /** Protects access to [sessions], [participants] and [participantsBySession]. */ + private val syncRoot = Any() + + /** + * Expire everything. + */ + override fun expire() = synchronized(syncRoot) { + logger.info("Expiring.") + sessions.values.forEach { session -> + logger.debug { "Expiring $session" } + session.bridge.endpointsRemoved(getSessionParticipants(session).size) + session.expire() + } + sessions.clear() + eventEmitter.fireEvent { bridgeCountChanged(0) } + clear() + } + + override fun removeParticipant(participantId: String) = synchronized(syncRoot) { + logger.debug { "Asked to remove $participantId" } + + participants[participantId]?.let { + logger.debug("Removing ${it.id}") + removeParticipantInfosBySession(mapOf(it.session to singletonList(it))) + } ?: logger.warn("Can not remove $participantId, no participantInfo") + Unit + } + + private fun repairMesh(cascade: ColibriV2SessionManager, disconnectedMeshes: Set>) = + config.topologyStrategy.repairMesh(cascade, disconnectedMeshes) + + private fun removeSession(session: Colibri2Session): Set { + val participants = getSessionParticipants(session) + session.bridge.endpointsRemoved(participants.size) + session.expire() + removeNode(session, ::repairMesh) + sessions.remove(session.relayId) + participantsBySession.remove(session) + participants.forEach { remove(it) } + session.relayId?.let { removedRelayId -> + sessions.values.forEach { otherSession -> otherSession.expireRelay(removedRelayId) } + } + if (session == connectSession) { + // The session hosting the transcriber/single-bridge translator connect went away; force reselection. + connectSession = null + } + // Reconcile connects across the remaining sessions (re-place transcriber/translator as needed). Safe because + // the removed session is already out of [sessions]. + if (transcriberUrl != null || translatorUrl != null) { + updateConnects() + } + return participants.toSet() + } + + private fun removeParticipantInfosBySession( + bySession: Map> + ): Set { + var sessionRemoved = false + val participantsRemoved = mutableSetOf() + bySession.forEach { (session, sessionParticipantsToRemove) -> + logger.debug { "Removing participants from session $session: ${sessionParticipantsToRemove.map { it.id }}" } + val remaining = getSessionParticipants(session) - sessionParticipantsToRemove.toSet() + val removeSession = remaining.isEmpty() + if (removeSession) { + logger.info("Removing session with no remaining participants: $session") + val sessionParticipantsRemoved = removeSession(session) + + participantsRemoved.addAll(sessionParticipantsRemoved) + sessionRemoved = true + } else { + session.expire(sessionParticipantsToRemove) + session.bridge.endpointRemoved() + sessionParticipantsToRemove.forEach { remove(it) } + participantsRemoved.addAll(sessionParticipantsToRemove) + + // Visitors don't have relay endpoints. + if (sessionParticipantsToRemove.any { !it.visitor }) { + // If the session was removed the relays themselves are expired, so there's no need to expire + // individual endpoints within a relay. + getPathsFrom(session) { _, otherSession, from -> + from?.relayId?.let { + otherSession.expireRemoteParticipants(sessionParticipantsToRemove, it) + } + } + } + } + } + + if (sessionRemoved) { + eventEmitter.fireEvent { bridgeCountChanged(sessions.size) } + } + + return participantsRemoved + } + + override fun mute(participantIds: Set, doMute: Boolean, mediaType: MediaType): Boolean { + require(mediaType == MediaType.AUDIO || mediaType == MediaType.VIDEO) { + "Unsupported media type: $mediaType" + } + synchronized(syncRoot) { + val participantsToMuteBySession = mutableMapOf>() + + participantIds.forEach { + val participantInfo = participants[it] + if (participantInfo == null) { + logger.error("No ParticipantInfo for $it, can not force mute.") + return@forEach + } + + if ((mediaType == MediaType.AUDIO && participantInfo.audioMuted == doMute) || + (mediaType == MediaType.VIDEO && participantInfo.videoMuted == doMute) + ) { + // No change required. + return@forEach + } + + // We set the updated state before we even send the request, i.e. we assume the request was successful. + // If an error occurs we should stop the whole colibri session with that bridge (TODO). + if (mediaType == MediaType.AUDIO) { + participantInfo.audioMuted = doMute + } + if (mediaType == MediaType.VIDEO) { + participantInfo.videoMuted = doMute + } + + participantsToMuteBySession.computeIfAbsent(participantInfo.session) { + mutableSetOf() + }.add(participantInfo) + } + + participantsToMuteBySession.forEach { (session, participantsToMute) -> + session.updateForceMute(participantsToMute) + } + } + return true + } + + override val bridgeCount: Int + get() = synchronized(syncRoot) { sessions.size } + override val bridgeRegions: Set + get() = synchronized(syncRoot) { sessions.values.mapNotNull { it.bridge.region }.toSet() } + + /** + * Get the [Colibri2Session] for a specific [Bridge]. If one doesn't exist, create it. Returns the session and + * a boolean indicating whether the session was just created (true) or existed (false). + */ + private fun getOrCreateSession(bridge: Bridge, visitor: Boolean): Pair = + synchronized(syncRoot) { + var session = sessions[bridge.relayId] + if (session != null) { + return Pair(session, false) + } + + session = Colibri2Session(this, bridge, visitor, logger) + // If connects are already desired but no session hosts them yet, this new session becomes the host; + // pre-populate them so they go out in the create (allocation) request rather than a separate update. + // Set connectSession first, since computeConnectSpecsFor checks it. + if (connectSession == null && (transcriberUrl != null || translatorUrl != null)) { + connectSession = session + } + session.setInitialConnects(computeConnectSpecsFor(session)) + return Pair(session, true) + } + + override fun setTranscriberUrl( + url: TemplatedUrl?, + customHeaders: Map?, + urlParams: Map? + ) = synchronized(syncRoot) { + if (transcriberUrl == url) { + return@synchronized + } + if (transcriberUrl != null && url != null) { + logger.error("Changing to a different transcriber URL is not supported") + return@synchronized + } + transcriberUrl = url + transcriberCustomHeaders = customHeaders + transcriberUrlParams = urlParams + updateConnects() + } + + override fun setTextTranslationLanguages(languages: Set) = synchronized(syncRoot) { + val sorted = languages.sorted() + if (sorted == textTranslationLanguages) { + return@synchronized + } + logger.info("Setting text translation languages: $sorted") + textTranslationLanguages = sorted + // Only the transcriber connect carries these, and only the requests change, so the bridge updates the + // existing connect in place instead of reconnecting its websocket. When there is no transcriber connect yet + // this is a no-op and the languages are picked up when it is created. + updateConnects() + } + + /** Recompute and (re)signal the connects (transcriber/translator) to every session. */ + private fun updateConnects() { + // The transcriber, and the translator in single-bridge mode, are hosted on a single chosen session. + val needsConnectSession = transcriberUrl != null || + (translatorUrl != null && TranslationConfig.config.mode == TranslationConfig.Mode.SINGLE_BRIDGE) + connectSession = if (needsConnectSession) { + (connectSession ?: sessions.values.firstOrNull()).also { + if (it == null) logger.info("No session available yet for transcriber/translator connects.") + } + } else { + null + } + + // Apply to every session, so connects that are no longer desired on a session are expired. + sessions.values.forEach { it.setConnects(computeConnectSpecsFor(it)) } + } + + /** The connects desired on [session] (transcriber and/or translator), with urls resolved for its bridge. */ + private fun computeConnectSpecsFor(session: Colibri2Session): List = buildList { + if (session == connectSession) { + transcriberUrl?.let { add(buildTranscriberSpec(session, it)) } + } + translatorUrl?.let { url -> + when (TranslationConfig.config.mode) { + TranslationConfig.Mode.SINGLE_BRIDGE -> + if (session == connectSession) add(buildSingleBridgeTranslatorSpec(session, url)) + + TranslationConfig.Mode.PER_SOURCE -> + addAll(buildPerSourceTranslatorSpecs(session, url)) + } + } + } + + /** The keepalive ping for translator connects, or null when disabled (mirrors the transcriber). */ + private fun translatorPing(): ConnectSpec.Ping? = if (TranslationConfig.config.pingEnabled) { + ConnectSpec.Ping( + TranslationConfig.config.pingInterval.toMillis().toInt(), + TranslationConfig.config.pingTimeout.toMillis().toInt() + ) + } else { + null + } + + /** A single translator connect on [session] carrying all senders' sources (single-bridge mode). */ + private fun buildSingleBridgeTranslatorSpec(session: Colibri2Session, urlTemplate: TemplatedUrl) = ConnectSpec( + id = TRANSLATOR_CONNECT_ID, + url = urlTemplate.resolve(TranslationConfig.REGION_TEMPLATE, session.bridge.region ?: ""), + type = Connect.Types.TRANSLATOR, + exports = translatorRequests.map { it.sourceName }, + requests = translatorRequests.flatMap { it.syntheticSourceNames }, + httpHeaders = translatorCustomHeaders ?: TranslationConfig.config.httpHeaders, + ping = translatorPing() + ) + + /** + * The translator connects for the senders located on [session] (per-source mode). Each sender gets one connect per + * chunk of at most [TranslationConfig.maxLanguagesPerConnect] requested languages. + */ + private fun buildPerSourceTranslatorSpecs(session: Colibri2Session, urlTemplate: TemplatedUrl): List { + val url = urlTemplate.resolve(TranslationConfig.REGION_TEMPLATE, session.bridge.region ?: "") + return translatorRequests + .filter { participants[it.senderEndpointId]?.session == session } + .flatMap { request -> + perSourceTranslatorSpecs( + request, + url, + TranslationConfig.config.maxLanguagesPerConnect, + translatorCustomHeaders ?: TranslationConfig.config.httpHeaders, + translatorPing() + ).also { specs -> + // Detect the at-risk condition for the positional-chunking glitch noted on perSourceTranslatorSpecs: + // a source split across multiple connects can have languages move between connects on a removal. + if (specs.size > 1) { + logger.warn( + "Source ${request.sourceName} is translated into ${request.syntheticSourceNames.size} " + + "languages, split across ${specs.size} translator connects; removing a language may " + + "move others between connects (possible brief glitch)." + ) + } + } + } + } + + private fun buildTranscriberSpec(session: Colibri2Session, urlTemplate: TemplatedUrl): ConnectSpec { + var url = urlTemplate.resolve(TranscriptionConfig.REGION_TEMPLATE, session.bridge.region ?: "") + val params = transcriberUrlParams + if (!params.isNullOrEmpty()) { + val queryString = params.entries.joinToString("&") { (key, value) -> + "${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}" + } + val separator = if (url.query == null) "?" else "&" + url = URI(url.toString() + separator + queryString) + } + return ConnectSpec( + id = TRANSCRIBER_CONNECT_ID, + url = url, + type = Connect.Types.TRANSCRIBER, + // On a transcriber connect the requests are the text-translation target languages (bare language codes), + // not source names. The bridge forwards them to the transcriber, which translates each final transcript + // into each of them. + requests = textTranslationLanguages, + httpHeaders = transcriberCustomHeaders ?: TranscriptionConfig.config.httpHeaders, + ping = if (TranscriptionConfig.config.pingEnabled) { + ConnectSpec.Ping( + TranscriptionConfig.config.pingInterval.toMillis().toInt(), + TranscriptionConfig.config.pingTimeout.toMillis().toInt() + ) + } else { + null + } + ) + } + + override fun setTranslator( + url: TemplatedUrl?, + requests: List, + customHeaders: Map? + ) = synchronized(syncRoot) { + translatorUrl = url + translatorRequests = requests + translatorCustomHeaders = customHeaders + updateConnects() + } + + /** Get the bridge-to-bridge-properties map needed for bridge selection. */ + override fun getBridges(): Map = synchronized(syncRoot) { + return participantsBySession.entries + .filter { it.key.bridge.isOperational } + .associate { + Pair( + it.key.bridge, + ConferenceBridgeProperties( + it.value.size, + it.value.firstOrNull()?.visitor == true, + it.key.recentlyAddedEndpointCount + ) + ) + } + } + + override fun addLinkBetween(session: Colibri2Session, otherSession: Colibri2Session, meshId: String) { + val participantsBehindSession = getNodesBehind(meshId, session).flatMap { getVisibleSessionParticipants(it) } + val participantsBehindOtherSession = getNodesBehind(meshId, otherSession).flatMap { + getVisibleSessionParticipants(it) + } + + session.createRelay(otherSession.relayId!!, participantsBehindOtherSession, initiator = true, meshId) + otherSession.createRelay(session.relayId!!, participantsBehindSession, initiator = false, meshId) + } + + override fun removeLinkTo(session: Colibri2Session, otherSession: Colibri2Session) { + otherSession.relayId?.let { removedRelayId -> + session.expireRelay(removedRelayId) + } + } + + @Throws(ColibriAllocationFailedException::class, BridgeSelectionFailedException::class) + override fun allocate(participant: ParticipantAllocationParameters): ColibriAllocation { + val span: Span = tracer.spanBuilder("colibri.allocate") + .startSpan() + try { + span.makeCurrent().use { s -> + return doAllocate(participant) + } + } catch (e: Throwable) { + span.setStatus(StatusCode.ERROR, e.message ?: "") + throw e + } finally { + span.end() + } + } + + @Throws(ColibriAllocationFailedException::class, BridgeSelectionFailedException::class) + fun doAllocate(participant: ParticipantAllocationParameters): ColibriAllocation { + logger.info("Allocating for ${participant.id}") + val stanzaCollector: StanzaCollector + val session: Colibri2Session + val created: Boolean + val participantInfo: ParticipantInfo + synchronized(syncRoot) { + if (participants.containsKey(participant.id)) { + throw IllegalStateException("participant already exists") + } + + if (bridgeVersion != null) { + logger.info("Selecting bridge. Conference is pinned to version \"$bridgeVersion\"") + } + + val visitor = participant.visitor + + // The requests for each session need to be sent in order, but we don't want to hold the lock while + // waiting for a response. I am not sure if processing responses is guaranteed to be in the order in which + // the requests were sent. + val bridge = bridgeSelector.selectBridge( + getBridges(), + ParticipantProperties(participant.region, visitor), + bridgeVersion + ) ?: run { + eventEmitter.fireEvent { bridgeSelectionFailed() } + throw BridgeSelectionFailedException() + } + eventEmitter.fireEvent { bridgeSelectionSucceeded() } + if (sessions.isNotEmpty() && sessions.none { it.value.bridge == bridge }) { + // There is an existing session, and this is a new bridge. + if (!OctoConfig.config.enabled) { + logger.error("A new bridge was selected, but Octo is disabled") + // This is a bridge selection failure, because the selector should not have returned a different + // bridge when Octo is not enabled. + throw BridgeSelectionFailedException() + } else if (sessions.any { it.value.relayId == null } || bridge.relayId == null) { + logger.error("Can not enable Octo: one of the selected bridges does not support Octo.") + // This is a bridge selection failure, because the selector should not have returned a different + // bridge when one of the bridges doesn't support Octo (does not have a relay ID). + throw BridgeSelectionFailedException() + } + } + getOrCreateSession(bridge, visitor).let { + session = it.first + created = it.second + } + logger.info( + "Selected ${bridge.jid.resourceOrNull} for ${participant.id} " + + "(visitor=${participant.visitor}, session exists: ${!created})" + ) + if (visitor != session.visitor) { + // Can happen if we're out of bridges for the specific class + logger.warn( + "Session $session with visitor=${session.visitor} chosen for participant with visitor=$visitor" + ) + } + participantInfo = ParticipantInfo(participant, session) + session.endpointAdded() + stanzaCollector = session.sendAllocationRequest(participantInfo) + add(participantInfo) + if (created) { + val topologySelectionResult = config.topologyStrategy.connectNode( + this, + session + ) + addNodeToMesh(session, topologySelectionResult.meshId, topologySelectionResult.existingNode) + } else { + if (!participantInfo.visitor) { + getPathsFrom(session) { _, otherSession, from -> + if (from != null) { + logger.debug { + "Adding a relayed endpoint to $otherSession for ${participantInfo.id} " + + "from ${from.relayId}." + } + // We already made sure that relayId is not null when there are multiple sessions. + otherSession.updateRemoteParticipant(participantInfo, from.relayId!!, create = true) + } + } + } + } + } + + if (created) { + eventEmitter.fireEvent { bridgeCountChanged(sessions.size) } + } + + val response: IQ? + try { + response = stanzaCollector.nextResult() + logger.trace { "Received response: ${response?.toXML()}" } + } finally { + stanzaCollector.cancel() + } + + synchronized(syncRoot) { + // We may have already removed the session and/or participant, for example due to a previous failure. In + // that case we shouldn't act on this error (hence removeBridge=false). + if (!sessions.containsValue(session)) { + logger.info("Ignoring response for a session that's no longer active (bridge=${session.bridge.jid})") + throw ColibriAllocationFailedException( + "Session no longer active (bridge=${session.bridge.jid})", + removeBridge = false + ) + } + if (!participants.containsValue(participantInfo)) { + logger.info("Ignoring response for a participant that's no longer active: ${participantInfo.id}") + throw ColibriAllocationFailedException( + "Participant no longer active: ${participantInfo.id}", + removeBridge = false + ) + } + try { + return handleResponse(response, session, created, participantInfo) + } catch (e: Exception) { + if (e is ConferenceAlreadyExistsException) { + logger.warn("Failed to allocate a colibri2 endpoint for ${participantInfo.id}: ${e.message}") + } else { + logger.error("Failed to allocate a colibri2 endpoint for ${participantInfo.id}: ${e.message}") + } + + if (e is ColibriAllocationFailedException && e.removeBridge) { + // Add participantInfo just in case it wasn't there already (the set will take care of dups). + val removedParticipants = removeSession(session) + participantInfo + remove(participantInfo) + eventEmitter.fireEvent { bridgeRemoved(session.bridge, removedParticipants.map { it.id }.toList()) } + } else { + remove(participantInfo) + } + throw e + } + } + } + + private fun handleResponse( + response: IQ?, + session: Colibri2Session, + created: Boolean, + participantInfo: ParticipantInfo + ): ColibriAllocation { + // The game we're playing here is throwing the appropriate exception type and setting or not setting the + // bridge as non-operational so the caller can do the right thing: + // * Do nothing (if this is due to an internal error we don't want to retry indefinitely) + // * Re-invite the participants (possibly on the same bridge) on this bridge + // * Re-invite the participants on this bridge to a different bridge + if (!sessions.containsKey(session.bridge.relayId)) { + val reinvite = participants[participantInfo.id] == null + logger.warn( + "Response for an unknown session, will ${if (reinvite) "" else "not "} reinvite the participant." + ) + // This is a response for a session that has been removed. We want to make sure the participant is + // re-invited, but it might have already been re-invited. + throw ColibriAllocationFailedException("Session already removed", reinvite) + } + + if (response == null) { + session.bridge.isOperational = false + throw ColibriAllocationFailedException("Timeout", true) + } + response.error?.let { error -> + // The reason in a colibri2 error extension, if one is present. If a reason is present we know the response + // comes from a jitsi-videobridge instance. Otherwise, it might come from another component (e.g. the + // XMPP server or MUC component). + val reason = error.getExtension( + Colibri2Error.ELEMENT, + Colibri2Error.NAMESPACE + )?.reason + logger.info("Received error response: ${response.toXML()}") + when (error.condition) { + bad_request -> { + // Most probably we sent a bad request. + // If we flag the bridge as non-operational we may disrupt other conferences. + // If we trigger a re-invite we may cause the same error repeating. + throw ColibriAllocationFailedException("Bad request: ${error.toXML()}", false) + } + + item_not_found -> { + if (reason == Colibri2Error.Reason.CONFERENCE_NOT_FOUND) { + // The conference on the bridge has expired. The state between jicofo and the bridge is out of + // sync. + throw ColibriAllocationFailedException("Conference not found", true) + } else { + // This is an item_not_found NOT coming from the bridge. Most likely coming from the MUC + // because the occupant left. + // This is probably a wrong selection decision, and if we re-try we run the risk of it + // repeating. + throw ColibriAllocationFailedException("Item not found, bridge unavailable?", false) + } + } + + conflict -> { + if (reason == null) { + // An error NOT coming from the bridge. + throw ColibriAllocationFailedException( + "XMPP error: ${error.toXML()}", + true + ) + } else if (reason == Colibri2Error.Reason.CONFERENCE_ALREADY_EXISTS) { + // The conference on the bridge already exists. The state between jicofo and the bridge + // is out of sync. + throw ConferenceAlreadyExistsException("Conference already exists error", true) + } else { + // An error coming from the bridge. The state between jicofo and the bridge must be out of sync. + // It's not clear how to handle this. Ideally we should expire the conference and retry, but + // we can't expire a conference without listing its individual endpoints and we think there + // were none. + // We remove the bridge from the conference (expiring it) and re-invite the participants. + throw ColibriAllocationFailedException("Colibri error: ${error.toXML()}", true) + } + } + + service_unavailable -> { + if (reason == Colibri2Error.Reason.GRACEFUL_SHUTDOWN) { + // The fact that this bridge was selected means that we haven't received its updated presence yet, + // so set the graceful-shutdown flag explicitly to prevent future selection. + session.bridge.isInGracefulShutdown = true + throw ColibriAllocationFailedException("Bridge in graceful shutdown", true) + } else { + session.bridge.isOperational = false + throw ColibriAllocationFailedException("Bridge failed with service_unavailable.", true) + } + } + + else -> { + session.bridge.isOperational = false + throw ColibriAllocationFailedException("Error: ${error.toXML()}", true) + } + } + } + + if (response !is ConferenceModifiedIQ) { + session.bridge.isOperational = false + throw ColibriAllocationFailedException( + "Response of wrong type: ${response::class.java.name}: ${response.toXML()}", + false + ) + } + + if (created) { + session.feedbackSources = response.parseSources() + } + + val transport = response.parseTransport(participantInfo.id) + ?: throw ColibriAllocationFailedException("failed to parse transport", false) + val sctpPort = transport.sctp?.port + if (participantInfo.useSctp && sctpPort == null) { + logger.error("Requested SCTP, but the response had no SCTP.") + throw ColibriAllocationFailedException("Requested SCTP, but the response had no SCTP", false) + } + + return ColibriAllocation( + session.feedbackSources, + transport.iceUdpTransport ?: throw ColibriAllocationFailedException("failed to parse transport", false), + session.bridge.region, + session.id, + sctpPort + ) + } + + internal fun sessionFailed(session: Colibri2Session) = synchronized(syncRoot) { + // Make sure the same instance is still in use. Especially with long timeouts (15s) it's possible that it's + // already been removed + if (sessions.values.contains(session)) { + val removedParticipants = removeSession(session) + eventEmitter.fireEvent { bridgeRemoved(session.bridge, removedParticipants.map { it.id }.toList()) } + } + } + + /** + * The bridge performed an ICE restart for [endpointId] and rotated its own ICE credentials. Relay its new + * transport on to the participant, which needs it to address its connectivity checks to the bridge's new + * Agent. + * + * Stale responses are dropped here rather than downstream: colibri2 responses are handled on an IO pool, so + * if the participant restarts twice in quick succession the two responses can arrive in either order. The + * `ice-generation` the bridge echoes tells us which round each belongs to, and anything older than what we + * have already relayed is discarded. + * + * A transport with no `ice-generation` at all means something different: the bridge did not restart and + * answered with the credentials of the Agent it already has. It does this when its transport has not + * connected yet, where there is nothing to preserve and nothing for the participant to do. There is nothing + * to relay in that case, and no restart to record. + */ + internal fun endpointIceRestarted(endpointId: String, transport: IceUdpTransportPacketExtension) { + val generation = transport.iceGeneration + synchronized(syncRoot) { + val participantInfo = participants[endpointId] ?: run { + logger.info("ICE restart: got a transport for an unknown endpoint $endpointId, ignoring.") + IceRestartMetrics.failed.inc() + return + } + if (generation == IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED) { + logger.info("ICE restart: the bridge kept its existing transport for $endpointId, not relaying.") + IceRestartMetrics.notNeeded.inc() + return + } + if (participantInfo.lastRelayedIceGeneration != IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED && + generation <= participantInfo.lastRelayedIceGeneration + ) { + logger.info( + "ICE restart: ignoring a stale transport for $endpointId, generation=$generation " + + "(already relayed ${participantInfo.lastRelayedIceGeneration})" + ) + IceRestartMetrics.failed.inc() + return + } + participantInfo.lastRelayedIceGeneration = generation + } + logger.info( + "ICE restart: received the bridge's transport for $endpointId, generation=$generation, " + + "ufrag=${transport.ufrag}" + ) + eventEmitter.fireEvent { endpointIceRestarted(endpointId, transport) } + } + + /** + * The bridge declined an ICE restart we requested for [endpointId] (it answered with no transport). The + * endpoint is waiting for a restart that will never arrive, so escalate to the recovery it would have got + * if it had never asked for an in-place restart. + */ + internal fun endpointIceRestartFailed(endpointId: String) { + logger.warn("ICE restart: the bridge did not restart ICE for $endpointId, falling back to a re-invite.") + IceRestartMetrics.failed.inc() + eventEmitter.fireEvent { endpointIceRestartFailed(endpointId) } + } + + override fun restartIce(participantId: String) = synchronized(syncRoot) { + val participantInfo = participants[participantId] ?: run { + logger.error("ICE restart: no ParticipantInfo for $participantId, can not request an ICE restart.") + IceRestartMetrics.failed.inc() + return + } + participantInfo.session.restartIce(participantInfo) + } + + internal fun endpointFailed(endpointId: String) { + val participantInfo = participants[endpointId] + if (participantInfo != null) { + remove(participantInfo) + eventEmitter.fireEvent { endpointRemoved(participantInfo.id) } + } else { + logger.error("Cannot find endpointFailed by $endpointId.") + } + } + + override fun updateParticipant( + participantId: String, + transport: IceUdpTransportPacketExtension?, + sources: EndpointSourceSet?, + initialLastN: InitialLastN?, + suppressLocalBridgeUpdate: Boolean + ) { + val span: Span = tracer.spanBuilder("colibri.update-participant") + .startSpan() + try { + span.makeCurrent().use { s -> + return doUpdateParticipant(participantId, transport, sources, initialLastN, suppressLocalBridgeUpdate) + } + } catch (e: Throwable) { + span.setStatus(StatusCode.ERROR, e.message ?: "") + throw e + } finally { + span.end() + } + } + + fun doUpdateParticipant( + participantId: String, + transport: IceUdpTransportPacketExtension?, + sources: EndpointSourceSet?, + initialLastN: InitialLastN?, + suppressLocalBridgeUpdate: Boolean + ) = synchronized(syncRoot) { + logger.debug("Updating $participantId with transport=$transport, sources=$sources") + + val participantInfo = participants[participantId] + ?: run { + // This can happen after a colibri session is removed due to a failure to allocate, since we never + // notify the JitsiMeetConferenceImpl object of the failure. + logger.error("No ParticipantInfo for $participantId") + return + } + transport?.let { stampIceGeneration(participantInfo, it) } + if (!suppressLocalBridgeUpdate) { + participantInfo.session.updateParticipant(participantInfo, transport, sources, initialLastN) + } + if (sources != null) { + participantInfo.sources = sources + if (!participantInfo.visitor) { + getPathsFrom(participantInfo.session) { _, otherSession, from -> + if (from != null) { + // We make sure that relayId is not null when there are multiple sessions. + otherSession.updateRemoteParticipant(participantInfo, from.relayId!!, false) + } + } + } + } + } + + /** + * Tags the transport that answers an ICE restart with the `ice-generation` of that restart round, which is + * how the bridge tells the credentials of the new Agent from an ordinary transport update for the + * established one. An untagged answer is applied to the established Agent, which already has connectivity + * and ignores it, so the new Agent never learns the endpoint's credentials, never starts its checks, and the + * restart is abandoned when it times out. + * + * We tag it here rather than rely on the endpoint to echo the attribute back: `ice-generation` has no SDP + * representation, so an endpoint can only carry it across by handling it explicitly, and one that does not + * (an older client, or one that failed to) would silently get no restart at all. Jicofo knows which round is + * outstanding, so it can tag on the endpoint's behalf. A tag the endpoint did set is left alone. + * + * Only the first credentials of a round are tagged. A transport with no credentials carries trickled + * candidates and belongs to the established Agent, and so does one that repeats the credentials we last + * forwarded - tagging either would hand the new Agent the wrong password. + * + * Must be called with [syncRoot] held. + */ + private fun stampIceGeneration(participantInfo: ParticipantInfo, transport: IceUdpTransportPacketExtension) { + val ufrag = transport.ufrag + if (ufrag == null || transport.password == null) { + return + } + + val previousUfrag = participantInfo.lastSignaledIceUfrag + participantInfo.lastSignaledIceUfrag = ufrag + + if (transport.iceGeneration != IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED) { + participantInfo.lastAnsweredIceGeneration = transport.iceGeneration + return + } + + val generation = participantInfo.lastRelayedIceGeneration + if (generation == IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED || + generation == participantInfo.lastAnsweredIceGeneration || + ufrag == previousUfrag + ) { + return + } + + participantInfo.lastAnsweredIceGeneration = generation + transport.iceGeneration = generation + logger.info( + "ICE restart: tagging the transport of ${participantInfo.id} with generation=$generation " + + "(ufrag=$ufrag), the endpoint did not." + ) + } + + override fun getBridgeSessionId(participantId: String): Pair = synchronized(syncRoot) { + val session = participants[participantId]?.session + return Pair(session?.bridge, session?.id) + } + + override fun removeBridge(bridge: Bridge): List = synchronized(syncRoot) { + val sessionToRemove = sessions.values.find { it.bridge.jid == bridge.jid } ?: return emptyList() + logger.info("Removing bridges: $bridge") + val participantsToRemove = getSessionParticipants(sessionToRemove) + + removeParticipantInfosBySession(mapOf(sessionToRemove to participantsToRemove)) + + logger.info("Removed participants: ${participantsToRemove.map { it.id }}") + participantsToRemove.map { it.id } + } + + override val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + synchronized(syncRoot) { + val participantsJson = JsonNodeFactory.instance.objectNode() + participants.values.forEach { participantsJson.set(it.id, it.toJson()) } + set("participants", participantsJson) + + val sessionsJson = JsonNodeFactory.instance.objectNode() + sessions.values.forEach { + sessionsJson.set( + it.bridge.jid.resourceOrNull.toString(), + it.toJson().also { sessionJson -> + sessionJson.set( + "participants", + JsonNodeFactory.instance.arrayNode().apply { + getSessionParticipants(it).forEach { participant -> add(participant.id) } + } + ) + } + ) + } + set("sessions", sessionsJson) + } + } + + /** + * Sets the transport information for a relay. Since relays connect two different [Colibri2Session]s which don't + * know about each other, their communications goes through [setRelayTransport] + */ + internal fun setRelayTransport( + /** The session which received transport information for a relay. */ + session: Colibri2Session, + /** The transport information for [session]'s bridge, which needs to be passed to the other session. */ + transport: IceUdpTransportPacketExtension, + /** The ID of the relay, which can be used to find the other session. */ + relayId: String + ) { + logger.info("Received transport from $session for relay $relayId") + logger.debug { "Received transport from $session for relay $relayId: ${transport.toXML()}" } + synchronized(syncRoot) { + // It's possible a new session was started for the same bridge. + if (!sessions.containsKey(session.bridge.relayId) || sessions[session.bridge.relayId] != session) { + logger.info("Received a response for a session that is no longer active. Ignoring.") + return + } + // We make sure relayId is not null when there are multiple sessions. + sessions.values.find { it.relayId == relayId }?.setRelayTransport(transport, session.relayId!!) + ?: logger.warn("Response for a relay that is no longer active. Ignoring.") + } + } + + private fun clear() { + participants.clear() + participantsBySession.clear() + } + + private fun getSessionParticipants(session: Colibri2Session): List = + participantsBySession[session]?.toList() ?: emptyList() + + /* In cases where we only want visitors, don't create a data structure with all participants only to + * discard them later. + */ + private fun getVisibleSessionParticipants(session: Colibri2Session): List = + participantsBySession[session]?.filter { !it.visitor }?.toList() ?: emptyList() + + private fun remove(participantInfo: ParticipantInfo) { + participants.remove(participantInfo.id) + participantsBySession[participantInfo.session]?.remove(participantInfo) + } + + private fun add(participantInfo: ParticipantInfo) { + participants[participantInfo.id] = participantInfo + participantsBySession.computeIfAbsent(participantInfo.session) { mutableListOf() }.add(participantInfo) + } +} + +typealias Colibri2CascadeRepair = CascadeRepair diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ConnectSpec.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ConnectSpec.kt new file mode 100644 index 0000000..ff1a8c3 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ConnectSpec.kt @@ -0,0 +1,69 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import org.jitsi.xmpp.extensions.colibri2.Connect +import java.net.URI + +/** + * A description of a single desired colibri2 `` on a bridge, with its [url] already resolved for that bridge. + * The identity is [id]; a session keeps its connects keyed by it and signals add/update/remove as deltas. This is the + * jicofo-side desired state, separate from the wire [Connect] (which also carries the create/expire delta markers). + */ +data class ConnectSpec( + val id: String, + val url: URI, + val type: Connect.Types, + val audio: Boolean = true, + /** Source names exported to (sent to) the peer. */ + val exports: List = emptyList(), + /** Source names requested from (received from) the peer. */ + val requests: List = emptyList(), + val httpHeaders: Map = emptyMap(), + val ping: Ping? = null +) { + data class Ping(val interval: Int, val timeout: Int) + + /** Build the wire [Connect] for this spec with the given delta markers. */ + fun toConnect(create: Boolean = false, expire: Boolean = false): Connect = Connect( + id = id, + url = url, + protocol = Connect.Protocols.MEDIAJSON, + type = type, + audio = audio, + create = create, + expire = expire + ).apply { + httpHeaders.forEach { (name, value) -> addHttpHeader(name, value) } + if (exports.isNotEmpty()) setExports(exports) + if (requests.isNotEmpty()) setRequests(requests) + ping?.let { setPing(it.interval, it.timeout) } + } + + /** + * Whether [other] (which must have the same [id]) describes the same connect, so no update needs to be signaled. + * Source-name lists are compared as sets, since their order is not significant. + */ + fun sameAs(other: ConnectSpec): Boolean = url == other.url && + type == other.type && + audio == other.audio && + httpHeaders == other.httpHeaders && + ping == other.ping && + exports.toSet() == other.exports.toSet() && + requests.toSet() == other.requests.toSet() +} diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/Exceptions.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/Exceptions.kt new file mode 100644 index 0000000..7dac820 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/Exceptions.kt @@ -0,0 +1,29 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +/** Bridge selection failed, i.e. there were no bridges available. */ +class BridgeSelectionFailedException : Exception("Bridge selection failed") + +open class ColibriAllocationFailedException( + message: String = "", + val removeBridge: Boolean = false +) : Exception(message) + +class ConferenceAlreadyExistsException( + message: String, + removeBridge: Boolean +) : ColibriAllocationFailedException(message, removeBridge) diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/Extensions.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/Extensions.kt new file mode 100644 index 0000000..a7ce829 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/Extensions.kt @@ -0,0 +1,149 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.conference.source.ConferenceSourceMap +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.conference.source.Source +import org.jitsi.jicofo.conference.source.SsrcGroup +import org.jitsi.xmpp.extensions.colibri2.Capability +import org.jitsi.xmpp.extensions.colibri2.Colibri2Endpoint +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifiedIQ +import org.jitsi.xmpp.extensions.colibri2.MediaSource +import org.jitsi.xmpp.extensions.colibri2.Sources +import org.jitsi.xmpp.extensions.colibri2.Transport +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.packet.IQ + +/** Read the [IceUdpTransportPacketExtension] for an endpoint with ID [endpointId] (or null if missing). */ +fun ConferenceModifiedIQ.parseTransport(endpointId: String): Transport? = + endpoints.find { it.id == endpointId }?.transport + +/** + * Reads the feedback sources (at the "conference" level) and parses them into a [ConferenceSourceMap]. + * Uses the special [SSRC_OWNER_JVB] JID as the "owner" and appends the msid, since this is how jitsi-meet clients + * expect the bridge's sources to be signaled. + */ +fun ConferenceModifiedIQ.parseSources(): ConferenceSourceMap { + val parsedSources = ConferenceSourceMap() + sources?.mediaSources?.forEach { mediaSource -> + mediaSource.sources.forEach { sourcePacketExtension -> + val msid = "mixedmslabel mixedlabel${mediaSource.type}0" + val source = Source(sourcePacketExtension.ssrc, mediaSource.type, sourcePacketExtension.name, msid) + parsedSources.add(SSRC_OWNER_JVB, EndpointSourceSet(source)) + } + mediaSource.ssrcGroups.forEach { + parsedSources.add(SSRC_OWNER_JVB, EndpointSourceSet(SsrcGroup.fromPacketExtension(it, mediaSource.type))) + } + } + + return parsedSources +} + +fun EndpointSourceSet.toColibriMediaSources(endpointId: String): Sources { + val mediaSources: MutableMap = mutableMapOf() + + // We use the signaled "name" of the source at the colibri2 source ID. If a source name isn't signaled, we use a + // default ID of "endpointId-v0". This allows backwards compat with clients that don't signal source names + // (and only support a single source). + sources.forEach { source -> + val sourceId = source.name ?: Source.nameForIdAndMediaType(endpointId, source.mediaType, 0) + val mediaSource = mediaSources.computeIfAbsent(sourceId) { + MediaSource.getBuilder() + .setType(source.mediaType) + .setId(sourceId) + // Synthetic sources (e.g. translated audio) are injected by the server and the bridge must only + // forward them to endpoints explicitly subscribed to them. + .setSynthetic(source.synthetic) + } + mediaSource.addSource(source.toPacketExtension(encodeMsid = false)) + } + ssrcGroups.forEach group@{ ssrcGroup -> + if (ssrcGroup.ssrcs.isEmpty()) return@group + + val firstSource = sources.firstOrNull { ssrcGroup.ssrcs.contains(it.ssrc) } + ?: throw IllegalStateException("An SsrcGroup in an EndpointSourceSet has an SSRC without a Source") + + val sourceId = firstSource.name ?: Source.nameForIdAndMediaType(endpointId, ssrcGroup.mediaType, 0) + val mediaSource = mediaSources.computeIfAbsent(sourceId) { + MediaSource.getBuilder() + .setType(ssrcGroup.mediaType) + .setId(sourceId) + } + mediaSource.addSsrcGroup(ssrcGroup.toPacketExtension()) + } + + val sources = Sources.getBuilder() + mediaSources.values.forEach { sources.addMediaSource(it.build()) } + return sources.build() +} + +/** Create a [Colibri2Endpoint] for a specific [ParticipantInfo]. */ +internal fun ParticipantInfo.toEndpoint( + /** Whether the request should have the "create" flag set. */ + create: Boolean, + /** Whether the request should have the "expire" flag set. */ + expire: Boolean +): Colibri2Endpoint.Builder = Colibri2Endpoint.getBuilder().apply { + setId(id) + if (create) { + setCreate(true) + setStatsId(statsId) + addCapability(Capability.CAP_SOURCE_NAME_SUPPORT) + if (supportsPrivateAddresses) { + addCapability(Capability.CAP_PRIVATE_ADDRESS_CONNECTIVITY) + } + if (useSsrcRewriting) { + addCapability(Capability.CAP_SSRC_REWRITING_SUPPORT) + } + if (useRtpMidDemux) { + addCapability(Capability.CAP_RTP_MID_DEMUX_SUPPORT) + } + if (diarize) { + // Call setDiarize on the Colibri2Endpoint.Builder directly: the fluent builder chain returns a parent + // Builder type after setId(...) which does not expose setDiarize. + setDiarize(true) + } + } + // TODO: find a way to signal sources only when they change? Or is this already the case implicitly? + if (!expire) { + setSources(sources.toColibriMediaSources(id)) + } + if (expire) { + setExpire(true) + } +} + +internal fun AbstractXMPPConnection.sendIqAndHandleResponseAsync(iq: IQ, block: (IQ?) -> Unit) { + val stanzaCollector = createStanzaCollectorAndSend(iq) + TaskPools.ioPool.submit { + try { + block(stanzaCollector.nextResult()) + } finally { + stanzaCollector.cancel() + } + } +} + +/** + * The constant value used as owner attribute value of + * {@link SSRCInfoPacketExtension} for the SSRC which belongs to the JVB. + */ +const val SSRC_OWNER_JVB = "jvb" diff --git a/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ParticipantInfo.kt b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ParticipantInfo.kt new file mode 100644 index 0000000..9254d85 --- /dev/null +++ b/jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ParticipantInfo.kt @@ -0,0 +1,78 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension + +/** + * Represents the information for a specific participant/endpoint needed for colibri2. + */ +class ParticipantInfo( + parameters: ParticipantAllocationParameters, + var session: Colibri2Session +) { + val id = parameters.id + val statsId = parameters.statsId + val useSctp = parameters.useSctp + val medias = parameters.medias + val supportsPrivateAddresses = parameters.supportsPrivateAddresses + val useSsrcRewriting = parameters.useSsrcRewriting + val useRtpMidDemux = parameters.useRtpMidDemux + val visitor = parameters.visitor + val diarize = parameters.diarize + + var audioMuted = parameters.forceMuteAudio + var videoMuted = parameters.forceMuteVideo + var sources = parameters.sources + + /** + * The `ice-generation` of the most recent ICE restart for which we relayed the bridge's rotated transport + * back to this participant, used to discard responses that arrive out of order. See + * [ColibriV2SessionManager.endpointIceRestarted]. Guarded by the session manager's lock. + */ + var lastRelayedIceGeneration = IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED + + /** + * The `ice-generation` of the most recent ICE restart whose answer we have already forwarded to the bridge, + * so that only the first set of new credentials of a round is tagged. See + * [ColibriV2SessionManager.stampIceGeneration]. Guarded by the session manager's lock. + */ + var lastAnsweredIceGeneration = IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED + + /** + * The ICE ufrag of the last transport we forwarded to the bridge for this participant. The answer to an ICE + * restart is recognized by carrying credentials which are not these. Guarded by the session manager's lock. + */ + var lastSignaledIceUfrag: String? = null + + fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + put("id", id) + put("stats_id", statsId.toString()) + set("sources", sources.toJson()) + put("bridge", session.bridge.jid.resourceOrNull.toString()) + put("audio_muted", audioMuted) + put("video_muted", videoMuted) + put("private_addresses", supportsPrivateAddresses) + put("ssrc_rewriting", useSsrcRewriting) + put("rtp_mid_demux", useRtpMidDemux) + put("visitor", visitor) + put("diarize", diarize) + } +} diff --git a/jicofo-selector/src/main/resources/reference.conf b/jicofo-selector/src/main/resources/reference.conf new file mode 100644 index 0000000..138904a --- /dev/null +++ b/jicofo-selector/src/main/resources/reference.conf @@ -0,0 +1,181 @@ +// Default values for configuration read by classes in jicofo-selector. Defaults for other jicofo.* sections live in +// the reference.conf of the module that owns them (jicofo-common, jicofo). +jicofo { + // Configuration related to jitsi-videobridge + bridge { + // The maximum number of participants in a single conference to put on one bridge (use -1 for no maximum). + max-bridge-participants = 80 + // The maximum number of participants that a single conference may add to a single bridge within + // [max-bridge-participants-interval]. A bridge which has reached this limit is considered overloaded *for that + // conference only* when selecting a bridge for a new participant; other conferences, and all other bridge-level + // logic (stress, sorting, load redistribution) are unaffected. + // + // This limits the overshoot that happens when a conference bursts: a bridge reports its stress level only every + // 5-10 seconds, so during a burst of joins the reported stress is stale by construction and jicofo can keep + // selecting the same bridge long after it is full. Note this is a limit on the *rate* at which a conference grows + // on a bridge, so it does not affect a conference which grows slowly (where the stress feedback has caught up), + // and it does not affect many small conferences sharing a bridge (each has a low rate of its own). + // + // Use -1 to disable. A suggested value for a production deployment is 30 (with the default 1 minute interval). + // Note that [max-bridge-participants] remains in effect as a hard limit. + max-bridge-participants-per-interval = -1 + // The interval over which [max-bridge-participants-per-interval] is enforced. It should comfortably cover the + // interval at which bridges report their stress level (5-10 seconds). + max-bridge-participants-interval = 1 minute + // The default assumed average stress per participant. This value is only used when a bridge does not report its + // own value. + average-participant-stress = 0.01 + // The assumed time that an endpoint takes to start contributing fully to the load on a bridge. To avoid allocating + // a burst of endpoints to the same bridge, the bridge stress is adjusted by adding the number of new endpoints + // in the last [participant-rampup-time] multiplied by [average-participant-stress]. + participant-rampup-interval = 20 seconds + // The stress level above which a bridge is considered overstressed. + stress-threshold = 0.8 + // Whether to prefix the bridge session ID (used e.g. in ICE restarts) with the bridge's name. This helps + // identify the bridge from the session ID alone, e.g. in client-side logs. + include-bridge-name-in-session-id = true + // The amount of to wait before retrying using a failed bridge. + failure-reset-threshold = 1 minute + // The bridge selection strategy. The built-in strategies are: + // SingleBridgeSelectionStrategy: Use the least loaded bridge, do not split a conference between bridges (Octo). + // SplitBridgeSelectionStrategy: Use a separate bridge for each participant (for testing). + // RegionBasedBridgeSelectionStrategy: Attempt to put each participant in a bridge in their local region (i.e. use + // Octo for geo-location). + // IntraRegionBridgeSelectionStrategy: Use additional bridges only when a bridge becomes overloaded (i.e. use Octo + // only for load balancing). This hasn't been tested much; use RegionBasedBridgeSelectionStrategy unless there's a + // reason not to. + // VisitorSelectionStrategy: Use separate strategies and bridges for visitors and participants. In this case, + // visitor-selection-strategy and participant-selection-strategy should be defined + // + // Additionally, you can use the fully qualified class name for custom BridgeSelectionStrategy implementations. + selection-strategy = SingleBridgeSelectionStrategy + + // Uncomment and define these if your primary selection strategy is VisitorSelectionStrategy + # visitor-selection-strategy = RegionBasedBridgeSelectionStrategy + # participant-selection-strategy = RegionBasedBridgeSelectionStrategy + + // The topology selection strategy. + topology-strategy = SingleMeshTopologyStrategy + + // A partition of regions into groups that are "close" to each other (regions not specified here will be assumed + // to be in a group of their own). When selecting a bridge for a region R, existing conference bridge in R's group + // of regions will all be considered to match the region. + // For example, with the sample values below, if a conference has a bridge in "us-east" and a participant in + // "us-west" joins, the bridge in "us-east" will be used because "us-east" and "us-west" are in the same group. + // region-groups = [ + // [ "us-east", "us-west" ], + // [ "eu-central", "eu-west"] + // ] + health-checks { + // Whether jicofo should perform periodic health checks to the connected bridges. + enabled = true + // The interval at which to perform health checks. + interval = 10 seconds + // When a health checks times out, jicofo will retry and only consider it fail after the retry fails. This + // configures the delay between the original health check timing out and the second health check being sent. + // It is a duration and defaults to half the [interval]. + # retry-delay = 5 seconds + + // Use the lack of presence to infer unhealthy status instead of sending explicit health check requests. + use-presence = false + + // A bridge will be consider unhealthy unless we've received its presence in that period. + presence-timeout = 45 seconds + } + + // The JID of the MUC to be used as a brewery for bridge instances. + # brewery-jid = jvbbrewery@example.com + + // The XMPP connection to use to communicate with Jitsi Videobridge instances. + // Either `Client` or `Service` (case-sensitive). See the corresponding XMPP connection configuration under `xmpp`. + // Note that if no separate Service connection has been configured, all services will automatically use the + // Client connection. + xmpp-connection-name = Service + + // Detect bridge failure based on endpoints that request a restart due to an ICE failure. + ice-failure-detection { + enabled = false + // Count the number of restart requests in the last `interval`. + interval = 2 minutes + // The minimum number of endpoints on the bridge before this check is enabled. + min-endpoints = 30 + // The threshold, in terms of fraction of endpoints that requested a restart in the last `interval`, above which + // the bridge will be considered failing ICE. + threshold = 0.1 + // Once the "failing ICE" state is entered, the bridge will stay in it at least this long (refreshed as long as + // the condition is met). + timeout = 10 minutes + } + load-redistribution { + // Whether automatic redistribution of endpoints away from overloaded bridges is enabled. + enabled = false + // The interval at which to run. + interval = 1 minute + // The threshold above which to redistribute endpoints away from a bridge. This needs to be higher than + // jicofo.bridge.stress-threshold. + stress-threshold = 0.9 + // The amount of time to wait before moving more endpoints away from a bridge. + timeout = 3 minutes + // The number of endpoints to move away from a bridge at a time. + endpoints = 10 + } + } + + transcription { + # A templated URL to use for backend (non-jigasi) transcription. The strings "{{MEEETING_ID}}" and "{{REGION}}" + # will be replaced with the unique meeting ID and the region of the bridge used for transcription. The region is + # optional. Note no whitespace is allowed in the templates, they have to match exactly. + // url-template = "wss://{{REGION}}.example.com/recorder/{{MEETING_ID}}" + + # HTTP headers to include when connecting to the transcription service. These can include authentication tokens, + # API keys, content type specifications, etc. + http-headers { + // "Authorization" = "Bearer your-api-key" + // "Sec-Websocket-Protocol" = "my proto" + } + + # Ping configuration for the transcription service connection + ping { + # Whether to enable ping messages + enabled = true + # Interval between ping messages + interval = 10 seconds + # Timeout for ping responses + timeout = 3 seconds + } + } + + translation { + # A templated URL for the live-translation websocket. "{{MEETING_ID}}" and "{{REGION}}" are substituted (REGION is + # optional, set to the bridge's region). When unset, live translation is disabled. No whitespace in the templates. + // url-template = "wss://{{REGION}}.example.com/translate/{{MEETING_ID}}" + + # HTTP headers to include when connecting to the translation service. + http-headers { + // "Authorization" = "Bearer your-api-key" + } + + # How translation connects are placed on bridges: + # per-source - each sender's audio is translated by a connect on its own (local) bridge. A sender requesting + # more than max-languages-per-connect target languages is split across multiple connects on that + # bridge, so a bridge has one or more websockets per source. + # single-bridge - a single connect on one selected bridge handles all sources and languages (mirrors the + # transcriber selection). + mode = "per-source" + + # The maximum number of target languages handled by a single translation connect. ONLY applies in "per-source" + # mode; a sender requesting more languages is split across multiple connects on its bridge. Ignored in + # "single-bridge" mode. + max-languages-per-connect = 5 + + # Ping configuration for the translation service connection (keepalive to avoid idle-timeout). + ping { + # Whether to enable ping messages + enabled = true + # Interval between ping messages + interval = 10 seconds + # Timeout for ping responses + timeout = 3 seconds + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/JicofoSelectorKotestProjectConfig.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/JicofoSelectorKotestProjectConfig.kt new file mode 100644 index 0000000..92910ef --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/JicofoSelectorKotestProjectConfig.kt @@ -0,0 +1,33 @@ +import io.kotest.core.config.AbstractProjectConfig +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.metaconfig.MetaconfigSettings + +/* + * Copyright @ 2020 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Note: jicofo and jicofo-selector still share packages, so the class name is change to avoid conflicts. + */ +class JicofoSelectorKotestProjectConfig : AbstractProjectConfig() { + override suspend fun beforeProject() = super.beforeProject().also { + // The only purpose of config caching is performance. We always want caching disabled in tests (so we can + // freely modify the config without affecting other tests executing afterwards). + MetaconfigSettings.cacheEnabled = false + + JicofoMetricsContainer.instance.checkForNameConflicts = false + JicofoMetricsContainer.instance.metricsUpdater.disablePeriodicUpdates = true + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/TranscriptionConfigTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/TranscriptionConfigTest.kt new file mode 100644 index 0000000..c51aa81 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/TranscriptionConfigTest.kt @@ -0,0 +1,257 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.xmpp.RoomMetadata +import java.time.Duration + +class TranscriptionConfigTest : ShouldSpec() { + init { + context("http-headers") { + context("With no headers configured") { + withNewConfig("") { + TranscriptionConfig.config.httpHeaders shouldBe emptyMap() + } + } + + context("With headers configured") { + withNewConfig( + """ + jicofo.transcription.http-headers { + "Authorization" = "Bearer token123" + "Content-Type" = "application/json" + "User-Agent" = "JitsiMeet/1.0" + "X-API-Key" = "secret-key" + } + """.trimIndent() + ) { + TranscriptionConfig.config.httpHeaders shouldBe mapOf( + "Authorization" to "Bearer token123", + "Content-Type" to "application/json", + "User-Agent" to "JitsiMeet/1.0", + "X-API-Key" to "secret-key" + ) + } + } + + context("With empty headers block") { + withNewConfig( + """ + jicofo.transcription.http-headers { } + """.trimIndent() + ) { + TranscriptionConfig.config.httpHeaders shouldBe emptyMap() + } + } + + context("With single header") { + withNewConfig( + """ + jicofo.transcription.http-headers { + "Authorization" = "Bearer single-token" + } + """.trimIndent() + ) { + TranscriptionConfig.config.httpHeaders shouldBe mapOf( + "Authorization" to "Bearer single-token" + ) + } + } + } + + context("url-template with headers") { + context("Both URL template and headers configured") { + withNewConfig( + """ + jicofo.transcription { + url-template = "wss://{{REGION}}.example.com/recorder/{{MEETING_ID}}" + http-headers { + "Authorization" = "Bearer api-token" + "Content-Type" = "application/json" + } + } + """.trimIndent() + ) { + val config = TranscriptionConfig.config + + // Test URL functionality still works + val url = config.getUrl("test-meeting-123") + url?.resolve("REGION", "us-west")?.toString() shouldBe + "wss://us-west.example.com/recorder/test-meeting-123" + + // Test headers are available + config.httpHeaders shouldBe mapOf( + "Authorization" to "Bearer api-token", + "Content-Type" to "application/json" + ) + } + } + } + + context("processTranscriptionMetadata") { + val baseHeaders = mapOf("Base-Header" to "base-value", "Shared-Header" to "base-shared") + + context("With null transcription") { + val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(null, baseHeaders) + should("return null headers") { headers shouldBe null } + should("return null params") { params shouldBe null } + } + + context("With transcription having no headers or params") { + val (headers, params) = TranscriptionConfig.processTranscriptionMetadata( + RoomMetadata.Metadata.Transcription(), + baseHeaders + ) + should("return null headers") { headers shouldBe null } + should("return null params") { params shouldBe null } + } + + context("With URL params only") { + val urlParams = mapOf("key1" to "value1", "key2" to "value2") + val (headers, params) = TranscriptionConfig.processTranscriptionMetadata( + RoomMetadata.Metadata.Transcription(urlParams = urlParams), + baseHeaders + ) + should("return null headers") { headers shouldBe null } + should("return the URL params") { params shouldBe urlParams } + } + + context("With custom headers only") { + val customHeaders = mapOf("X-Custom" to "custom-value", "Shared-Header" to "custom-shared") + val (headers, params) = TranscriptionConfig.processTranscriptionMetadata( + RoomMetadata.Metadata.Transcription(httpHeaders = customHeaders), + baseHeaders + ) + should("return merged headers with custom taking precedence") { + headers shouldBe mapOf( + "Base-Header" to "base-value", + "Shared-Header" to "custom-shared", + "X-Custom" to "custom-value" + ) + } + should("return null params") { params shouldBe null } + } + + context("With both custom headers and URL params") { + val customHeaders = mapOf("X-Custom" to "custom-value") + val urlParams = mapOf("param1" to "v1") + val (headers, params) = TranscriptionConfig.processTranscriptionMetadata( + RoomMetadata.Metadata.Transcription(urlParams = urlParams, httpHeaders = customHeaders), + baseHeaders + ) + should("return merged headers") { + headers shouldBe mapOf( + "Base-Header" to "base-value", + "Shared-Header" to "base-shared", + "X-Custom" to "custom-value" + ) + } + should("return URL params") { params shouldBe urlParams } + } + } + + context("ping") { + context("With default values") { + withNewConfig( + """ + jicofo.transcription.ping { + enabled = false + interval = 5 seconds + timeout = 3 seconds + } + """.trimIndent() + ) { + val config = TranscriptionConfig.config + config.pingEnabled shouldBe false + config.pingInterval shouldBe Duration.ofSeconds(5) + config.pingTimeout shouldBe Duration.ofSeconds(3) + } + } + + context("With ping enabled") { + withNewConfig( + """ + jicofo.transcription.ping { + enabled = true + interval = 10 seconds + timeout = 5 seconds + } + """.trimIndent() + ) { + val config = TranscriptionConfig.config + config.pingEnabled shouldBe true + config.pingInterval shouldBe Duration.ofSeconds(10) + config.pingTimeout shouldBe Duration.ofSeconds(5) + } + } + + context("With ping in milliseconds") { + withNewConfig( + """ + jicofo.transcription.ping { + enabled = true + interval = 1500 milliseconds + timeout = 500 milliseconds + } + """.trimIndent() + ) { + val config = TranscriptionConfig.config + config.pingEnabled shouldBe true + config.pingInterval shouldBe Duration.ofMillis(1500) + config.pingTimeout shouldBe Duration.ofMillis(500) + } + } + + context("Complete configuration with URL, headers and ping") { + withNewConfig( + """ + jicofo.transcription { + url-template = "wss://{{REGION}}.example.com/recorder/{{MEETING_ID}}" + http-headers { + "Authorization" = "Bearer api-token" + } + ping { + enabled = true + interval = 30 seconds + timeout = 10 seconds + } + } + """.trimIndent() + ) { + val config = TranscriptionConfig.config + + // Test URL + val url = config.getUrl("test-meeting-123") + url?.resolve("REGION", "us-west")?.toString() shouldBe + "wss://us-west.example.com/recorder/test-meeting-123" + + // Test headers + config.httpHeaders shouldBe mapOf("Authorization" to "Bearer api-token") + + // Test ping + config.pingEnabled shouldBe true + config.pingInterval shouldBe Duration.ofSeconds(30) + config.pingTimeout shouldBe Duration.ofSeconds(10) + } + } + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/TranslationConfigTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/TranslationConfigTest.kt new file mode 100644 index 0000000..93a98d7 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/TranslationConfigTest.kt @@ -0,0 +1,93 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.xmpp.RoomMetadata + +class TranslationConfigTest : ShouldSpec() { + init { + context("http-headers") { + context("With no headers configured") { + withNewConfig("") { + TranslationConfig.config.httpHeaders shouldBe emptyMap() + } + } + + context("With headers configured") { + withNewConfig( + """ + jicofo.translation.http-headers { + "Authorization" = "Bearer token123" + "X-API-Key" = "secret-key" + } + """.trimIndent() + ) { + TranslationConfig.config.httpHeaders shouldBe mapOf( + "Authorization" to "Bearer token123", + "X-API-Key" to "secret-key" + ) + } + } + } + + context("processTranslationMetadata") { + val baseHeaders = mapOf("Base-Header" to "base-value", "Shared-Header" to "base-shared") + + context("With null translation") { + should("return null so callers fall back to config headers") { + TranslationConfig.processTranslationMetadata(null, baseHeaders) shouldBe null + } + } + + context("With translation having no headers") { + should("return null so callers fall back to config headers") { + TranslationConfig.processTranslationMetadata( + RoomMetadata.Metadata.Translation(), + baseHeaders + ) shouldBe null + } + } + + context("With custom headers") { + val customHeaders = mapOf("X-Custom" to "custom-value", "Shared-Header" to "custom-shared") + should("return base headers merged with custom, custom taking precedence") { + TranslationConfig.processTranslationMetadata( + RoomMetadata.Metadata.Translation(httpHeaders = customHeaders), + baseHeaders + ) shouldBe mapOf( + "Base-Header" to "base-value", + "Shared-Header" to "custom-shared", + "X-Custom" to "custom-value" + ) + } + } + + context("With empty custom headers") { + should("return the base headers unchanged") { + TranslationConfig.processTranslationMetadata( + RoomMetadata.Metadata.Translation(httpHeaders = emptyMap()), + baseHeaders + ) shouldBe baseHeaders + } + } + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeConfigTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeConfigTest.kt new file mode 100644 index 0000000..5e68520 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeConfigTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright @ 2020 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.jitsi.config.withLegacyConfig +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.bridge.BridgeConfig.Companion.config +import org.jitsi.metaconfig.MetaconfigSettings +import org.jitsi.utils.mins +import org.jitsi.utils.secs + +class BridgeConfigTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + init { + MetaconfigSettings.cacheEnabled = false + context("with no config the defaults from reference.conf should be used") { + config.maxBridgeParticipants shouldBe 80 + // The rate limit is disabled by default. + config.maxBridgeParticipantsPerInterval shouldBe -1 + config.maxBridgeParticipantsInterval shouldBe 1.mins + } + context("max-bridge-participants-per-interval") { + withNewConfig( + """ + jicofo.bridge.max-bridge-participants-per-interval=30 + jicofo.bridge.max-bridge-participants-interval=30 seconds + """.trimIndent() + ) { + config.maxBridgeParticipantsPerInterval shouldBe 30 + config.maxBridgeParticipantsInterval shouldBe 30.secs + } + } + context("with legacy config") { + withLegacyConfig(legacyConfig) { + config.maxBridgeParticipants shouldBe 111 + } + } + context("with new config") { + withNewConfig(newConfig) { + config.maxBridgeParticipants shouldBe 222 + } + } + context("with both legacy and new config the legacy values should be used") { + withLegacyConfig(legacyConfig) { + withNewConfig(newConfig) { + config.maxBridgeParticipants shouldBe 111 + } + } + } + context("Selection strategy") { + context("With default config") { + config.selectionStrategy.shouldBeInstanceOf() + } + context("With legacy config") { + withLegacyConfig( + "org.jitsi.jicofo.BridgeSelector.BRIDGE_SELECTION_STRATEGY=SplitBridgeSelectionStrategy" + ) { + config.selectionStrategy.shouldBeInstanceOf() + } + } + context("With new config") { + withNewConfig("jicofo.bridge.selection-strategy=RegionBasedBridgeSelectionStrategy") { + config.selectionStrategy.shouldBeInstanceOf() + } + } + } + context("Health checks") { + withNewConfig("jicofo.bridge.health-checks.interval = 2 minutes".trimIndent()) { + config.healthChecksInterval shouldBe 2.mins + config.healthChecksRetryDelay shouldBe 1.mins + } + } + context("Region groups") { + config.regionGroups shouldBe emptyMap() + + withNewConfig( + """ + jicofo.bridge.region-groups = [ + [ "us-east", "us-west" ], + [ "eu-central", "eu-west"] + ] + """.trimIndent() + ) { + config.regionGroups shouldBe mapOf( + "us-east" to setOf("us-east", "us-west"), + "us-west" to setOf("us-east", "us-west"), + "eu-central" to setOf("eu-central", "eu-west"), + "eu-west" to setOf("eu-central", "eu-west") + ) + config.getRegionGroup(null) shouldBe emptySet() + config.getRegionGroup("abc") shouldBe setOf("abc") + config.getRegionGroup("us-east") shouldBe setOf("us-east", "us-west") + } + } + } +} +private val legacyConfig = """ +org.jitsi.jicofo.BridgeSelector.MAX_PARTICIPANTS_PER_BRIDGE=111 +org.jitsi.jicofo.BridgeSelector.MAX_BRIDGE_PACKET_RATE=111 +org.jitsi.jicofo.BridgeSelector.AVG_PARTICIPANT_PACKET_RATE=111 +""".trimIndent() + +private val newConfig = """ +jicofo { + bridge { + max-bridge-participants=222 + average-participant-packet-rate-pps=222 + } +} +""".trimIndent() diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeReleaseTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeReleaseTest.kt new file mode 100644 index 0000000..b36b111 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeReleaseTest.kt @@ -0,0 +1,218 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.Spec +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.config.setNewConfig +import org.jitsi.utils.time.FakeClock +import org.jxmpp.jid.impl.JidCreate + +/** + * This test simulates the intended procedure to upgrade the set of bridges connected to a jicofo instance, and + * verifies the selection logic. + */ +class BridgeReleaseTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.SingleInstance + + val clock = FakeClock() + + override suspend fun afterSpec(spec: Spec) = super.afterSpec(spec).also { + setNewConfig("", true) + } + init { + context("Test") { + // This config is required for the whole test. Set it here instead of in [beforeSpec] because this executes + // earlier. + setNewConfig( + """ + $regionBasedConfig + jicofo.bridge.max-bridge-participants=$MAX_BP + """.trimIndent(), + true + ) + + BridgeConfig.config.maxBridgeParticipants shouldBe MAX_BP + + val selector = BridgeSelector(clock) + // We start with bridges from a single "old" release. + val old1 = selector.createBridge("old1", OLD_VERSION, 0.1) + val old2 = selector.createBridge("old2", OLD_VERSION, 0.2) + val old3 = selector.createBridge("old3", OLD_VERSION, 0.3) + + // Initially we only have bridges from the "old" release. Verify the basics. + // Select the least loaded bridge + selector.testSelect() shouldBe old1 + selector.testSelect(version = OLD_VERSION) shouldBe old1 + selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(1))) shouldBe old1 + // Select an existing conference bridge + selector.testSelect(mapOf(old2 to ConferenceBridgeProperties(1))) shouldBe old2 + selector.testSelect(mapOf(old3 to ConferenceBridgeProperties(1))) shouldBe old3 + selector.testSelect( + mapOf( + old2 to ConferenceBridgeProperties(2), + old3 to ConferenceBridgeProperties(1) + ) + ) shouldBe old2 + // Fail if the version doesn't match + selector.testSelect(version = "invalid-version") shouldBe null + // Fail with inconsistent version pinning + selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(1)), version = NEW_VERSION) shouldBe null + // Honor max-participants-per-bridge + selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(MAX_BP))) shouldBe old2 + selector.testSelect( + mapOf( + old1 to ConferenceBridgeProperties(MAX_BP), + old3 to ConferenceBridgeProperties(1) + ) + ) shouldBe old3 + selector.testSelect( + mapOf( + old1 to ConferenceBridgeProperties(MAX_BP), + old3 to ConferenceBridgeProperties(MAX_BP) + ) + ) shouldBe old2 + // Select the least loaded if all are full + selector.testSelect( + mapOf( + old1 to ConferenceBridgeProperties(MAX_BP), + old2 to ConferenceBridgeProperties(MAX_BP), + old3 to ConferenceBridgeProperties(MAX_BP) + ) + ) shouldBe old1 + + // We add a new bridge with a new release. + val new1 = selector.createBridge("new1", NEW_VERSION, 0.0, drain = true) + // An old one should be used even though the new one has stress 0, because the new one is drained. + selector.testSelect() shouldBe old1 + selector.testSelect(version = NEW_VERSION) shouldBe new1 + selector.testSelect(mapOf(new1 to ConferenceBridgeProperties(1))) shouldBe new1 + new1.setStats(stress = 0.3, drain = true) + // And more bridges with a new release. + val new2 = selector.createBridge("new2", NEW_VERSION, 0.2, drain = true) + val new3 = selector.createBridge("new3", NEW_VERSION, 0.1, drain = true) + selector.testSelect(version = NEW_VERSION) shouldBe new3 + selector.testSelect(mapOf(new2 to ConferenceBridgeProperties(1))) shouldBe new2 + selector.testSelect(mapOf(new1 to ConferenceBridgeProperties(1))) shouldBe new1 + selector.testSelect(mapOf(new1 to ConferenceBridgeProperties(1)), version = NEW_VERSION) shouldBe new1 + selector.testSelect(mapOf(new2 to ConferenceBridgeProperties(1))) shouldBe new2 + selector.testSelect(mapOf(new2 to ConferenceBridgeProperties(1)), version = NEW_VERSION) shouldBe new2 + selector.testSelect(mapOf(new3 to ConferenceBridgeProperties(1))) shouldBe new3 + selector.testSelect(mapOf(new3 to ConferenceBridgeProperties(1)), version = NEW_VERSION) shouldBe new3 + selector.testSelect(mapOf(new1 to ConferenceBridgeProperties(1)), version = OLD_VERSION) shouldBe null + // Honor max-participants-per-bridge, even though all new bridges are in drain. + selector.testSelect(mapOf(new1 to ConferenceBridgeProperties(MAX_BP))) shouldBe new3 + selector.testSelect( + mapOf( + new1 to ConferenceBridgeProperties(MAX_BP), + new2 to ConferenceBridgeProperties(1) + ) + ) shouldBe new2 + selector.testSelect( + mapOf( + new1 to ConferenceBridgeProperties(MAX_BP), + new2 to ConferenceBridgeProperties(MAX_BP) + ) + ) shouldBe new3 + selector.testSelect( + mapOf( + new1 to ConferenceBridgeProperties(MAX_BP), + new2 to ConferenceBridgeProperties(MAX_BP), + new3 to ConferenceBridgeProperties(MAX_BP) + ) + ) shouldBe new3 + + // Everything should work the same unless a conference is pinned to the new version. + // Select the least loaded bridge + selector.testSelect() shouldBe old1 + selector.testSelect(version = OLD_VERSION) shouldBe old1 + selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(1))) shouldBe old1 + // Select an existing conference bridge + selector.testSelect(mapOf(old2 to ConferenceBridgeProperties(1))) shouldBe old2 + selector.testSelect(mapOf(old3 to ConferenceBridgeProperties(1))) shouldBe old3 + selector.testSelect( + mapOf( + old2 to ConferenceBridgeProperties(2), + old3 to ConferenceBridgeProperties(1) + ) + ) shouldBe old2 + // Fail if the version doesn't match + selector.testSelect(version = "invalid-version") shouldBe null + // Fail with inconsistent version pinning + selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(1)), version = NEW_VERSION) shouldBe null + // Honor max-participants-per-bridge + selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(MAX_BP))) shouldBe old2 + selector.testSelect( + mapOf( + old1 to ConferenceBridgeProperties(MAX_BP), + old3 to ConferenceBridgeProperties(1) + ) + ) shouldBe old3 + selector.testSelect( + mapOf( + old1 to ConferenceBridgeProperties(MAX_BP), + old3 to ConferenceBridgeProperties(MAX_BP) + ) + ) shouldBe old2 + selector.testSelect( + mapOf( + old1 to ConferenceBridgeProperties(MAX_BP), + old2 to ConferenceBridgeProperties(MAX_BP), + old3 to ConferenceBridgeProperties(MAX_BP) + ) + ) shouldBe old1 + + // Switch the releases + setOf(old1, old2, old3).forEach { it.setStats(drain = true) } + setOf(new1, new2, new3).forEach { it.setStats(drain = false) } + + // Select the new version for new conferences + old1.setStats(stress = 0.0, drain = true) + // old1 should not be selected because it is in drain + selector.testSelect() shouldBe new3 + selector.testSelect(mapOf(new3 to ConferenceBridgeProperties(MAX_BP))) shouldBe new2 + // Select the old version for existing conferences or pinned + selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(1))) shouldBe old1 + // Should select old2 even though all old bridges are in drain. + selector.testSelect(mapOf(old1 to ConferenceBridgeProperties(MAX_BP))) shouldBe old2 + selector.testSelect(version = OLD_VERSION) shouldBe old1 + } + } +} + +private fun BridgeSelector.createBridge(jid: String, version: String, stress: Double, drain: Boolean = false) = + addJvbAddress(JidCreate.from(jid)).apply { + setStats( + version = version, + stress = stress, + drain = drain, + region = "region", + relayId = jid + ) + } + +private const val MAX_BP = 80 +private const val OLD_VERSION = "old" +private const val NEW_VERSION = "new" + +private fun BridgeSelector.testSelect( + conferenceBridges: Map = emptyMap(), + version: String? = null +) = selectBridge(conferenceBridges, ParticipantProperties(region = "region"), version = version) diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeSelectionStrategyTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeSelectionStrategyTest.kt new file mode 100644 index 0000000..e6b591b --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeSelectionStrategyTest.kt @@ -0,0 +1,176 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.config.withNewConfig +import org.jxmpp.jid.impl.JidCreate + +class BridgeSelectionStrategyTest : ShouldSpec() { + init { + val localRegion = "local-region" + val strategy: RegionBasedBridgeSelectionStrategy = createWithNewConfig("jicofo.local-region=$localRegion") { + RegionBasedBridgeSelectionStrategy() + } + + context("testRegionBasedSelection") { + val region1 = "region1" + val region2 = localRegion + val region3 = "region3" + val bridge1 = Bridge(JidCreate.from("bridge1")).apply { setStats(region = region1) } + val bridge2 = Bridge(JidCreate.from("bridge2")).apply { setStats(region = region2) } + val bridge3 = Bridge(JidCreate.from("bridge3")).apply { setStats(region = region3) } + val localBridge = bridge2 + + val props1 = ParticipantProperties(region1) + val props2 = ParticipantProperties(region2) + val props3 = ParticipantProperties(region3) + + val propsInvalid = ParticipantProperties("invalid region") + val propsNull = ParticipantProperties(null) + + val allBridges = listOf(bridge1, bridge2, bridge3) + val conferenceBridges: MutableMap = HashMap() + + // Initial selection should select a bridge in the participant's region/ if possible + strategy.select(allBridges, conferenceBridges, props1, true) shouldBe bridge1 + strategy.select(allBridges, conferenceBridges, props2, true) shouldBe bridge2 + + // Or a bridge in the local region otherwise + strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe localBridge + strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe localBridge + + conferenceBridges[bridge3] = ConferenceBridgeProperties(1) + strategy.select(allBridges, conferenceBridges, props3, true) shouldBe bridge3 + strategy.select(allBridges, conferenceBridges, props2, true) shouldBe bridge2 + // A participant in an unknown region should be allocated on a local bridge. + strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe localBridge + + conferenceBridges[bridge2] = ConferenceBridgeProperties(1) + // A participant in an unknown region should be allocated on the least loaded (according to the order of + // 'allBridges') existing conference bridge. + strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe bridge2 + + // A participant in a region with no bridges should also be allocated on the least loaded (according to the + // order of 'allBridges') existing conference bridge. + strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe bridge2 + } + context("Port of BridgeSelectionStrategyTest.java#preferLowestStress") { + val lowStressRegion = "lowStressRegion" + val mediumStressRegion = "mediumStressRegion" + val highStressRegion = "highStressRegion" + + val lowStressBridge = createBridge(lowStressRegion, 0.1) + val mediumStressBridge = createBridge(mediumStressRegion, 0.3) + val highStressBridge = createBridge(highStressRegion, 0.8) + val allBridges = listOf(lowStressBridge, mediumStressBridge, highStressBridge) + + val lowStressProps = ParticipantProperties(lowStressRegion) + val mediumStressProps = ParticipantProperties(mediumStressRegion) + val highStressProps = ParticipantProperties(highStressRegion) + + val propsInvalid = ParticipantProperties("invalid region") + val propsNull = ParticipantProperties(null) + + val conferenceBridges = mutableMapOf() + // Initial selection should select a non-overloaded bridge in the participant's region if possible. If not, + // it should select the lowest loaded bridge. + strategy.select(allBridges, conferenceBridges, highStressProps, true) shouldBe lowStressBridge + strategy.select(allBridges, conferenceBridges, mediumStressProps, true) shouldBe mediumStressBridge + strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe lowStressBridge + + strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe lowStressBridge + + // Now assume that the low-stressed bridge is in the conference. + conferenceBridges[lowStressBridge] = ConferenceBridgeProperties(1) + strategy.select(allBridges, conferenceBridges, lowStressProps, true) shouldBe lowStressBridge + strategy.select(allBridges, conferenceBridges, mediumStressProps, true) shouldBe mediumStressBridge + // A participant in an unknown region should be allocated on the + // existing conference bridge. + strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe lowStressBridge + + // Now assume that a medium-stressed bridge is also in the conference. + conferenceBridges[mediumStressBridge] = ConferenceBridgeProperties(1) + // A participant in an unknown region should be allocated on the least + // loaded (according to the order of 'allBridges') existing conference + // bridge. + strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe lowStressBridge + // A participant in a region with no bridges should also be allocated + // on the least loaded (according to the order of 'allBridges') existing + // conference bridge. + strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe lowStressBridge + } + context("Port of BridgeSelectionStrategyTest.java#preferRegionWhenStressIsEqual") { + // Here we specify 3 bridges in 3 different regions: one high-stressed and two medium-stressed. + val mediumStressRegion1 = "mediumStressRegion1" + val mediumStressRegion2 = "mediumStressRegion2" + val highStressRegion = "highStressRegion" + + val mediumStressBridge1 = createBridge(mediumStressRegion1, 0.25) + val mediumStressBridge2 = createBridge(mediumStressRegion2, 0.3) + val highStressBridge = createBridge(highStressRegion, 0.8) + val allBridges = listOf(mediumStressBridge1, mediumStressBridge2, highStressBridge) + + val mediumStressProps1 = ParticipantProperties(mediumStressRegion1) + val mediumStressProps2 = ParticipantProperties(mediumStressRegion2) + val highStressProps = ParticipantProperties(highStressRegion) + + val propsInvalid = ParticipantProperties("invalid region") + val propsNull = ParticipantProperties(null) + + val conferenceBridges = mutableMapOf() + + // Initial selection should select a non-overloaded bridge in the participant's region if possible. If not, + // it should select the lowest loaded bridge. + strategy.select(allBridges, conferenceBridges, highStressProps, true) shouldBe mediumStressBridge1 + strategy.select(allBridges, conferenceBridges, mediumStressProps2, true) shouldBe mediumStressBridge2 + strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe mediumStressBridge1 + strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe mediumStressBridge1 + + conferenceBridges[mediumStressBridge2] = ConferenceBridgeProperties(1) + strategy.select(allBridges, conferenceBridges, mediumStressProps1, true) shouldBe mediumStressBridge1 + strategy.select(allBridges, conferenceBridges, mediumStressProps2, true) shouldBe mediumStressBridge2 + // A participant in an unknown region should be allocated on the existing conference bridge. + strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe mediumStressBridge2 + + // Now assume that a high-stressed bridge is in the conference. + conferenceBridges[highStressBridge] = ConferenceBridgeProperties(1) + // A participant in an unknown region should be allocated on the least + // loaded (according to the order of 'allBridges') existing conference + // bridge. + strategy.select(allBridges, conferenceBridges, propsNull, true) shouldBe mediumStressBridge2 + // A participant in a region with no bridges should also be allocated + // on the least loaded (according to the order of 'allBridges') existing + // conference bridge. + strategy.select(allBridges, conferenceBridges, propsInvalid, true) shouldBe mediumStressBridge2 + } + } +} + +private fun createBridge(region: String, stress: Double) = Bridge(JidCreate.from(region)).apply { + setStats(stress = stress, region = region) +} + +private fun createWithNewConfig(config: String, block: () -> T): T { + lateinit var ret: T + withNewConfig(config) { + ret = block() + } + return ret +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeSelectorTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeSelectorTest.kt new file mode 100644 index 0000000..340abb6 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeSelectorTest.kt @@ -0,0 +1,289 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldBeIn +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.util.context +import org.jitsi.metaconfig.MetaconfigSettings +import org.jitsi.utils.ms +import org.jitsi.utils.time.FakeClock +import org.jxmpp.jid.impl.JidCreate + +class BridgeSelectorTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + init { + val clock = FakeClock() + // Test different types of jid (domain, entity bare, entity full). + val jid1 = JidCreate.from("jvb1.example.com") + val jid2 = JidCreate.from("jvb2@example.com") + val jid3 = JidCreate.from("jvb3@example.com/goldengate") + + context("Stress from new endpoints") { + val bridgeSelector = BridgeSelector(clock) + val bridge = bridgeSelector.addJvbAddress(jid1).apply { setStats() } + bridge.correctedStress shouldBe 0 + bridgeSelector.selectBridge() + bridge.endpointAdded() + // The stress should increase because it was recently selected. + bridge.correctedStress shouldNotBe 0 + } + + context("Selection based on operational status") { + val bridgeSelector = BridgeSelector(clock) + val jvb1 = bridgeSelector.addJvbAddress(jid1).apply { setStats() } + val jvb2 = bridgeSelector.addJvbAddress(jid2).apply { setStats() } + val jvb3 = bridgeSelector.addJvbAddress(jid3).apply { setStats() } + + bridgeSelector.selectBridge() shouldBeIn setOf(jvb1, jvb2, jvb3) + + // Bridge 1 is down + jvb1.isOperational = false + bridgeSelector.selectBridge() shouldBeIn setOf(jvb2, jvb3) + + // Bridge 2 is down + jvb2.isOperational = false + bridgeSelector.selectBridge() shouldBe jvb3 + + // Bridge 1 is up again, but 3 is down instead + jvb1.isOperational = true + jvb3.isOperational = false + // We need to elapse time after setting isOperational=true because isOperational=false is sticky + clock.elapse(BridgeConfig.config.failureResetThreshold) + bridgeSelector.selectBridge() shouldBe jvb1 + } + context("Selection based on stress level") { + val bridgeSelector = BridgeSelector(clock) + val jvb1 = bridgeSelector.addJvbAddress(jid1).apply { setStats(stress = 0.1) } + val jvb2 = bridgeSelector.addJvbAddress(jid2).apply { setStats(stress = 0.23) } + val jvb3 = bridgeSelector.addJvbAddress(jid3).apply { setStats(stress = 0.0) } + + bridgeSelector.selectBridge() shouldBe jvb3 + + // Now Jvb 3 gets occupied the most + jvb3.setStats(stress = 0.3) + bridgeSelector.selectBridge() shouldBe jvb1 + + // Jvb 1 is gone + jvb1.isOperational = false + bridgeSelector.selectBridge() shouldBe jvb2 + + // All bridges down + jvb2.isOperational = false + jvb3.isOperational = false + bridgeSelector.selectBridge() shouldBe null + + jvb1.isOperational = true + jvb2.isOperational = true + jvb3.isOperational = true + // We need to elapse time after setting isOperational=true because isOperational=false is sticky + clock.elapse(BridgeConfig.config.failureResetThreshold) + + jvb1.setStats(stress = .01) + jvb2.setStats(stress = 0.0) + jvb3.setStats(stress = 0.0) + bridgeSelector.selectBridge() shouldBeIn setOf(jvb2, jvb3) + + // JVB 2 least occupied + jvb1.setStats(stress = .01) + jvb2.setStats(stress = 0.0) + jvb3.setStats(stress = .01) + // Reset recently added endpoints (100ms needed because of the bucket size). + clock.elapse(BridgeConfig.config.participantRampupInterval + 100.ms) + bridgeSelector.selectBridge() shouldBe jvb2 + } + context(config = regionBasedConfig, name = "Mixing versions") { + val bridgeSelector = BridgeSelector(clock) + val jvb1 = bridgeSelector.addJvbAddress(jid1).apply { setStats(version = "v1", stress = 0.9, region = "r") } + val jvb2 = bridgeSelector.addJvbAddress(jid2).apply { setStats(version = "v2", stress = 0.1, region = "r") } + + context("With explicit API call") { + bridgeSelector.selectBridge(version = "v1") shouldBe jvb1 + bridgeSelector.selectBridge(version = "v2") shouldBe jvb2 + bridgeSelector.selectBridge(version = "v-nonexistent") shouldBe null + } + context("From an existing conference bridge") { + bridgeSelector.selectBridge( + conferenceBridges = mapOf(jvb1 to ConferenceBridgeProperties(1)) + ) shouldBe jvb1 + val jvb3 = bridgeSelector.addJvbAddress(jid3).apply { + setStats(version = "v1", stress = 0.1, region = "r") + } + bridgeSelector.selectBridge( + conferenceBridges = mapOf( + jvb1 to ConferenceBridgeProperties(1) + ) + ) shouldBe jvb3 + } + } + context(config = regionBasedConfig, name = "Selection with a conference bridge removed from the selector") { + val regionBasedSelector = BridgeSelector(clock) + val jvb1 = regionBasedSelector.addJvbAddress(jid1).apply { setStats(stress = 0.2, region = "r1") } + val jvb2 = regionBasedSelector.addJvbAddress(jid2).apply { setStats(stress = 0.5, region = "r2") } + val jvb3 = regionBasedSelector.addJvbAddress(jid3).apply { setStats(stress = 0.1, region = "r3") } + + regionBasedSelector.removeJvbAddress(jid3) + + regionBasedSelector.selectBridge( + mapOf( + jvb1 to ConferenceBridgeProperties(1), + jvb2 to ConferenceBridgeProperties(1), + jvb3 to ConferenceBridgeProperties(1) + ), + ParticipantProperties() + ) shouldBe jvb1 + regionBasedSelector.selectBridge( + mapOf( + jvb1 to ConferenceBridgeProperties(1), + jvb2 to ConferenceBridgeProperties(1), + jvb3 to ConferenceBridgeProperties(1) + ), + ParticipantProperties("r2") + ) shouldBe jvb2 + } + context(config = splitConfig, name = "SplitBridgeSelectionStrategy") { + val splitSelector = BridgeSelector(clock) + val jvb1 = splitSelector.addJvbAddress(jid1).apply { setStats(stress = 0.2, region = "r1") } + val jvb2 = splitSelector.addJvbAddress(jid2).apply { setStats(stress = 0.5, region = "r2") } + val jvb3 = splitSelector.addJvbAddress(jid3).apply { setStats(stress = 0.1, region = "r3") } + + splitSelector.selectBridge() shouldBeIn setOf(jvb1, jvb2, jvb3) + splitSelector.selectBridge( + mapOf( + jvb1 to ConferenceBridgeProperties(1) + ), + ParticipantProperties() + ) shouldBeIn setOf(jvb2, jvb3) + splitSelector.selectBridge( + mapOf( + jvb1 to ConferenceBridgeProperties(1), + jvb2 to ConferenceBridgeProperties(1) + ), + ParticipantProperties() + ) shouldBe jvb3 + splitSelector.selectBridge( + mapOf( + jvb1 to ConferenceBridgeProperties(1), + jvb2 to ConferenceBridgeProperties(2), + jvb3 to ConferenceBridgeProperties(3) + ), + ParticipantProperties() + ) shouldBe jvb1 + + splitSelector.removeJvbAddress(jid1) + + splitSelector.selectBridge( + mapOf( + jvb1 to ConferenceBridgeProperties(1), + jvb2 to ConferenceBridgeProperties(2), + jvb3 to ConferenceBridgeProperties(3) + ), + ParticipantProperties() + ) shouldBe jvb2 + } + context(config = visitorConfig, name = "VisitorSelectionStrategy") { + val visitorSelector = BridgeSelector(clock) + val jvb1 = visitorSelector.addJvbAddress(jid1).apply { setStats(stress = 0.2, region = "r1") } + val jvb2 = visitorSelector.addJvbAddress(jid2).apply { setStats(stress = 0.2, region = "r1") } + val jvb3 = visitorSelector.addJvbAddress(jid3).apply { setStats(stress = 0.1, region = "r1") } + + val participantBridge = visitorSelector.selectBridge( + mapOf(), + ParticipantProperties(visitor = false) + ) + participantBridge shouldBeIn setOf(jvb1, jvb2, jvb3) + + val visitorBridge = visitorSelector.selectBridge( + mapOf( + participantBridge!! to ConferenceBridgeProperties(1) + ), + ParticipantProperties(visitor = true) + ) + visitorBridge shouldNotBe participantBridge + } + context("Lost bridges stats") { + val selector = BridgeSelector(clock) + // TODO use MetricsContainer.reset() instead + val initialLostBridges = BridgeSelector.lostBridges.get() + // BridgeSelector.lostBridges.get() shouldBe initialLostBridges + + val jvb1 = selector.addJvbAddress(jid1) + BridgeSelector.lostBridges.get() shouldBe initialLostBridges + + should("Increment the lost bridges stat when a bridge goes away") { + selector.removeJvbAddress(jvb1.jid) + BridgeSelector.lostBridges.get() shouldBe initialLostBridges + 1 + } + should("Not increment the lost bridges stat when a bridge in graceful-shutdown goes away") { + jvb1.setStats(gracefulShutdown = true) + selector.removeJvbAddress(jvb1.jid) + BridgeSelector.lostBridges.get() shouldBe initialLostBridges + } + } + xcontext("Performance") { + withNewConfig(regionGroupsConfig) { + // Config caching has a huge impact! + MetaconfigSettings.cacheEnabled = true + + val numBridges = 500 + val times = 1000 + + val selector = BridgeSelector(clock) + for (i in 1..numBridges) { + selector.addJvbAddress(JidCreate.from("jvb-$i")).apply { + // Force the worst-case in RegionBasedBridgeSelectionStrategy (the last "least loaded" branch). + setStats(stress = 0.99, region = "bridge-region") + } + } + + repeat(10) { + val start = System.currentTimeMillis() + for (i in 0..times) { + selector.selectBridge( + participantProperties = ParticipantProperties("participant-region-no-match") + ) + } + val end = System.currentTimeMillis() + val avgNs = (end - start) * 1_000_000.toDouble() / times + println("Took ${end - start} ms to select $times times (an average of $avgNs ns per selection)") + } + } + } + } +} + +private const val ENABLE_OCTO_CONFIG = "jicofo.octo.enabled=true" +val regionBasedConfig = """ + $ENABLE_OCTO_CONFIG + jicofo.bridge.selection-strategy=RegionBasedBridgeSelectionStrategy +""".trimIndent() +private val splitConfig = """ + $ENABLE_OCTO_CONFIG + jicofo.bridge.selection-strategy=SplitBridgeSelectionStrategy +""".trimIndent() +private val visitorConfig = """ + $ENABLE_OCTO_CONFIG + jicofo.bridge.selection-strategy=VisitorSelectionStrategy + jicofo.bridge.visitor-selection-strategy=SingleBridgeSelectionStrategy + jicofo.bridge.participant-selection-strategy=SingleBridgeSelectionStrategy +""".trimIndent() diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeTest.kt new file mode 100644 index 0000000..5331251 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright @ 2020 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.ints.shouldBeLessThan +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.jitsi.utils.time.FakeClock +import org.jitsi.utils.times +import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension +import org.jxmpp.jid.impl.JidCreate + +class BridgeTest : ShouldSpec({ + context("when comparing two bridges") { + should("the bridge that is operational should have higher priority (should compare lower)") { + val bridge1: Bridge = mockk { + every { isOperational } returns true + every { isInGracefulShutdown } returns false + every { correctedStress } returns 10.0 + } + + val bridge2: Bridge = mockk { + every { isOperational } returns false + every { isInGracefulShutdown } returns true + every { correctedStress } returns .1 + } + Bridge.compare(bridge1, bridge2) shouldBeLessThan 0 + } + + should("the bridge that is in graceful shutdown mode should have lower priority (should compare higher)") { + val bridge1: Bridge = mockk { + every { isOperational } returns true + every { isInGracefulShutdown } returns false + every { correctedStress } returns 10.0 + } + + val bridge2: Bridge = mockk { + every { isOperational } returns true + every { isInGracefulShutdown } returns true + every { correctedStress } returns .1 + } + Bridge.compare(bridge1, bridge2) shouldBeLessThan 0 + } + + should("the bridge that is stressed should have lower priority (should compare higher)") { + val bridge1: Bridge = mockk { + every { isOperational } returns true + every { isInGracefulShutdown } returns false + every { correctedStress } returns 10.0 + } + + val bridge2: Bridge = mockk { + every { isOperational } returns true + every { isInGracefulShutdown } returns false + every { correctedStress } returns .1 + } + Bridge.compare(bridge2, bridge1) shouldBeLessThan 0 + } + } + context("notOperationalThresholdTest") { + val clock = FakeClock() + val bridge = Bridge(JidCreate.from("bridge"), clock) + val failureResetThreshold = BridgeConfig.config.failureResetThreshold + bridge.isOperational shouldBe true + + bridge.isOperational = false + bridge.isOperational shouldBe false + + clock.elapse(failureResetThreshold.times(100)) + bridge.isOperational shouldBe false + + bridge.isOperational = true + bridge.isOperational shouldBe true + + bridge.isOperational = false + bridge.isOperational shouldBe false + + clock.elapse(failureResetThreshold.dividedBy(2)) + bridge.isOperational shouldBe false + + bridge.isOperational = true + bridge.isOperational shouldBe false + + clock.elapse(failureResetThreshold) + bridge.isOperational shouldBe true + } + context("Setting stats") { + // This mostly makes sure the test framework works as expected. + val bridge = Bridge(JidCreate.from("bridge")) + + bridge.correctedStress shouldBe 0 + bridge.region shouldBe null + + bridge.setStats(stress = 0.1) + bridge.correctedStress shouldBe 0.1 + bridge.region shouldBe null + + // The different stats should be updated independently. + bridge.setStats(region = "region") + bridge.correctedStress shouldBe 0.1 + bridge.region shouldBe "region" + + // The different stats should be updated independently. + bridge.setStats(stress = 0.2) + bridge.correctedStress shouldBe 0.2 + bridge.region shouldBe "region" + } +}) + +fun Bridge.setStats( + stress: Double? = null, + region: String? = null, + relayId: String? = region, + version: String? = null, + colibri2: Boolean = true, + gracefulShutdown: Boolean = false, + drain: Boolean = false +) = setStats( + ColibriStatsExtension().apply { + stress?.let { addStat(ColibriStatsExtension.Stat("stress_level", it)) } + region?.let { + addStat(ColibriStatsExtension.Stat(ColibriStatsExtension.REGION, it)) + } + relayId?.let { + addStat(ColibriStatsExtension.Stat(ColibriStatsExtension.RELAY_ID, it)) + } + version?.let { addStat(ColibriStatsExtension.Stat("version", version)) } + if (colibri2) addStat("colibri2", "true") + if (gracefulShutdown) addStat(ColibriStatsExtension.SHUTDOWN_IN_PROGRESS, "true") + addStat(ColibriStatsExtension.DRAIN, if (drain) "true" else "false") + } +) diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/CascadeTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/CascadeTest.kt new file mode 100644 index 0000000..4c8ba09 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/CascadeTest.kt @@ -0,0 +1,414 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jicofo.bridge + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe + +class TestCascade : Cascade { + override val sessions = HashMap() + + var linksRemoved = 0 + + override fun addLinkBetween(session: TestCascadeNode, otherSession: TestCascadeNode, meshId: String) { + require(!session.relays.contains(otherSession.relayId)) { + "$this already has a link to $otherSession" + } + require(!otherSession.relays.contains(session.relayId)) { + "$otherSession already has a link to $this" + } + session.addLink(otherSession, meshId) + otherSession.addLink(session, meshId) + } + + override fun removeLinkTo(session: TestCascadeNode, otherSession: TestCascadeNode) { + linksRemoved++ + } +} + +class TestCascadeNode(override val relayId: String) : CascadeNode { + override val relays = HashMap() + internal fun addLink(node: TestCascadeNode, meshId: String) { + relays[node.relayId] = TestCascadeLink(node.relayId, meshId) + } + + override fun toString(): String = "$relayId: [${relays.values.joinToString()}]" +} + +class TestCascadeLink(override val relayId: String, override val meshId: String?) : CascadeLink { + override fun toString(): String = "$meshId->$relayId" +} + +class CascadeTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val numNodes = 5 + init { + context("creating a cascade with a single mesh") { + val cascade = TestCascade() + val nodes = Array(numNodes) { i -> TestCascadeNode(i.toString()) } + nodes.iterator().forEach { cascade.addNodeToMesh(it, "A") } + + should("cause all the nodes to be connected") { + nodes.forEach { node -> + cascade.containsNode(node) shouldBe true + node.relays.size shouldBe numNodes - 1 + + nodes.forEach { other -> + if (other === node) { + node.relays.contains(other.relayId) shouldBe false + } else { + node.relays[other.relayId]?.relayId shouldBe other.relayId + } + } + } + } + should("validate") { + cascade.validate() + } + should("enumerate only one node behind each link") { + nodes.forEach { node -> + nodes.forEach { other -> + if (node != other) { + cascade.getNodesBehind(node, other).shouldContainExactlyInAnyOrder(other) + } + } + cascade.getNodesBehind("A", node).shouldContainExactlyInAnyOrder(node) + } + } + should("have paths directly from each node") { + nodes.forEach { node -> + var pathsVisited = 0 + cascade.getPathsFrom(node) { c, n, from -> + c shouldBe cascade + if (n == node) { + from shouldBe null + } else { + from shouldBe node + } + pathsVisited++ + } + pathsVisited shouldBe numNodes + } + } + should("have the correct distance from each node to another") { + nodes.forEach { first -> + nodes.forEach { other -> + cascade.getDistanceFrom(first) { it == other } shouldBe if (first == other) 0 else 1 + } + } + cascade.getDistanceFrom(nodes[1]) { false } shouldBe Int.MAX_VALUE + } + should("not call a callback when removing any node") { + nodes.forEach { + var called = false + cascade.removeNode(it) { _, _ -> + called = true + setOf() + } + called shouldBe false + } + cascade.validate() + cascade.linksRemoved shouldBe 4 + 3 + 2 + 1 + } + } + context("creating a cascade with two meshes") { + val cascade = TestCascade() + val nodes = Array(numNodes) { i -> TestCascadeNode(i.toString()) } + cascade.addNodeToMesh(nodes[0], "A") + cascade.addNodeToMesh(nodes[1], "A") + cascade.addNodeToMesh(nodes[2], "A") + + cascade.addNodeToMesh(nodes[3], "B", nodes[0]) + cascade.addNodeToMesh(nodes[4], "B") + + should("create the correct links for each node") { + val aNodes = cascade.getMeshNodes("A") + val bNodes = cascade.getMeshNodes("B") + + aNodes.map { it.relayId }.shouldContainExactly("0", "1", "2") + bNodes.map { it.relayId }.shouldContainExactly("0", "3", "4") + + nodes[0].relays.size shouldBe 4 + for (i in 1 until numNodes) { + nodes[i].relays.size shouldBe 2 + } + + arrayOf(aNodes, bNodes).forEach { set -> + set.forEach { node -> + set.forEach { other -> + if (other === node) { + node.relays.contains(other.relayId) shouldBe false + } else { + node.relays[other.relayId]?.relayId shouldBe other.relayId + } + } + } + } + } + should("validate") { + cascade.validate() + } + should("enumerate only one node behind each leaf node from the core node") { + for (i in 1 until numNodes) { + cascade.getNodesBehind(nodes[0], nodes[i]).shouldContainExactlyInAnyOrder(nodes[i]) + } + } + should("enumerate the nodes of the far mesh behind the core node from each leaf node") { + cascade.getNodesBehind(nodes[1], nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[3], nodes[4]) + cascade.getNodesBehind(nodes[2], nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[3], nodes[4]) + cascade.getNodesBehind(nodes[3], nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[2]) + cascade.getNodesBehind(nodes[4], nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[2]) + + cascade.getNodesBehind("A", nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[3], nodes[4]) + cascade.getNodesBehind("B", nodes[0]).shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[2]) + } + should("have paths directly from the core node") { + var pathsVisited = 0 + cascade.getPathsFrom(nodes[0]) { c, n, from -> + c shouldBe cascade + if (n == nodes[0]) { + from shouldBe null + } else { + from shouldBe nodes[0] + } + pathsVisited++ + } + pathsVisited shouldBe numNodes + } + should("have non-local paths from other nodes through the core node") { + for (i in 1 until numNodes) { + val locals = when (i) { + 1, 2 -> setOf(nodes[0], nodes[1], nodes[2]) + 3, 4 -> setOf(nodes[0], nodes[3], nodes[4]) + else -> setOf() + } + var pathsVisited = 0 + cascade.getPathsFrom(nodes[i]) { c, n, from -> + c shouldBe cascade + when { + n == nodes[i] -> from shouldBe null + locals.contains(n) -> from shouldBe nodes[i] + else -> from shouldBe nodes[0] + } + pathsVisited++ + } + pathsVisited shouldBe numNodes + } + } + should("have the correct distance from each node to another") { + for (i in 1 until numNodes) { + cascade.getDistanceFrom(nodes[i]) { it == nodes[0] } shouldBe 1 + cascade.getDistanceFrom(nodes[0]) { it == nodes[i] } shouldBe 1 + } + // Just spot-check these rather than enumerating all of them + cascade.getDistanceFrom(nodes[1]) { it == nodes[2] } shouldBe 1 + cascade.getDistanceFrom(nodes[1]) { it == nodes[3] } shouldBe 2 + cascade.getDistanceFrom(nodes[1]) { false } shouldBe Int.MAX_VALUE + } + should("not call a callback when removing a leaf node") { + for (i in 1 until numNodes) { + var called = false + cascade.removeNode(nodes[i]) { _, _ -> + called = true + setOf() + } + called shouldBe false + } + cascade.validate() + cascade.linksRemoved shouldBe 3 + 2 + 1 + } + should("call the callback when removing the core node") { + var called = true + cascade.removeNode(nodes[0]) { _, disconnectedMeshes -> + called = true + disconnectedMeshes.size shouldBe 2 + disconnectedMeshes.shouldContainExactlyInAnyOrder( + setOf(nodes[1], nodes[2]), + setOf(nodes[3], nodes[4]) + ) + setOf() + } + called shouldBe true + } + should("fail validation if not repaired after removing the core node") { + cascade.removeNode(nodes[0]) { _, _ -> setOf() } + shouldThrow { + cascade.validate() + } + } + should("validate if repaired correctly (1) after removing the core node") { + cascade.removeNode(nodes[0]) { _, _ -> + setOf(CascadeRepair(nodes[1], nodes[3], "C")) + } + cascade.validate() + } + should("validate if repaired correctly (2) after removing the core node") { + cascade.removeNode(nodes[0]) { _, _ -> + setOf( + CascadeRepair(nodes[1], nodes[3], "A"), + CascadeRepair(nodes[2], nodes[3], "A") + ) + } + cascade.validate() + } + should("not validate if repaired incorrectly (1) after removing the core node") { + cascade.removeNode(nodes[0]) { _, _ -> + setOf(CascadeRepair(nodes[1], nodes[3], "A")) + } + shouldThrow { + cascade.validate() + } + } + should("not validate if repaired incorrectly (2) after removing the core node") { + cascade.removeNode(nodes[0]) { _, _ -> + setOf( + CascadeRepair(nodes[1], nodes[3], "C"), + CascadeRepair(nodes[2], nodes[4], "D") + ) + } + shouldThrow { + cascade.validate() + } + } + } + + context("creating a star topology") { + val cascade = TestCascade() + val nodes = Array(numNodes) { i -> TestCascadeNode(i.toString()) } + + cascade.addNodeToMesh(nodes[0], "A") + for (i in 1 until numNodes) { + cascade.addNodeToMesh(nodes[i], ('A'.code + i).toChar().toString(), nodes[0]) + } + + should("create the correct links for each node") { + cascade.getMeshNodes("A").size shouldBe 0 + for (m in 'B'..'E') { + cascade.getMeshNodes(m.toString()).size shouldBe 2 + } + nodes[0].relays.size shouldBe 4 + for (i in 1 until numNodes) { + nodes[i].relays.size shouldBe 1 + } + } + should("validate") { + cascade.validate() + } + should("enumerate only one node behind each leaf node from the core node") { + for (i in 1 until numNodes) { + cascade.getNodesBehind(nodes[0], nodes[i]).shouldContainExactlyInAnyOrder(nodes[i]) + } + } + should("enumerate all the other nodes behind the core node from each leaf node") { + cascade.getNodesBehind(nodes[1], nodes[0]) + .shouldContainExactlyInAnyOrder(nodes[0], nodes[2], nodes[3], nodes[4]) + cascade.getNodesBehind(nodes[2], nodes[0]) + .shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[3], nodes[4]) + cascade.getNodesBehind(nodes[3], nodes[0]) + .shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[2], nodes[4]) + cascade.getNodesBehind(nodes[4], nodes[0]) + .shouldContainExactlyInAnyOrder(nodes[0], nodes[1], nodes[2], nodes[3]) + } + should("have paths directly from the core node") { + var pathsVisited = 0 + cascade.getPathsFrom(nodes[0]) { c, n, from -> + c shouldBe cascade + if (n == nodes[0]) { + from shouldBe null + } else { + from shouldBe nodes[0] + } + pathsVisited++ + } + pathsVisited shouldBe numNodes + } + should("have paths from other nodes through the core node") { + for (i in 1 until numNodes) { + var pathsVisited = 0 + cascade.getPathsFrom(nodes[i]) { c, n, from -> + c shouldBe cascade + when (n) { + nodes[i] -> from shouldBe null + nodes[0] -> from shouldBe nodes[i] + else -> from shouldBe nodes[0] + } + pathsVisited++ + } + pathsVisited shouldBe numNodes + } + } + should("have the correct distance from each node to another") { + for (i in 1 until numNodes) { + cascade.getDistanceFrom(nodes[i]) { it == nodes[0] } shouldBe 1 + cascade.getDistanceFrom(nodes[0]) { it == nodes[i] } shouldBe 1 + for (j in 1 until numNodes) { + cascade.getDistanceFrom(nodes[i]) { it == nodes[j] } shouldBe if (i == j) 0 else 2 + } + cascade.getDistanceFrom(nodes[i]) { false } shouldBe Int.MAX_VALUE + } + } + should("not call a callback when removing a leaf node") { + for (i in 1 until numNodes) { + var called = false + cascade.removeNode(nodes[i]) { _, _ -> + called = true + setOf() + } + called shouldBe false + } + } + should("call the callback when removing the core node") { + var called = false + cascade.removeNode(nodes[0]) { _, disconnectedMeshes -> + called = true + disconnectedMeshes.size shouldBe 4 + disconnectedMeshes.shouldContainExactlyInAnyOrder( + setOf(nodes[1]), + setOf(nodes[2]), + setOf(nodes[3]), + setOf(nodes[4]) + ) + setOf() + } + called shouldBe true + } + should("fail validation if not repaired after removing the core node") { + cascade.removeNode(nodes[0]) { _, _ -> setOf() } + shouldThrow { + cascade.validate() + } + } + should("validate when repaired correctly after removing the core node") { + cascade.removeNode(nodes[0]) { _, _ -> + setOf( + CascadeRepair(nodes[1], nodes[2], "B"), + CascadeRepair(nodes[1], nodes[3], "C"), + CascadeRepair(nodes[1], nodes[4], "D") + ) + } + cascade.validate() + } + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/MaxParticipantsPerIntervalTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/MaxParticipantsPerIntervalTest.kt new file mode 100644 index 0000000..d0314e2 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/MaxParticipantsPerIntervalTest.kt @@ -0,0 +1,148 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.config.withNewConfig +import org.jxmpp.jid.impl.JidCreate + +/** + * Tests the `max-bridge-participants-per-interval` limit, i.e. a bridge being considered overloaded for a specific + * conference because that conference recently added too many endpoints to it. + */ +class MaxParticipantsPerIntervalTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private fun createBridge(name: String, stress: Double) = Bridge(JidCreate.from(name)).apply { + setStats(stress = stress, region = REGION) + } + private val bridge1 = createBridge("bridge1", 0.0) + private val bridge2 = createBridge("bridge2", 0.1) + private val bridges = listOf(bridge1, bridge2) + private val participant = ParticipantProperties(REGION) + + /** The conference has [recentlyAdded] endpoints on [bridge1] and none on [bridge2] (which it doesn't use). */ + private fun onlyBridge1(recentlyAdded: Long) = mapOf(bridge1 to conferenceBridge(recentlyAdded)) + + private fun conferenceBridge(recentlyAdded: Long, participantCount: Int = 1) = + ConferenceBridgeProperties(participantCount, false, recentlyAdded) + + init { + context("With the limit disabled (the default)") { + BridgeConfig.config.maxBridgeParticipantsPerInterval shouldBe -1 + + with(RegionBasedBridgeSelectionStrategy()) { + should("not affect selection regardless of the number of recently added endpoints") { + select(bridges, onlyBridge1(1000), participant, true) shouldBe bridge1 + } + } + } + context("With the limit enabled") { + withNewConfig("$MAX_PER_INTERVAL_CONFIG=$MAX_PER_INTERVAL") { + with(RegionBasedBridgeSelectionStrategy()) { + should("keep using the bridge below the limit") { + select(bridges, onlyBridge1(MAX_PER_INTERVAL - 1L), participant, true) shouldBe bridge1 + } + should("select another bridge once the limit is reached") { + select(bridges, onlyBridge1(MAX_PER_INTERVAL.toLong()), participant, true) shouldBe bridge2 + select(bridges, onlyBridge1(MAX_PER_INTERVAL + 100L), participant, true) shouldBe bridge2 + } + should("prefer another bridge that the conference already uses") { + val bridge3 = createBridge("bridge3", 0.2) + select( + bridges + bridge3, + mapOf( + bridge1 to conferenceBridge(MAX_PER_INTERVAL.toLong()), + bridge3 to conferenceBridge(0) + ), + participant, + true + ) shouldBe bridge3 + } + should("fall through to the least loaded bridge when all bridges are over the limit") { + select( + bridges, + mapOf( + bridge1 to conferenceBridge(MAX_PER_INTERVAL.toLong()), + bridge2 to conferenceBridge(MAX_PER_INTERVAL.toLong()) + ), + participant, + true + ) shouldBe bridge1 + } + should("not affect the initial selection for a conference with no bridges") { + select(bridges, emptyMap(), participant, true) shouldBe bridge1 + } + } + context("With IntraRegionBridgeSelectionStrategy") { + with(IntraRegionBridgeSelectionStrategy()) { + should("keep using the bridge below the limit") { + select(bridges, onlyBridge1(MAX_PER_INTERVAL - 1L), participant, true) shouldBe bridge1 + } + should("select another bridge once the limit is reached") { + select(bridges, onlyBridge1(MAX_PER_INTERVAL.toLong()), participant, true) shouldBe bridge2 + } + } + } + context("With all bridges overloaded") { + // The last-resort tier (leastLoadedNotMaxedAlreadyInConference) intentionally does not honor the + // rate limit: it exists to return *some* bridge rather than fail the allocation. + val overloaded1 = createBridge("overloaded1", 0.9) + val overloaded2 = createBridge("overloaded2", 0.95) + overloaded1.isOverloaded shouldBe true + + with(RegionBasedBridgeSelectionStrategy()) { + should("still select a bridge that the conference already uses") { + select( + listOf(overloaded1, overloaded2), + mapOf( + overloaded1 to conferenceBridge(MAX_PER_INTERVAL.toLong()), + overloaded2 to conferenceBridge(MAX_PER_INTERVAL.toLong()) + ), + participant, + true + ) shouldBe overloaded1 + } + } + } + context("The bridge-level state") { + should("not be affected") { + // The limit is specific to a conference. Nothing about the bridge itself changes, so other + // conferences (and load redistribution, sorting, etc) are unaffected. + val conferenceBridges = onlyBridge1(MAX_PER_INTERVAL + 100L) + with(RegionBasedBridgeSelectionStrategy()) { + select(bridges, conferenceBridges, participant, true) shouldBe bridge2 + } + + bridge1.isOverloaded shouldBe false + bridge1.correctedStress shouldBe 0.0 + + // A different conference, which has not added anything to bridge1, still gets bridge1. + with(RegionBasedBridgeSelectionStrategy()) { + select(bridges, mapOf(bridge1 to conferenceBridge(0)), participant, true) shouldBe bridge1 + } + } + } + } + } + } +} + +private const val REGION = "region" +private const val MAX_PER_INTERVAL = 10 +private const val MAX_PER_INTERVAL_CONFIG = "jicofo.bridge.max-bridge-participants-per-interval" diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/RegionBasedSelectionTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/RegionBasedSelectionTest.kt new file mode 100644 index 0000000..fda557c --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/RegionBasedSelectionTest.kt @@ -0,0 +1,216 @@ +/* + * Copyright @ 2022 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.bridge.Regions.ApSouth +import org.jitsi.jicofo.bridge.Regions.EuCentral +import org.jitsi.jicofo.bridge.Regions.EuWest +import org.jitsi.jicofo.bridge.Regions.UsEast +import org.jitsi.jicofo.bridge.Regions.UsWest +import org.jitsi.jicofo.bridge.StressLevels.High +import org.jitsi.jicofo.bridge.StressLevels.Low +import org.jitsi.jicofo.bridge.StressLevels.Medium + +class RegionBasedSelectionTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + private val bridges = createBridges() + private val bridgesList = bridges.values.flatMap { it.values } + private fun BridgeSelectionStrategy.select( + bridges: List = bridgesList, + conferenceBridges: Map = emptyMap(), + participantRegion: Regions? = null + ) = select(bridges, conferenceBridges, ParticipantProperties(participantRegion?.region), true) + + init { + context("Without region groups") { + withNewConfig(MAX_BP_CONFIG) { + BridgeConfig.config.maxBridgeParticipants shouldBe MAX_BP + + with(RegionBasedBridgeSelectionStrategy()) { + context("In a single region") { + select()!!.correctedStress shouldBe Low.stress + select(participantRegion = ApSouth) shouldBe bridges[ApSouth][Low] + select( + participantRegion = ApSouth, + conferenceBridges = mapOf(bridges[ApSouth][Medium] to ConferenceBridgeProperties(1)) + ) shouldBe bridges[ApSouth][Medium] + select( + participantRegion = ApSouth, + conferenceBridges = mapOf(bridges[ApSouth][Medium] to ConferenceBridgeProperties(MAX_BP)) + ) shouldBe bridges[ApSouth][Low] + select( + participantRegion = ApSouth, + conferenceBridges = mapOf(bridges[ApSouth][Low] to ConferenceBridgeProperties(MAX_BP)) + ) shouldBe bridges[ApSouth][Medium] + select( + participantRegion = ApSouth, + conferenceBridges = mapOf( + bridges[ApSouth][Low] to ConferenceBridgeProperties(2), + bridges[ApSouth][Medium] to ConferenceBridgeProperties(1) + ) + ) shouldBe bridges[ApSouth][Low] + select( + conferenceBridges = mapOf( + bridges[ApSouth][Low] to ConferenceBridgeProperties(2), + bridges[ApSouth][Medium] to ConferenceBridgeProperties(1) + ) + ) shouldBe bridges[ApSouth][Low] + } + context("In multiple regions") { + select( + conferenceBridges = mapOf( + bridges[ApSouth][Low] to ConferenceBridgeProperties(2), + bridges[EuWest][Medium] to ConferenceBridgeProperties(1), + bridges[EuWest][High] to ConferenceBridgeProperties(1) + ) + ) shouldBe bridges[ApSouth][Low] + select( + participantRegion = EuWest, + conferenceBridges = mapOf( + bridges[ApSouth][Low] to ConferenceBridgeProperties(2), + bridges[EuWest][Medium] to ConferenceBridgeProperties(1), + bridges[EuWest][High] to ConferenceBridgeProperties(1) + ) + ) shouldBe bridges[EuWest][Medium] + select( + participantRegion = EuCentral, + conferenceBridges = mapOf( + bridges[ApSouth][Low] to ConferenceBridgeProperties(2), + bridges[EuWest][Medium] to ConferenceBridgeProperties(1), + bridges[EuWest][High] to ConferenceBridgeProperties(1) + ) + ) shouldBe bridges[EuCentral][Low] + select( + participantRegion = EuCentral, + conferenceBridges = mapOf( + bridges[ApSouth][Low] to ConferenceBridgeProperties(2), + bridges[EuWest][Medium] to ConferenceBridgeProperties(1), + bridges[EuWest][High] to ConferenceBridgeProperties(1), + bridges[EuCentral][Low] to ConferenceBridgeProperties(MAX_BP) + ) + ) shouldBe bridges[EuCentral][Medium] + } + } + } + } + context("With region groups") { + withNewConfig(regionGroupsConfig) { + BridgeConfig.config.maxBridgeParticipants shouldBe MAX_BP + + with(RegionBasedBridgeSelectionStrategy()) { + select(participantRegion = ApSouth) shouldBe bridges[ApSouth][Low] + select(participantRegion = EuWest) shouldBe bridges[EuWest][Low] + + // When there are no bridges available in the region, regions in the group should be preferred + select( + participantRegion = EuWest, + bridges = bridgesList.filterNot { it.region == EuWest.region } + ) shouldBe bridges[EuCentral][Low] + select( + participantRegion = EuWest, + bridges = bridgesList.filterNot { it.region == EuWest.region }, + conferenceBridges = mapOf( + bridges[UsEast][Low] to ConferenceBridgeProperties(1) + ) + ) shouldBe bridges[EuCentral][Low] + + select( + participantRegion = EuCentral, + conferenceBridges = mapOf( + bridges[ApSouth][Low] to ConferenceBridgeProperties(2), + bridges[EuWest][Medium] to ConferenceBridgeProperties(1), + bridges[EuWest][High] to ConferenceBridgeProperties(1), + bridges[EuCentral][Low] to ConferenceBridgeProperties(MAX_BP) + ) + ) shouldBe bridges[EuWest][Medium] + select( + participantRegion = UsEast, + conferenceBridges = mapOf( + bridges[ApSouth][Low] to ConferenceBridgeProperties(2), + bridges[EuWest][Medium] to ConferenceBridgeProperties(1), + bridges[EuWest][High] to ConferenceBridgeProperties(1), + bridges[EuCentral][Low] to ConferenceBridgeProperties(MAX_BP) + ) + ) shouldBe bridges[UsEast][Low] + context("Initial selection in the local region group, but not in the local region") { + select(participantRegion = UsWest, conferenceBridges = emptyMap()) shouldBe bridges[UsEast][Low] + } + } + } + } + } +} + +private const val MAX_BP = 10 +const val MAX_BP_CONFIG = "jicofo.bridge.max-bridge-participants=$MAX_BP" +val regionGroupsConfig = """ + jicofo.local-region = ${UsEast.region} + jicofo.bridge.selection-strategy=RegionBasedBridgeSelectionStrategy + jicofo.bridge.region-groups = [ + [ "${UsEast.region}", "${UsWest.region}" ], + [ "${EuCentral.region}", "${EuWest.region}" ], + ] + $MAX_BP_CONFIG +""".trimIndent() + +private fun mockBridge(r: Regions, s: StressLevels) = mockk { + every { region } returns r.region + every { correctedStress } returns s.stress + every { isOverloaded } returns (s == High) + every { lastReportedStressLevel } returns s.stress + every { relayId } returns "dummy" + every { this@mockk.toString() } returns "MockBridge[region=$region, stress=$correctedStress]" +} + +// Create a Low, Medium and High stress bridge in each region. +private fun createBridges() = RegionToBridgesMap(mutableMapOf()).apply { + Regions.values().forEach { r -> + put(r, StressToBridgeMap(StressLevels.values().associateWith { mockBridge(r, it) })) + } +} + +// Hide the "!!" here for convenience. +private class RegionToBridgesMap( + private val m: MutableMap +) : MutableMap by m { + override fun get(key: Regions): StressToBridgeMap = m[key]!! +} + +// Hide the "!!" here for convenience. +private class StressToBridgeMap(private val m: Map) : Map by m { + override fun get(key: StressLevels): Bridge = m[key]!! +} + +private enum class Regions(val region: String) { + ApSouth("ap-south"), + EuCentral("eu-central"), + EuWest("eu-west"), + UsEast("us-east"), + UsWest("us-west") +} + +private enum class StressLevels(val stress: Double) { + Low(0.0), + Medium(0.3), + High(0.9) +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/UtilTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/UtilTest.kt new file mode 100644 index 0000000..a056dad --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/UtilTest.kt @@ -0,0 +1,41 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension + +class UtilTest : ShouldSpec() { + init { + context("ColibriStatsExtension.getDouble test") { + with(ColibriStatsExtension()) { + addStat("int", 5) + addStat("double", 5.0) + addStat("valid-string", "5") + addStat("invalid-string", "five") + + getDouble("int") shouldBe 5.toDouble() + getDouble("double") shouldBe 5.toDouble() + getDouble("valid-string") shouldBe 5.toDouble() + getDouble("invalid-string") shouldBe null + getDouble("non-existent-key") shouldBe null + } + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriTextTranslationTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriTextTranslationTest.kt new file mode 100644 index 0000000..de8b628 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriTextTranslationTest.kt @@ -0,0 +1,217 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.bridge.Bridge +import org.jitsi.jicofo.bridge.BridgeSelector +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.mock.MockXmppConnection +import org.jitsi.jicofo.mock.TestColibri2Server +import org.jitsi.jicofo.mock.inPlaceExecutor +import org.jitsi.jicofo.mock.inPlaceScheduledExecutor +import org.jitsi.utils.TemplatedUrl +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ +import org.jitsi.xmpp.extensions.colibri2.Connect +import org.jivesoftware.smack.packet.IQ +import org.jxmpp.jid.impl.JidCreate + +/** + * Tests the colibri2 signaling of the text-translation target languages: they ride on the transcriber connect's + * `requests` list as bare language codes, so changing them updates the existing connect rather than reconnecting it. + */ +class ColibriTextTranslationTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val colibriRequests = mutableListOf() + private val colibri2Server = TestColibri2Server() + private val xmppConnection = object : MockXmppConnection() { + override fun handleIq(iq: IQ): IQ? { + if (iq is ConferenceModifyIQ) { + colibriRequests.add(iq) + return colibri2Server.handleConferenceModifyIq(iq) + } + return null + } + } + + private val bridge: Bridge = mockk(relaxed = true) { + every { jid } returns JidCreate.from("jvb@example.com/jvb1") + every { relayId } returns null + every { isOperational } returns true + every { debugState } returns JsonNodeFactory.instance.objectNode() + every { region } returns "us-east" + } + + private val bridgeSelector: BridgeSelector = mockk { + every { selectBridge(any(), any(), any()) } returns bridge + } + + private fun createSessionManager() = ColibriV2SessionManager( + xmppConnection.xmppConnection, + bridgeSelector, + "test-conference", + "test-meeting-id", + false, + null, + createLogger() + ) + + private fun allocateParticipant(manager: ColibriV2SessionManager, id: String) = manager.allocate( + ParticipantAllocationParameters( + id = id, + statsId = null, + region = null, + sources = EndpointSourceSet.EMPTY, + useSsrcRewriting = false, + useRtpMidDemux = false, + forceMuteAudio = false, + forceMuteVideo = false, + useSctp = false, + visitor = false, + supportsPrivateAddresses = false, + diarize = false, + medias = emptySet() + ) + ) + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.ioPool = inPlaceExecutor + TaskPools.scheduledPool = inPlaceScheduledExecutor + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetIoPool() + TaskPools.resetScheduledPool() + } + + init { + val transcriberUrl = TemplatedUrl( + "wss://{{REGION}}.transcribe.example.com/transcribe", + requiredKeys = setOf("REGION") + ) + + fun lastTranscriberConnect(): Connect? = colibriRequests + .lastOrNull { iq -> iq.connects?.getConnects()?.any { it.type == Connect.Types.TRANSCRIBER } == true } + ?.connects?.getConnects()?.first { it.type == Connect.Types.TRANSCRIBER } + + context("Requesting languages after the transcriber is connected") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + manager.setTranscriberUrl(transcriberUrl) + colibriRequests.clear() + manager.setTextTranslationLanguages(setOf("fr", "de")) + val connect = lastTranscriberConnect() + + should("update the existing transcriber connect rather than create a new one") { + connect shouldNotBe null + connect!!.id shouldBe "transcriber" + connect.create shouldBe false + connect.expire shouldBe false + } + should("carry the languages as the connect requests") { + connect!!.getRequests() shouldBe listOf("de", "fr") + } + should("not change the url, so the bridge keeps the same websocket") { + connect!!.url.toString() shouldBe "wss://us-east.transcribe.example.com/transcribe" + } + } + + context("Requesting languages before the transcriber is connected") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + manager.setTextTranslationLanguages(setOf("fr")) + colibriRequests.clear() + manager.setTranscriberUrl(transcriberUrl) + + should("apply them to the transcriber connect when it is created") { + val connect = lastTranscriberConnect() + connect shouldNotBe null + connect!!.create shouldBe true + connect.getRequests() shouldBe listOf("fr") + } + } + + context("Changing the requested languages") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + manager.setTranscriberUrl(transcriberUrl) + manager.setTextTranslationLanguages(setOf("fr")) + colibriRequests.clear() + manager.setTextTranslationLanguages(setOf("fr", "es")) + + should("re-signal the connect with the new set") { + lastTranscriberConnect()!!.getRequests() shouldBe listOf("es", "fr") + } + } + + context("Requesting the same languages again") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + manager.setTranscriberUrl(transcriberUrl) + manager.setTextTranslationLanguages(setOf("fr", "de")) + colibriRequests.clear() + + should("not signal anything when the set is unchanged") { + manager.setTextTranslationLanguages(setOf("fr", "de")) + lastTranscriberConnect() shouldBe null + } + should("not signal anything when only the order differs") { + manager.setTextTranslationLanguages(linkedSetOf("de", "fr")) + lastTranscriberConnect() shouldBe null + } + } + + context("Giving up the last language") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + manager.setTranscriberUrl(transcriberUrl) + manager.setTextTranslationLanguages(setOf("fr")) + colibriRequests.clear() + manager.setTextTranslationLanguages(emptySet()) + + should("signal an empty requests list, which stops translation") { + val connect = lastTranscriberConnect() + connect shouldNotBe null + connect!!.expire shouldBe false + connect.getRequests() shouldBe emptyList() + } + } + + context("Requesting languages with no transcriber at all") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + colibriRequests.clear() + manager.setTextTranslationLanguages(setOf("fr")) + + should("not signal a transcriber connect") { + lastTranscriberConnect() shouldBe null + } + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriTranscriptionTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriTranscriptionTest.kt new file mode 100644 index 0000000..7fc94c6 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriTranscriptionTest.kt @@ -0,0 +1,243 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.bridge.Bridge +import org.jitsi.jicofo.bridge.BridgeSelector +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.mock.MockXmppConnection +import org.jitsi.jicofo.mock.TestColibri2Server +import org.jitsi.jicofo.mock.inPlaceExecutor +import org.jitsi.jicofo.mock.inPlaceScheduledExecutor +import org.jitsi.utils.TemplatedUrl +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ +import org.jivesoftware.smack.packet.IQ +import org.jxmpp.jid.impl.JidCreate + +/** + * Tests that custom transcription HTTP headers and URL parameters are correctly included in Colibri2 requests, + * covering both the case where transcription is configured before the first session is created (bug fix) and + * after a session already exists. + */ +class ColibriTranscriptionTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val colibriRequests = mutableListOf() + private val colibri2Server = TestColibri2Server() + private val xmppConnection = object : MockXmppConnection() { + override fun handleIq(iq: IQ): IQ? { + if (iq is ConferenceModifyIQ) { + colibriRequests.add(iq) + return colibri2Server.handleConferenceModifyIq(iq) + } + return null + } + } + + private val bridge: Bridge = mockk(relaxed = true) { + every { jid } returns JidCreate.from("jvb@example.com/jvb1") + every { relayId } returns null + every { isOperational } returns true + every { debugState } returns JsonNodeFactory.instance.objectNode() + every { region } returns "us-east" + } + + private val bridgeSelector: BridgeSelector = mockk { + every { selectBridge(any(), any(), any()) } returns bridge + } + + private fun createSessionManager() = ColibriV2SessionManager( + xmppConnection.xmppConnection, + bridgeSelector, + "test-conference", + "test-meeting-id", + false, + null, + createLogger() + ) + + private fun allocateParticipant(manager: ColibriV2SessionManager, id: String = "p1", diarize: Boolean = false) = + manager.allocate( + ParticipantAllocationParameters( + id = id, + statsId = null, + region = null, + sources = EndpointSourceSet.EMPTY, + useSsrcRewriting = false, + useRtpMidDemux = false, + forceMuteAudio = false, + forceMuteVideo = false, + useSctp = false, + visitor = false, + supportsPrivateAddresses = false, + diarize = diarize, + medias = emptySet() + ) + ) + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.ioPool = inPlaceExecutor + TaskPools.scheduledPool = inPlaceScheduledExecutor + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetIoPool() + TaskPools.resetScheduledPool() + } + + init { + // bridge.region = "us-east", so the URL becomes wss://us-east.transcriber.example.com/rec + val transcriberUrl = TemplatedUrl( + "wss://{{REGION}}.transcriber.example.com/rec", + requiredKeys = setOf("REGION") + ) + val customHeaders = mapOf("X-Custom-Header" to "custom-value", "Authorization" to "Bearer custom") + val urlParams = mapOf("key1" to "value1", "key2" to "hello world") + val expectedBaseUrl = "wss://us-east.transcriber.example.com/rec" + + context("Transcription configured before session exists") { + // This covers the bug fix: when setTranscriberUrl is called before any Colibri session exists, + // the custom headers and URL params must be included in the initial create request + // (via sendAllocationRequest → createRequest(create=true)). + val manager = createSessionManager() + manager.setTranscriberUrl(transcriberUrl, customHeaders, urlParams) + allocateParticipant(manager) + + val createRequest = colibriRequests.find { it.create } + createRequest shouldNotBe null + val connect = createRequest!!.connects?.getConnects()?.firstOrNull() + + should("include a Connect in the create request") { + connect shouldNotBe null + } + should("use custom HTTP headers") { + val headers = connect!!.getHttpHeaders().associate { it.name to it.value } + headers["X-Custom-Header"] shouldBe "custom-value" + headers["Authorization"] shouldBe "Bearer custom" + } + should("append URL params to the transcriber URL") { + val urlStr = connect!!.url.toString() + urlStr shouldContain "$expectedBaseUrl?" + urlStr shouldContain "key1=value1" + urlStr shouldContain "key2=hello+world" + } + } + + context("Transcription configured after session exists") { + // Verifies the working path: setTranscriberUrl called after a session exists sends the + // custom headers and URL params in the update request. + val manager = createSessionManager() + allocateParticipant(manager) + colibriRequests.clear() + manager.setTranscriberUrl(transcriberUrl, customHeaders, urlParams) + + val setUrlRequest = colibriRequests.firstOrNull() + setUrlRequest shouldNotBe null + val connect = setUrlRequest!!.connects?.getConnects()?.firstOrNull() + + should("include a Connect in the setTranscriberUrl request") { + connect shouldNotBe null + } + should("use custom HTTP headers") { + val headers = connect!!.getHttpHeaders().associate { it.name to it.value } + headers["X-Custom-Header"] shouldBe "custom-value" + headers["Authorization"] shouldBe "Bearer custom" + } + should("append URL params to the transcriber URL") { + val urlStr = connect!!.url.toString() + urlStr shouldContain "$expectedBaseUrl?" + urlStr shouldContain "key1=value1" + urlStr shouldContain "key2=hello+world" + } + } + + context("Diarize flag") { + context("Participant with diarize=true") { + val manager = createSessionManager() + allocateParticipant(manager, diarize = true) + + val createRequest = colibriRequests.find { it.create }!! + val endpoint = createRequest.endpoints.first() + + should("set the diarize attribute on the endpoint") { + endpoint.diarize shouldBe true + } + } + context("Participant with diarize=false") { + val manager = createSessionManager() + allocateParticipant(manager, diarize = false) + + val createRequest = colibriRequests.find { it.create }!! + val endpoint = createRequest.endpoints.first() + + should("not set the diarize attribute on the endpoint") { + // The attribute is omitted, so getDiarize() returns null. + endpoint.diarize shouldBe null + } + } + } + + context("Transcription with no custom headers or params") { + context("Configured before session exists") { + val manager = createSessionManager() + manager.setTranscriberUrl(transcriberUrl, null, null) + allocateParticipant(manager) + + val createRequest = colibriRequests.find { it.create }!! + val connect = createRequest.connects?.getConnects()?.firstOrNull() + + should("include a Connect") { connect shouldNotBe null } + should("use the base URL without query params") { + connect!!.url.toString() shouldBe expectedBaseUrl + } + should("include no HTTP headers") { + connect!!.getHttpHeaders() shouldBe emptyList() + } + } + context("Configured after session exists") { + val manager = createSessionManager() + allocateParticipant(manager) + colibriRequests.clear() + manager.setTranscriberUrl(transcriberUrl, null, null) + + val setUrlRequest = colibriRequests.firstOrNull()!! + val connect = setUrlRequest.connects?.getConnects()?.firstOrNull() + + should("include a Connect") { connect shouldNotBe null } + should("use the base URL without query params") { + connect!!.url.toString() shouldBe expectedBaseUrl + } + should("include no HTTP headers") { + connect!!.getHttpHeaders() shouldBe emptyList() + } + } + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriTranslationTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriTranslationTest.kt new file mode 100644 index 0000000..3c14479 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriTranslationTest.kt @@ -0,0 +1,217 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.bridge.Bridge +import org.jitsi.jicofo.bridge.BridgeSelector +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.mock.MockXmppConnection +import org.jitsi.jicofo.mock.TestColibri2Server +import org.jitsi.jicofo.mock.inPlaceExecutor +import org.jitsi.jicofo.mock.inPlaceScheduledExecutor +import org.jitsi.utils.TemplatedUrl +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ +import org.jitsi.xmpp.extensions.colibri2.Connect +import org.jivesoftware.smack.packet.IQ +import org.jxmpp.jid.impl.JidCreate + +/** + * Tests the colibri2 signaling for live translation in the default per-source mode: each sender's connect is placed on + * its (single, here) bridge, signaled as create/update/expire deltas, keyed by a per-source connect id. + */ +class ColibriTranslationTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val colibriRequests = mutableListOf() + private val colibri2Server = TestColibri2Server() + private val xmppConnection = object : MockXmppConnection() { + override fun handleIq(iq: IQ): IQ? { + if (iq is ConferenceModifyIQ) { + colibriRequests.add(iq) + return colibri2Server.handleConferenceModifyIq(iq) + } + return null + } + } + + private val bridge: Bridge = mockk(relaxed = true) { + every { jid } returns JidCreate.from("jvb@example.com/jvb1") + every { relayId } returns null + every { isOperational } returns true + every { debugState } returns JsonNodeFactory.instance.objectNode() + every { region } returns "us-east" + } + + private val bridgeSelector: BridgeSelector = mockk { + every { selectBridge(any(), any(), any()) } returns bridge + } + + private fun createSessionManager() = ColibriV2SessionManager( + xmppConnection.xmppConnection, + bridgeSelector, + "test-conference", + "test-meeting-id", + false, + null, + createLogger() + ) + + private fun allocateParticipant(manager: ColibriV2SessionManager, id: String) = manager.allocate( + ParticipantAllocationParameters( + id = id, + statsId = null, + region = null, + sources = EndpointSourceSet.EMPTY, + useSsrcRewriting = false, + useRtpMidDemux = false, + forceMuteAudio = false, + forceMuteVideo = false, + useSctp = false, + visitor = false, + supportsPrivateAddresses = false, + diarize = false, + medias = emptySet() + ) + ) + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.ioPool = inPlaceExecutor + TaskPools.scheduledPool = inPlaceScheduledExecutor + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetIoPool() + TaskPools.resetScheduledPool() + } + + init { + val translatorUrl = TemplatedUrl( + "wss://{{REGION}}.translate.example.com/t", + requiredKeys = setOf("REGION") + ) + val expectedUrl = "wss://us-east.translate.example.com/t" + + fun lastConnect() = colibriRequests.lastOrNull { it.connects != null }?.connects?.getConnects()?.firstOrNull() + + context("Enabling per-source translation for a sender") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + colibriRequests.clear() + manager.setTranslator( + translatorUrl, + listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en", "p1-a0.es"))) + ) + val connect = lastConnect() + + should("send a per-source translator connect with create") { + connect shouldNotBe null + connect!!.id shouldBe "translator-p1-a0-0" + connect.type shouldBe Connect.Types.TRANSLATOR + connect.create shouldBe true + connect.expire shouldBe false + } + should("export the sender's source and request its synthetic outputs") { + connect!!.getExports() shouldBe listOf("p1-a0") + connect.getRequests() shouldBe listOf("p1-a0.en", "p1-a0.es") + } + should("resolve the url for the bridge region") { + connect!!.url.toString() shouldBe expectedUrl + } + } + + context("Updating the requested languages") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + manager.setTranslator(translatorUrl, listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en")))) + colibriRequests.clear() + manager.setTranslator( + translatorUrl, + listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en", "p1-a0.es"))) + ) + val connect = lastConnect() + + should("re-signal the same connect as an update (not create)") { + connect shouldNotBe null + connect!!.id shouldBe "translator-p1-a0-0" + connect.create shouldBe false + connect.expire shouldBe false + connect.getRequests() shouldBe listOf("p1-a0.en", "p1-a0.es") + } + } + + context("Disabling translation") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + manager.setTranslator(translatorUrl, listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en")))) + colibriRequests.clear() + manager.setTranslator(null, emptyList()) + val connect = lastConnect() + + should("expire the connect") { + connect shouldNotBe null + connect!!.id shouldBe "translator-p1-a0-0" + connect.expire shouldBe true + } + } + + context("With per-room translator headers") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + colibriRequests.clear() + manager.setTranslator( + translatorUrl, + listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en"))), + customHeaders = mapOf("Authorization" to "Bearer per-room", "X-Custom" to "v1") + ) + val connect = lastConnect() + + should("put the per-room headers on the connect") { + connect shouldNotBe null + connect!!.getHttpHeaders().associate { it.name to it.value } shouldBe mapOf( + "Authorization" to "Bearer per-room", + "X-Custom" to "v1" + ) + } + } + + context("Without per-room translator headers") { + val manager = createSessionManager() + allocateParticipant(manager, "p1") + colibriRequests.clear() + // No customHeaders: fall back to the (empty, in this test) static config headers. + manager.setTranslator(translatorUrl, listOf(TranslationRequest("p1", "p1-a0", listOf("p1-a0.en")))) + val connect = lastConnect() + + should("fall back to the config headers (none configured here)") { + connect shouldNotBe null + connect!!.getHttpHeaders().associate { it.name to it.value } shouldBe emptyMap() + } + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriV2SessionManagerTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriV2SessionManagerTest.kt new file mode 100644 index 0000000..a814d37 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriV2SessionManagerTest.kt @@ -0,0 +1,462 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.bridge.Bridge +import org.jitsi.jicofo.bridge.BridgeConfig +import org.jitsi.jicofo.bridge.BridgeSelector +import org.jitsi.jicofo.bridge.ParticipantProperties +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.mock.MockXmppConnection +import org.jitsi.jicofo.mock.PendingExecutor +import org.jitsi.jicofo.mock.TestColibri2Server +import org.jitsi.jicofo.mock.inPlaceScheduledExecutor +import org.jitsi.utils.logging2.createLogger +import org.jitsi.utils.ms +import org.jitsi.utils.time.FakeClock +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ +import org.jitsi.xmpp.extensions.jingle.DtlsFingerprintPacketExtension +import org.jitsi.xmpp.extensions.jingle.IceCandidatePacketExtension +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension +import org.jivesoftware.smack.packet.IQ +import org.jxmpp.jid.Jid +import org.jxmpp.jid.impl.JidCreate + +private fun transportWithGeneration(generation: Int?) = IceUdpTransportPacketExtension().apply { + ufrag = "ufrag-$generation" + password = "password-$generation" + generation?.let { setIceGeneration(it) } +} + +/** A transport as a participant signals it: ICE credentials, with no `ice-generation` of its own. */ +private fun transportWithCredentials(ufrag: String) = IceUdpTransportPacketExtension().apply { + this.ufrag = ufrag + password = "password-$ufrag" +} + +/** A transport as a participant signals it when it trickles a candidate: no credentials. */ +private fun transportWithCandidateOnly() = IceUdpTransportPacketExtension().apply { + addCandidate(IceCandidatePacketExtension()) +} + +/** + * Tests [ColibriV2SessionManager] against [TestColibri2Server]s, including multi-bridge (Octo) conferences with + * relays. + */ +class ColibriV2SessionManagerTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + /** Requests jicofo sent, per bridge JID. */ + private val colibriRequests = mutableMapOf>() + + /** One colibri2 server per bridge JID, IQs are routed by their `to` address. */ + private val colibriServers = mutableMapOf() + private val xmppConnection = object : MockXmppConnection() { + override fun handleIq(iq: IQ): IQ? { + if (iq is ConferenceModifyIQ) { + colibriRequests.computeIfAbsent(iq.to) { mutableListOf() }.add(iq) + return colibriServers.computeIfAbsent(iq.to) { TestColibri2Server() }.handleConferenceModifyIq(iq) + } + return null + } + } + + private fun createBridge(name: String, bridgeRelayId: String? = name, bridgeRegion: String = "region-$name") = + mockk(relaxed = true) { + every { jid } returns JidCreate.from("jvbbrewery@example.com/$name") + every { relayId } returns bridgeRelayId + every { isOperational } returns true + every { debugState } returns JsonNodeFactory.instance.objectNode() + every { region } returns bridgeRegion + } + + private val bridge1 = createBridge("jvb1") + private val bridge2 = createBridge("jvb2") + + private val bridgeSelector: BridgeSelector = mockk { + every { selectBridge(any(), any(), any()) } answers { + // Select a bridge matching the participant's region, if any. + val region = secondArg().region + listOf(bridge1, bridge2).find { it.region == region } ?: bridge1 + } + } + + private val failedSessions = mutableListOf() + private val removedEndpoints = mutableListOf() + + /** The transports relayed to participants after an ICE restart, in the order they were fired. */ + private val iceRestartedTransports = mutableListOf>() + private val listener = object : ColibriSessionManager.Listener { + override fun bridgeCountChanged(bridgeCount: Int) {} + override fun bridgeRemoved(bridge: Bridge, participantIds: List) { + failedSessions.add(bridge) + } + override fun endpointRemoved(endpointId: String) { + removedEndpoints.add(endpointId) + } + override fun endpointIceRestarted(endpointId: String, transport: IceUdpTransportPacketExtension) { + iceRestartedTransports.add(endpointId to transport) + } + } + + /** + * "Async" tasks (response handling, event emission) are queued and executed by [drain], modeling the fact that + * in production they run on a separate thread after the initiating call has completed (and released its locks). + */ + private val ioExecutor = PendingExecutor() + + private val clock = FakeClock() + + // Initialized lazily so that construction happens after [beforeAny] has replaced the TaskPools executors. + private val sessionManager by lazy { + ColibriV2SessionManager( + xmppConnection.xmppConnection, + bridgeSelector, + "test-conference", + "test-meeting-id", + false, + null, + createLogger(), + clock + ).apply { addListener(listener) } + } + + private fun drain() = ioExecutor.runAll() + + private fun allocate(id: String, region: String? = null) = sessionManager.allocate( + ParticipantAllocationParameters( + id = id, + statsId = null, + region = region, + sources = EndpointSourceSet.EMPTY, + useSsrcRewriting = false, + useRtpMidDemux = false, + forceMuteAudio = false, + forceMuteVideo = false, + useSctp = false, + visitor = false, + supportsPrivateAddresses = false, + medias = emptySet(), + diarize = false + ) + ).also { drain() } + + private fun requestsTo(bridge: Bridge) = colibriRequests[bridge.jid] ?: emptyList() + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.ioPool = ioExecutor.executor + TaskPools.scheduledPool = inPlaceScheduledExecutor + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetIoPool() + TaskPools.resetScheduledPool() + } + + init { + context("Single bridge") { + val allocation = allocate("p1", region = "region-jvb1") + + should("allocate an endpoint on the bridge") { + allocation.shouldNotBeNull() + sessionManager.getBridges().keys shouldBe setOf(bridge1) + sessionManager.getParticipants(bridge1) shouldBe listOf("p1") + } + should("send a conference create request") { + requestsTo(bridge1).count { it.create } shouldBe 1 + } + should("not create any relays") { + requestsTo(bridge1).flatMap { it.relays }.shouldBeEmpty() + } + context("And removing the participant") { + sessionManager.removeParticipant("p1").also { drain() } + should("expire the conference on the bridge") { + // Removing the last participant expires the whole colibri2 conference. + requestsTo(bridge1).count { it.expire } shouldBe 1 + } + should("leave no bridges in the conference") { + sessionManager.getBridges().keys.shouldBeEmpty() + } + } + } + + context("Two bridges") { + withNewConfig("jicofo.octo.enabled=true") { + allocate("p1", region = "region-jvb1") + allocate("p2", region = "region-jvb2") + + should("allocate endpoints on both bridges") { + sessionManager.getBridges().keys shouldBe setOf(bridge1, bridge2) + sessionManager.getParticipants(bridge1) shouldBe listOf("p1") + sessionManager.getParticipants(bridge2) shouldBe listOf("p2") + } + should("create a relay on each bridge pointing to the other") { + val relaysOn1 = requestsTo(bridge1).flatMap { it.relays }.filter { it.create } + val relaysOn2 = requestsTo(bridge2).flatMap { it.relays }.filter { it.create } + relaysOn1.map { it.id } shouldBe listOf("jvb2") + relaysOn2.map { it.id } shouldBe listOf("jvb1") + } + should("exchange relay transports (one side active, one side passive)") { + val setups = listOf(bridge1, bridge2).map { bridge -> + // The transport update for the relay (create=false, with a transport). + val transportUpdate = requestsTo(bridge).flatMap { it.relays } + .filter { !it.create && it.transport?.iceUdpTransport != null } + transportUpdate.size shouldBe 1 + transportUpdate.first().transport!!.iceUdpTransport!!.getChildExtensionsOfType( + DtlsFingerprintPacketExtension::class.java + ).first().setup + } + setups.toSet() shouldBe setOf("active", "passive") + } + should("not fail any sessions") { + failedSessions.shouldBeEmpty() + } + should("signal each participant as a remote endpoint on the other bridge's relay") { + val relayEndpointsOn1 = requestsTo(bridge1).flatMap { it.relays } + .mapNotNull { it.endpoints }.flatMap { it.endpoints }.map { it.id } + val relayEndpointsOn2 = requestsTo(bridge2).flatMap { it.relays } + .mapNotNull { it.endpoints }.flatMap { it.endpoints }.map { it.id } + relayEndpointsOn1 shouldBe listOf("p2") + relayEndpointsOn2 shouldBe listOf("p1") + } + + context("And removing the participant on the second bridge") { + sessionManager.removeParticipant("p2").also { drain() } + + should("expire the second bridge's session and its relay") { + sessionManager.getBridges().keys shouldBe setOf(bridge1) + val relayExpires = requestsTo(bridge1).flatMap { it.relays }.filter { it.expire } + relayExpires.map { it.id } shouldBe listOf("jvb2") + } + should("not fail any sessions") { + failedSessions.shouldBeEmpty() + } + } + } + } + + context("A bridge failing") { + withNewConfig("jicofo.octo.enabled=true") { + allocate("p1", region = "region-jvb1") + allocate("p2", region = "region-jvb2") + + val removed = sessionManager.removeBridge(bridge2).also { drain() } + should("report the participants on the failed bridge") { + removed shouldBe listOf("p2") + } + should("expire the relay to the failed bridge") { + val relayExpires = requestsTo(bridge1).flatMap { it.relays }.filter { it.expire } + relayExpires.map { it.id } shouldBe listOf("jvb2") + } + should("keep the remaining bridge in the conference") { + sessionManager.getBridges().keys shouldBe setOf(bridge1) + sessionManager.getParticipants(bridge1) shouldBe listOf("p1") + } + } + } + + context("Tracking recently added endpoints") { + withNewConfig("jicofo.octo.enabled=true") { + allocate("p1", region = "region-jvb1") + allocate("p2", region = "region-jvb1") + allocate("p3", region = "region-jvb2") + + should("count the endpoints that this conference added to each bridge") { + sessionManager.getBridges()[bridge1]!!.recentlyAddedParticipantCount shouldBe 2 + sessionManager.getBridges()[bridge2]!!.recentlyAddedParticipantCount shouldBe 1 + } + should("not decrease the count when a participant is removed") { + sessionManager.removeParticipant("p2").also { drain() } + with(sessionManager.getBridges()[bridge1]!!) { + participantCount shouldBe 1 + recentlyAddedParticipantCount shouldBe 2 + } + } + should("decay the count after the configured interval") { + clock.elapse(BridgeConfig.config.maxBridgeParticipantsInterval + 100.ms) + sessionManager.getBridges()[bridge1]!!.recentlyAddedParticipantCount shouldBe 0 + } + should("reset the count when the session is removed and re-created") { + sessionManager.removeParticipant("p1").also { drain() } + sessionManager.removeParticipant("p2").also { drain() } + sessionManager.getBridges().keys shouldBe setOf(bridge2) + + allocate("p4", region = "region-jvb1") + sessionManager.getBridges()[bridge1]!!.recentlyAddedParticipantCount shouldBe 1 + } + } + } + + context("ICE restart") { + allocate("p1", region = "region-jvb1") + + context("For an existing participant") { + sessionManager.restartIce("p1").also { drain() } + + should("request an ICE restart for the endpoint from its bridge") { + val iceRestarts = requestsTo(bridge1).flatMap { it.endpoints } + .filter { it.transport?.iceRestart == true } + iceRestarts.map { it.id } shouldBe listOf("p1") + } + should("relay the bridge's rotated transport back to the participant") { + iceRestartedTransports.size shouldBe 1 + val (endpointId, transport) = iceRestartedTransports.first() + endpointId shouldBe "p1" + // The bridge rotated its credentials and tagged them with the first generation. + transport.iceGeneration shouldBe 1 + transport.ufrag shouldBe "ufrag-test-meeting-id-p1-1" + } + should("not fail the session") { + failedSessions.shouldBeEmpty() + } + + context("And restarting again") { + sessionManager.restartIce("p1").also { drain() } + + should("relay the next generation") { + iceRestartedTransports.map { it.second.iceGeneration } shouldBe listOf(1, 2) + } + } + } + + context("Tagging the participant's answer") { + // The bridge tells the credentials of its new Agent apart from an ordinary transport update by + // the `ice-generation`. The participant can not carry that attribute across SDP, so jicofo tags + // the answer on its behalf. + fun transportsToBridge() = requestsTo(bridge1).flatMap { it.endpoints } + .mapNotNull { it.transport?.iceUdpTransport } + + sessionManager.updateParticipant("p1", transport = transportWithCredentials("initial")) + .also { drain() } + + should("not tag a transport signaled before any restart") { + transportsToBridge().last().iceGeneration shouldBe + IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED + } + + context("After a restart was relayed") { + sessionManager.restartIce("p1").also { drain() } + sessionManager.updateParticipant("p1", transport = transportWithCredentials("answer")) + .also { drain() } + + should("tag the new credentials with the generation of that restart") { + transportsToBridge().last().iceGeneration shouldBe 1 + } + + context("And the participant repeats the same credentials") { + sessionManager.updateParticipant("p1", transport = transportWithCredentials("answer")) + .also { drain() } + + should("not tag them again") { + transportsToBridge().last().iceGeneration shouldBe + IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED + } + } + + context("And the participant trickles a candidate") { + sessionManager.updateParticipant("p1", transport = transportWithCandidateOnly()) + .also { drain() } + + should("not tag it") { + transportsToBridge().last().iceGeneration shouldBe + IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED + } + } + + context("And a second restart is relayed") { + sessionManager.restartIce("p1").also { drain() } + sessionManager.updateParticipant("p1", transport = transportWithCredentials("answer-2")) + .also { drain() } + + should("tag the next answer with the next generation") { + transportsToBridge().last().iceGeneration shouldBe 2 + } + } + } + + context("With a participant that tags the answer itself") { + sessionManager.restartIce("p1").also { drain() } + sessionManager.updateParticipant("p1", transport = transportWithGeneration(1)).also { drain() } + + should("leave its tag alone") { + transportsToBridge().last().iceGeneration shouldBe 1 + } + } + } + + context("For an unknown participant") { + sessionManager.restartIce("nonexistent").also { drain() } + + should("not send anything to the bridge") { + requestsTo(bridge1).flatMap { it.endpoints }.none { it.transport?.iceRestart == true } shouldBe true + } + should("not relay anything") { + iceRestartedTransports.shouldBeEmpty() + } + } + + context("With responses arriving out of order") { + // Colibri2 responses are handled on an IO pool, so two restarts in quick succession can be handled in + // either order. Only the latest generation may be relayed on to the participant. + sessionManager.endpointIceRestarted("p1", transportWithGeneration(2)).also { drain() } + sessionManager.endpointIceRestarted("p1", transportWithGeneration(1)).also { drain() } + sessionManager.endpointIceRestarted("p1", transportWithGeneration(3)).also { drain() } + + should("drop the stale generation and relay the rest") { + iceRestartedTransports.map { it.second.iceGeneration } shouldBe listOf(2, 3) + } + } + + context("With a transport that carries no generation") { + // The bridge kept the Agent it already had instead of restarting, so there is nothing to relay. + sessionManager.endpointIceRestarted("p1", transportWithGeneration(2)).also { drain() } + sessionManager.endpointIceRestarted("p1", transportWithGeneration(null)).also { drain() } + + should("not relay it") { + iceRestartedTransports.map { it.second.iceGeneration } shouldBe listOf(2) + } + should("not lose the generation of the last transport that was relayed") { + sessionManager.endpointIceRestarted("p1", transportWithGeneration(1)).also { drain() } + + iceRestartedTransports.map { it.second.iceGeneration } shouldBe listOf(2) + } + } + } + + context("Debug state") { + withNewConfig("jicofo.octo.enabled=true") { + allocate("p1", region = "region-jvb1") + allocate("p2", region = "region-jvb2") + sessionManager.debugState shouldNotBe null + } + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ConnectSpecTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ConnectSpecTest.kt new file mode 100644 index 0000000..3939ac3 --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/ConnectSpecTest.kt @@ -0,0 +1,87 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.xmpp.extensions.colibri2.Connect +import java.net.URI + +class ConnectSpecTest : ShouldSpec() { + init { + val spec = ConnectSpec( + id = "c1", + url = URI("wss://example.com/t"), + type = Connect.Types.TRANSLATOR, + exports = listOf("a-a0", "b-a0"), + requests = listOf("a-a0.es", "b-a0.fr"), + httpHeaders = mapOf("Authorization" to "Bearer x"), + ping = ConnectSpec.Ping(1000, 2000) + ) + + context("toConnect") { + should("carry all fields and default to no create/expire") { + val connect = spec.toConnect() + connect.id shouldBe "c1" + connect.url shouldBe URI("wss://example.com/t") + connect.type shouldBe Connect.Types.TRANSLATOR + connect.audio shouldBe true + connect.create shouldBe false + connect.expire shouldBe false + connect.getExports() shouldBe listOf("a-a0", "b-a0") + connect.getRequests() shouldBe listOf("a-a0.es", "b-a0.fr") + connect.getHttpHeaders().associate { it.name to it.value } shouldBe mapOf("Authorization" to "Bearer x") + connect.getPing()!!.let { + it.interval shouldBe 1000 + it.timeout shouldBe 2000 + } + } + should("set the create marker") { + spec.toConnect(create = true).create shouldBe true + } + should("set the expire marker") { + spec.toConnect(expire = true).expire shouldBe true + } + } + + context("sameAs") { + should("be true for an identical spec") { + spec.sameAs(spec.copy()) shouldBe true + } + should("ignore source-name ordering") { + val reordered = spec.copy(exports = listOf("b-a0", "a-a0"), requests = listOf("b-a0.fr", "a-a0.es")) + spec.sameAs(reordered) shouldBe true + } + should("differ when the exported source set changes") { + spec.sameAs(spec.copy(exports = listOf("a-a0"))) shouldBe false + } + should("differ when the requested source set changes") { + spec.sameAs(spec.copy(requests = listOf("a-a0.es"))) shouldBe false + } + should("differ when the url changes") { + spec.sameAs(spec.copy(url = URI("wss://example.com/other"))) shouldBe false + } + should("differ when the headers change") { + spec.sameAs(spec.copy(httpHeaders = emptyMap())) shouldBe false + } + should("differ when the ping changes") { + spec.sameAs(spec.copy(ping = ConnectSpec.Ping(1, 2))) shouldBe false + } + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/PerSourceTranslatorSpecsTest.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/PerSourceTranslatorSpecsTest.kt new file mode 100644 index 0000000..f2a98ab --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/colibri/PerSourceTranslatorSpecsTest.kt @@ -0,0 +1,101 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge.colibri + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import org.jitsi.xmpp.extensions.colibri2.Connect +import java.net.URI + +class PerSourceTranslatorSpecsTest : ShouldSpec() { + private val url = URI("wss://example.com/t") + + init { + context("A sender within the per-connect limit") { + val request = TranslationRequest("aaaaaaaa", "aaaaaaaa-a0", listOf("aaaaaaaa-a0.en", "aaaaaaaa-a0.es")) + val specs = perSourceTranslatorSpecs(request, url, maxLanguagesPerConnect = 5, httpHeaders = emptyMap()) + + should("produce a single connect with all languages") { + specs.size shouldBe 1 + specs[0].id shouldBe "translator-aaaaaaaa-a0-0" + specs[0].type shouldBe Connect.Types.TRANSLATOR + specs[0].exports shouldContainExactly listOf("aaaaaaaa-a0") + specs[0].requests shouldContainExactly listOf("aaaaaaaa-a0.en", "aaaaaaaa-a0.es") + } + } + + context("A sender exceeding the per-connect limit") { + val languages = (1..7).map { "aaaaaaaa-a0.l$it" } + val request = TranslationRequest("aaaaaaaa", "aaaaaaaa-a0", languages) + val specs = perSourceTranslatorSpecs(request, url, maxLanguagesPerConnect = 3, httpHeaders = emptyMap()) + + should("split into ceil(n / max) connects with stable ids") { + specs.map { it.id } shouldContainExactly listOf( + "translator-aaaaaaaa-a0-0", + "translator-aaaaaaaa-a0-1", + "translator-aaaaaaaa-a0-2" + ) + } + should("chunk the languages, each connect exporting the same source") { + specs[0].requests shouldContainExactly languages.subList(0, 3) + specs[1].requests shouldContainExactly languages.subList(3, 6) + specs[2].requests shouldContainExactly languages.subList(6, 7) + specs.forEach { it.exports shouldContainExactly listOf("aaaaaaaa-a0") } + } + } + + context("A ping passed through") { + val request = TranslationRequest("aaaaaaaa", "aaaaaaaa-a0", listOf("aaaaaaaa-a0.en", "aaaaaaaa-a0.fr")) + val specs = perSourceTranslatorSpecs( + request, + url, + maxLanguagesPerConnect = 1, + httpHeaders = emptyMap(), + ping = ConnectSpec.Ping(10000, 3000) + ) + + should("set the ping on every connect") { + specs.size shouldBe 2 + specs.forEach { it.ping shouldBe ConnectSpec.Ping(10000, 3000) } + } + } + + context("No ping by default") { + should("leave ping null") { + perSourceTranslatorSpecs( + TranslationRequest("aaaaaaaa", "aaaaaaaa-a0", listOf("aaaaaaaa-a0.en")), + url, + maxLanguagesPerConnect = 5, + httpHeaders = emptyMap() + ).single().ping shouldBe null + } + } + + context("A sender with no requested languages") { + should("produce no connects") { + perSourceTranslatorSpecs( + TranslationRequest("aaaaaaaa", "aaaaaaaa-a0", emptyList()), + url, + maxLanguagesPerConnect = 5, + httpHeaders = emptyMap() + ) shouldBe emptyList() + } + } + } +} diff --git a/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/mock/TestColibri2Server.kt b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/mock/TestColibri2Server.kt new file mode 100644 index 0000000..2c9c62e --- /dev/null +++ b/jicofo-selector/src/test/kotlin/org/jitsi/jicofo/mock/TestColibri2Server.kt @@ -0,0 +1,321 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jicofo.mock + +import org.jitsi.utils.MediaType +import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension +import org.jitsi.xmpp.extensions.colibri.WebSocketPacketExtension +import org.jitsi.xmpp.extensions.colibri2.Colibri2Endpoint +import org.jitsi.xmpp.extensions.colibri2.Colibri2Error +import org.jitsi.xmpp.extensions.colibri2.Colibri2Relay +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifiedIQ +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ +import org.jitsi.xmpp.extensions.colibri2.MediaSource +import org.jitsi.xmpp.extensions.colibri2.Sctp +import org.jitsi.xmpp.extensions.colibri2.Sources +import org.jitsi.xmpp.extensions.colibri2.Transport +import org.jitsi.xmpp.extensions.jingle.DtlsFingerprintPacketExtension +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension +import org.jivesoftware.smack.packet.ExtensionElement +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import org.jivesoftware.smack.packet.StanzaError.Condition.bad_request +import org.jivesoftware.smack.packet.StanzaError.Condition.conflict +import org.jivesoftware.smack.packet.StanzaError.Condition.feature_not_implemented +import org.jivesoftware.smack.packet.StanzaError.Condition.item_not_found +import java.lang.Exception +import kotlin.jvm.Throws +import kotlin.random.Random + +class TestColibri2Server { + private val conferences = mutableMapOf() + + fun stop() = conferences.clear() + + fun handleConferenceModifyIq(request: ConferenceModifyIQ): IQ { + val conference: Conference = if (request.create) { + if (conferences.containsKey(request.meetingId)) { + return createError(request, conflict, "Conference already exists") + } + val newConference = Conference(request.meetingId) + conferences[request.meetingId] = newConference + newConference + } else { + conferences[request.meetingId] + ?: return createError(request, item_not_found, "Conference not found") + } + + return try { + conference.handleRequest(request) + } catch (e: IqProcessingException) { + // Make sure to tag the error as coming from a bridge, otherwise item_not_found may be misiterpreted. + return createError(request, e.condition, e.message, Colibri2Error()) + } + } + private fun expireConference(meetingId: String) = conferences.remove(meetingId) + + private inner class Conference(val meetingId: String) { + val endpoints = mutableMapOf() + val relays = mutableMapOf() + + @Throws(IqProcessingException::class) + fun handleRequest(request: ConferenceModifyIQ): IQ { + val responseBuilder = + ConferenceModifiedIQ.builder(ConferenceModifiedIQ.Builder.createResponse(request)) + + if (request.expire) { + expireConference(meetingId) + return responseBuilder.build() + } + + if (request.create) { + responseBuilder.setSources(buildFeedbackSources(Random.nextLong(), Random.nextLong())) + } + + request.endpoints.forEach { responseBuilder.addEndpoint(handleEndpoint(it)) } + request.relays.forEach { responseBuilder.addRelay(handleRelay(it)) } + + if (!request.create && endpoints.isEmpty()) { + expireConference(meetingId) + } + + return responseBuilder.build() + } + + private fun expireEndpoint(id: String) = endpoints.remove(id) + + private fun handleEndpoint(c2endpoint: Colibri2Endpoint): Colibri2Endpoint { + val respBuilder = Colibri2Endpoint.getBuilder().apply { setId(c2endpoint.id) } + if (c2endpoint.expire) { + expireEndpoint(c2endpoint.id) + respBuilder.setExpire(true) + return respBuilder.build() + } + + val endpoint = if (c2endpoint.create) { + if (endpoints.containsKey(c2endpoint.id)) { + throw IqProcessingException(conflict, "Endpoint with ID ${c2endpoint.id} already exists") + } + val transport = c2endpoint.transport ?: throw IqProcessingException( + bad_request, + "Attempt to create endpoint ${c2endpoint.id} with no " + ) + val newEndpoint = Endpoint(c2endpoint.id).apply { + transport.sctp?.let { sctp -> + if (sctp.role != null && sctp.role != Sctp.Role.SERVER) { + throw IqProcessingException( + feature_not_implemented, + "Unsupported SCTP role: ${sctp.role}" + ) + } + if (sctp.port != null) { + throw IqProcessingException(bad_request, "Specific SCTP port requested, not supported.") + } + + useSctp = true + } + } + endpoints[c2endpoint.id] = newEndpoint + newEndpoint + } else { + endpoints[c2endpoint.id] ?: throw IqProcessingException( + item_not_found, + "Unknown endpoint ${c2endpoint.id}" + ) + } + + // c2endpoint.transport?.iceUdpTransport?.let { endpoint.setTransportInfo(it) } + if (c2endpoint.create) { + val transBuilder = Transport.getBuilder() + transBuilder.setIceUdpExtension(endpoint.describeTransport()) + if (c2endpoint.transport?.sctp != null) { + transBuilder.setSctp( + Sctp.Builder() + .setPort(5000) + .setRole(Sctp.Role.SERVER) + .build() + ) + } + respBuilder.setTransport(transBuilder.build()) + } else if (c2endpoint.transport?.iceRestart == true) { + // Model the bridge's in-place ICE restart: it creates a new agent with fresh credentials, tags them + // with the next generation, and returns them in the response. + endpoint.iceGeneration++ + respBuilder.setTransport( + Transport.getBuilder().setIceUdpExtension(endpoint.describeTransport()).build() + ) + } + + c2endpoint.forceMute?.let { + endpoint.forceMuteAudio = it.audio + endpoint.forceMuteVideo = it.video + } + + return respBuilder.build() + } + + private fun handleRelay(c2relay: Colibri2Relay): Colibri2Relay { + val respBuilder = Colibri2Relay.getBuilder().apply { setId(c2relay.id) } + if (c2relay.expire) { + if (relays.remove(c2relay.id) == null) { + throw IqProcessingException(item_not_found, "Unknown relay ${c2relay.id}") + } + respBuilder.setExpire(true) + return respBuilder.build() + } + + val relay = if (c2relay.create) { + if (relays.containsKey(c2relay.id)) { + throw IqProcessingException(conflict, "Relay with ID ${c2relay.id} already exists") + } + val transport = c2relay.transport ?: throw IqProcessingException( + bad_request, + "Attempt to create relay ${c2relay.id} with no " + ) + val newRelay = Relay(c2relay.id, useSctp = transport.sctp != null) + relays[c2relay.id] = newRelay + newRelay + } else { + relays[c2relay.id] ?: throw IqProcessingException( + item_not_found, + "Unknown relay ${c2relay.id}" + ) + } + + c2relay.endpoints?.endpoints?.forEach { relayEndpoint -> + if (relayEndpoint.expire) { + relay.endpoints.remove(relayEndpoint.id) + } else { + relay.endpoints[relayEndpoint.id] = relayEndpoint + } + } + + if (c2relay.create) { + respBuilder.setTransport( + Transport.getBuilder().apply { + setIceUdpExtension(relay.describeTransport()) + }.build() + ) + } + + return respBuilder.build() + } + + private inner class Relay(val id: String, val useSctp: Boolean) { + /** Remote endpoints that have been set on this relay, by ID. */ + val endpoints = mutableMapOf() + + fun describeTransport() = IceUdpTransportPacketExtension().apply { + password = "password-$meetingId-relay-$id" + ufrag = "ufrag-$meetingId-relay-$id" + addChildExtension( + DtlsFingerprintPacketExtension().apply { + hash = "sha-256" + setup = "actpass" + text = "AC:58:2D:03:40:89:87:30:6C:25:2C:50:17:5C:5C:2E:" + + "1F:A1:19:19:4D:74:A5:37:35:22:6E:8E:DF:55:13:8E" + } + ) + if (!useSctp) { + // The bridge advertises a websocket unless SCTP is in use. + addChildExtension(WebSocketPacketExtension("wss://example.com/colibri-relay-ws/$meetingId/$id")) + } + } + } + + private inner class Endpoint(val id: String) { + var useSctp = false + var forceMuteAudio = false + var forceMuteVideo = false + + /** Incremented on each in-place ICE restart, i.e. each time the bridge rotates its credentials. */ + var iceGeneration = 0 + + fun describeTransport(): IceUdpTransportPacketExtension { + // Note: bound outside the apply{} below, where `iceGeneration` would resolve to the transport's own + // property rather than this endpoint's. + val generation = iceGeneration + return IceUdpTransportPacketExtension().apply { + password = "password-$meetingId-$id-$generation" + ufrag = "ufrag-$meetingId-$id-$generation" + if (generation > 0) { + setIceGeneration(generation) + } + // TODO add some candidates? + addChildExtension( + DtlsFingerprintPacketExtension().apply { + hash = "sha-256" + text = "AC:58:2D:03:40:89:87:30:6C:25:2C:50:17:5C:5C:2E:" + + "1F:A1:19:19:4D:74:A5:37:35:22:6E:8E:DF:55:13:8E" + } + ) + } + } + } + } +} + +private class IqProcessingException( + val condition: StanzaError.Condition, + message: String +) : Exception(message) { + override fun toString() = "$condition $message" +} + +private fun buildFeedbackSources(localAudioSsrc: Long, localVideoSsrc: Long): Sources = Sources.getBuilder().apply { + addMediaSource( + MediaSource.getBuilder() + .setType(MediaType.AUDIO) + .setId("jvb-a0") + .addSource( + SourcePacketExtension().apply { + ssrc = localAudioSsrc + name = "jvb-a0" + } + ) + .build() + ) + addMediaSource( + MediaSource.getBuilder() + .setType(MediaType.VIDEO) + .setId("jvb-v0") + .addSource( + SourcePacketExtension().apply { + ssrc = localVideoSsrc + name = "jvb-v0" + } + ) + .build() + ) +}.build() + +fun createError( + request: IQ, + errorCondition: StanzaError.Condition, + errorMessage: String? = null, + extension: ExtensionElement? = null +): IQ { + val error = StanzaError.getBuilder(errorCondition) + errorMessage?.let { error.setDescriptiveEnText(it) } + extension?.let { + error.setExtensions(listOf(it)) + } + + return IQ.createErrorResponse(request, error.build()) +} diff --git a/jicofo/pom.xml b/jicofo/pom.xml new file mode 100644 index 0000000..990a573 --- /dev/null +++ b/jicofo/pom.xml @@ -0,0 +1,500 @@ + + + 4.0.0 + + org.jitsi + jicofo-parent + 1.1-SNAPSHOT + + jicofo + 1.1-SNAPSHOT + jar + jicofo + JItsi COnference FOcus is a server side focus component used in Jitsi Meet conferences. + + + ${project.groupId} + jicofo-common + ${project.version} + + + ${project.groupId} + jicofo-selector + ${project.version} + + + org.apache.commons + commons-lang3 + + + io.ktor + ktor-server-core-jvm + ${ktor.version} + + + io.ktor + ktor-server-netty-jvm + ${ktor.version} + + + io.ktor + ktor-server-content-negotiation-jvm + ${ktor.version} + + + io.ktor + ktor-serialization-jackson-jvm + ${ktor.version} + + + io.ktor + ktor-server-status-pages-jvm + ${ktor.version} + + + org.igniterealtime.smack + smack-tcp + + + + org.jxmpp + jxmpp-stringprep-rocksxmppprecis + + + org.jetbrains + annotations + + + org.jetbrains.kotlin + kotlin-stdlib + compile + + + + ${project.groupId} + jicoco-config + + + ${project.groupId} + jicoco-health-checker + + + ${project.groupId} + jicoco-jwt + + + ${project.groupId} + jitsi-metaconfig + + + ${project.groupId} + jicoco-metrics + + + ${project.groupId} + jicoco-tracing + + + ${project.groupId} + jitsi-utils + + + ${project.groupId} + jitsi-xmpp-extensions + + + org.slf4j + slf4j-jdk14 + + + org.slf4j + slf4j-api + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + + com.github.spotbugs + spotbugs-annotations + + + + io.sentry + sentry + runtime + + + + org.xmlunit + xmlunit-core + test + + + org.mockito + mockito-core + test + + + io.mockk + mockk-jvm + test + + + io.kotest + kotest-runner-junit5-jvm + test + + + io.kotest + kotest-assertions-core-jvm + test + + + io.kotest + kotest-property-jvm + test + + + org.junit.platform + junit-platform-launcher + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter + test + + + ${project.groupId} + jicoco-test-kotlin + test + + + ${project.groupId} + jicofo-common + ${project.version} + test-jar + test + + + ${project.groupId} + jicofo-selector + ${project.version} + test-jar + test + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + + + test-compile + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + + 17 + + -Xallow-result-return-type + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + + default-compile + none + + + + default-testCompile + none + + + java-compile + compile + + compile + + + + java-test-compile + test-compile + + testCompile + + + + + 17 + 17 + + -Xlint:all,-serial + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.3.0 + + + assemble-archive + package + + single + + + true + + src/assembly/archive.xml + + ${assembly.skipAssembly} + + jar-with-dependencies + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + ${project.parent.basedir}/lib/logging.properties + true + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + org.jitsi.jicofo.Main + + + + ktlint-check + verify + + exec + + + java + true + + -classpath + + com.pinterest.ktlint:ktlint-cli + + com.pinterest.ktlint.Main + --relative + src/main/kotlin/**/*.kt + src/test/kotlin/**/*.kt + + + + + ktlint-format + + exec + + + java + true + + -classpath + + com.pinterest.ktlint:ktlint-cli + + com.pinterest.ktlint.Main + --format + --relative + src/main/kotlin/**/*.kt + src/test/kotlin/**/*.kt + + + + + + + com.pinterest.ktlint + ktlint-cli + ${ktlint.version} + all + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + + true + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.2 + + checkstyle.xml + + + + com.puppycrawl.tools + checkstyle + 9.1 + + + + + + check + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + spotbugs-exclude.xml + true + + + + verify + + check + + + + + + com.github.spotbugs + spotbugs + ${spotbugs.version} + + + + + + + + jitsi-maven-repository-releases + default + Jitsi Maven Repository (Releases) + + true + + + false + + https://github.com/jitsi/jitsi-maven-repository/raw/master/releases/ + + + jitsi-maven-repository-snapshots + default + Jitsi Maven Repository (Snapshots) + + false + + + true + + https://github.com/jitsi/jitsi-maven-repository/raw/master/snapshots/ + + + + + coverage + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + + report + test + + report + + + + + report-aggregate + test + + report-aggregate + + + true + + + + + + + + + diff --git a/jicofo/spotbugs-exclude.xml b/jicofo/spotbugs-exclude.xml new file mode 100644 index 0000000..7657146 --- /dev/null +++ b/jicofo/spotbugs-exclude.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jicofo/src/assembly/archive.xml b/jicofo/src/assembly/archive.xml new file mode 100644 index 0000000..3adfc74 --- /dev/null +++ b/jicofo/src/assembly/archive.xml @@ -0,0 +1,55 @@ + + archive + + zip + + true + ${project.artifactId}-${project.version} + + + lib + runtime + false + false + + + + + ${project.basedir}/target/${project.artifactId}-${project.version}.jar + ${file.separator} + jicofo.jar + + + + + ${project.build.directory} + ${file.separator} + + *.jar + + + *-with-dependencies.jar + ${project.artifactId}-${project.version}.jar + + + + ${project.basedir}/lib + lib + + **/*.jar + + + + ${project.parent.basedir}/resources/ + ${file.separator} + 755 + + jicofo.sh + jicofo.bat + + + + diff --git a/jicofo/src/main/java/org/jitsi/jicofo/Main.java b/jicofo/src/main/java/org/jitsi/jicofo/Main.java new file mode 100644 index 0000000..b145c81 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/Main.java @@ -0,0 +1,147 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo; + +import kotlin.jvm.functions.*; +import org.jetbrains.annotations.*; +import org.jitsi.config.*; +import org.jitsi.metaconfig.*; +import org.jitsi.utils.*; +import org.jitsi.utils.logging2.*; +import sun.misc.*; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + + +/** + * Provides the main entry point of Jitsi Meet conference focus. + * + * @author Pawel Domas + */ +public class Main +{ + private static final Logger logger = new LoggerImpl(Main.class.getName()); + + /** + * Program entry point. + * + * @param args command-line arguments. + */ + public static void main(String[] args) + { + logger.info("Starting Jicofo."); + + Thread.setDefaultUncaughtExceptionHandler((t, e) -> + logger.error("An uncaught exception occurred in thread=" + t, e)); + + if (System.getProperty("config.file", "").isEmpty()) + { + logger.warn("Required property config.file is missing. Set with -Dconfig.file="); + return; + } + setupMetaconfigLogger(); + JitsiConfig.Companion.reloadNewConfig(); + + // Make sure that passwords are not printed by ConfigurationService + // on startup by setting password regExpr and cmd line args list + ConfigUtils.PASSWORD_SYS_PROPS = "pass"; + ConfigUtils.PASSWORD_CMD_LINE_ARGS = "user_password"; + + + CountDownLatch shutdownLatch = new CountDownLatch(1); + Runtime.getRuntime().addShutdownHook(new Thread(shutdownLatch::countDown)); + + JicofoServices jicofoServices; + try + { + jicofoServices = new JicofoServices(); + } + catch (Exception e) + { + logger.error("Failed to start jicofo: ", e); + TaskPools.shutdown(); + return; + } + + AtomicInteger exitStatus = new AtomicInteger(); + + /* Catch signals and cause them to trigger a clean shutdown. */ + for (String signalName : List.of("TERM", "HUP", "INT")) + { + try + { + Signal.handle(new Signal(signalName), signal -> + { + // Matches java.lang.Terminator + exitStatus.set(signal.getNumber() + 128); + logger.info("Caught signal " + signal + ", shutting down."); + + shutdownLatch.countDown(); + }); + } + catch (IllegalArgumentException e) + { + /* Unknown signal on this platform, or not allowed to register this signal; that's fine. */ + logger.warn("Unable to register signal " + signalName, e); + } + } + + + try + { + shutdownLatch.await(); + } + catch (Exception e) + { + logger.error(e, e); + } + + logger.info("Stopping services."); + jicofoServices.shutdown(); + TaskPools.shutdown(); + logger.info("Jicofo has been stopped, exiting with exit code " + exitStatus.get()); + System.exit(exitStatus.get()); + } + + private static void setupMetaconfigLogger() + { + org.jitsi.utils.logging2.Logger configLogger = new org.jitsi.utils.logging2.LoggerImpl("org.jitsi.config"); + MetaconfigSettings.Companion.setLogger(new MetaconfigLogger() + { + @Override + public void warn(@NotNull Function0 function0) + { + configLogger.warn(function0::invoke); + } + + @Override + public void error(@NotNull Function0 function0) + { + configLogger.error(function0::invoke); + } + + @Override + public void debug(@NotNull Function0 function0) + { + configLogger.debug(function0::invoke); + } + }); + } +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/auth/AbstractAuthAuthority.java b/jicofo/src/main/java/org/jitsi/jicofo/auth/AbstractAuthAuthority.java new file mode 100644 index 0000000..ea56296 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/auth/AbstractAuthAuthority.java @@ -0,0 +1,531 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.auth; + +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.*; + +import org.jetbrains.annotations.*; +import org.jitsi.xmpp.extensions.jitsimeet.*; +import org.jitsi.jicofo.*; +import org.jitsi.utils.logging2.*; +import org.jivesoftware.smack.packet.*; + +import org.jxmpp.jid.*; + +import static org.apache.commons.lang3.StringUtils.*; + +/** + * Common class for {@link AuthenticationAuthority} implementations. + * + * @author Pawel Domas + */ +public abstract class AbstractAuthAuthority + implements AuthenticationAuthority, ConferenceStore.Listener +{ + /** + * The logger. + */ + private final static Logger logger = new LoggerImpl(AbstractAuthAuthority.class.getName()); + + /** + * Interval at which we check for authentication sessions expiration. + */ + private final static long EXPIRE_POLLING_INTERVAL = 10000L; + + /** + * Authentication session lifetime in milliseconds. + */ + private final long authenticationLifetime; + + /** + * If set to true authentication session will be destroyed + * immediately after end of the conference for which it was created. + */ + private final boolean enableAutoLogin; + + /** + * The timer used to check for the expiration of authentication sessions. + */ + private Timer expireTimer; + + /** + * Synchronization root. + */ + protected final Object syncRoot = new Object(); + + /** + * The map of user JIDs to {@link AuthenticationSession}. + * + * Note that access to this field is almost always protected by a lock on + * {@link #syncRoot}. However, {@link #getSession(String)} executes + * {@link Map#get(Object)} on it, which wouldn't be safe with a + * {@link HashMap} (as opposed to a {@link ConcurrentHashMap}. + * I've chosen this solution, because I don't know whether the cleaner + * solution of synchronizing on {@link #syncRoot} in + * {@link #getSession(String)} is safe. + */ + private final Map authenticationSessions + = new ConcurrentHashMap<>(); + + /** + * The list of registered {@link AuthenticationListener}s. + */ + private final List authenticationListeners + = new CopyOnWriteArrayList<>(); + + /** + * Creates new instance of AbstractAuthAuthority. + * + * @param enableAutoLogin disables auto login feature. Authentication + * sessions are destroyed immediately when the conference ends. + * @param authenticationLifetime specifies how long authentication sessions + * will be stored in Jicofo's memory. Interval in milliseconds. + */ + public AbstractAuthAuthority(boolean enableAutoLogin, Duration authenticationLifetime) + { + this.enableAutoLogin = enableAutoLogin; + this.authenticationLifetime = authenticationLifetime.toMillis(); + + if (!enableAutoLogin) + { + logger.info("Auto login disabled"); + } + + logger.info("Authentication lifetime: " + authenticationLifetime); + } + + /** + * Finds an {@link AuthenticationSession} session. + * + * @param selector - Must return true when a match is found. + * @return the first {@link AuthenticationSession} that matches given + * predicate or null. + */ + protected AuthenticationSession findSession( + Predicate selector) + { + ArrayList sessions + = new ArrayList<>(authenticationSessions.values()); + + return sessions + .stream() + .filter(selector) + .findFirst().orElse(null); + } + + /** + * Creates new AuthenticationSession for given parameters. + * + * @param machineUID unique machine identifier for new session. + * @param authIdentity authenticated user's identity name that will be + * used in new session. + * @param roomName the name of the conference for which the session will be + * created + * @return new AuthenticationSession for given parameters. + */ + protected AuthenticationSession createNewSession( + String machineUID, String authIdentity, EntityBareJid roomName) + { + synchronized (syncRoot) + { + AuthenticationSession session + = new AuthenticationSession( + machineUID, + createNonExistingUUID().toString(), + authIdentity, + roomName); + + authenticationSessions.put(session.getSessionId(), session); + + logger.info("Authentication session created for " + authIdentity + " SID: " + session.getSessionId()); + + return session; + } + } + + /** + * Returns new UUID that does not collide with any of the existing + * ones. + */ + private UUID createNonExistingUUID() + { + synchronized (syncRoot) + { + UUID uuid = UUID.randomUUID(); + while (authenticationSessions.containsKey(uuid.toString())) + { + uuid = UUID.randomUUID(); + } + return uuid; + } + } + + /** + * Finds AuthenticationSession for given MUID and + * user's authenticated identity name. + * + * @param machineUID unique machine identifier used to distinguish + * between sessions for the same user on different + * machines. + * @param authIdentity authenticated user's identity name(usually login + * or email) + * + * @return AuthenticationSession for given MUID and + * user's authenticated identity name + */ + protected AuthenticationSession findSessionForIdentity( + String machineUID, String authIdentity) + { + if (isBlank(authIdentity) || isBlank(machineUID)) + { + return null; + } + + return findSession( + session -> session.getUserIdentity().equals(authIdentity) + && session.getMachineUID().equals(machineUID)); + } + + /** + * Finds AuthenticationSession for given Jabber ID. + * + * @param jabberId Jabber ID for which authentication session will be + * searched. + * + * @return AuthenticationSession for given Jabber ID or + * null if not found. + */ + protected AuthenticationSession findSessionForJabberId(Jid jabberId) + { + if (jabberId == null) + { + return null; + } + + return findSession( + session -> jabberId.equals(session.getUserJabberId())); + } + + /** + * Returns AuthenticationSession for given session identifier or + * null if not found. + * + * @param sessionId unique authentication session identifier string that + * will be used in the search. + */ + protected AuthenticationSession getSession(String sessionId) + { + return sessionId != null ? authenticationSessions.get(sessionId) : null; + } + + /** + * {@inheritDoc} + */ + @Override + public String getSessionForJid(Jid jabberId) + { + AuthenticationSession session = findSessionForJabberId(jabberId); + return session != null ? session.getSessionId() : null; + } + + /** + * {@inheritDoc} + */ + @Override + public String getUserIdentity(Jid jabberId) + { + AuthenticationSession session = findSessionForJabberId(jabberId); + + return session != null ? session.getUserIdentity() : null; + } + + /** + * Destroys AuthenticationSession for given ID. + * + * @param sessionId unique authentication session identifier. + */ + public void destroySession(String sessionId) + { + synchronized (syncRoot) + { + AuthenticationSession session = getSession(sessionId); + + if (session == null) + return; + + if (authenticationSessions.remove(sessionId) != null) + { + logger.info("Authentication removed: " + session); + } + } + } + + /** + * {@inheritDoc} + */ + @Override + public void conferenceEnded(EntityBareJid roomName) + { + if (enableAutoLogin) + { + return; + } + + synchronized (syncRoot) + { + Iterator sessionIterator + = authenticationSessions.values().iterator(); + + while (sessionIterator.hasNext()) + { + AuthenticationSession session = sessionIterator.next(); + if (roomName.equals(session.getRoomName())) + { + logger.info( + "Removing session for ended conference, S: " + session); + sessionIterator.remove(); + } + } + } + } + + /** + * Registers to the list of AuthenticationListeners. + * @param l the AuthenticationListener to be added to listeners + * list. + */ + @Override + public void addAuthenticationListener(AuthenticationListener l) + { + if (!authenticationListeners.contains(l)) + { + authenticationListeners.add(l); + } + } + + /** + * Unregisters from the list of AuthenticationListeners. + * @param l the AuthenticationListener that will be removed from + * authentication listeners list. + */ + @Override + public void removeAuthenticationListener(AuthenticationListener l) + { + authenticationListeners.remove(l); + } + + protected void notifyUserAuthenticated(Jid userJid, + String identity, + String sessionId) + { + logger.info("Jid " + userJid + " authenticated as: " + identity); + + for (AuthenticationListener l : authenticationListeners) + { + l.jidAuthenticated(userJid, identity, sessionId); + } + } + + /** + * {@inheritDoc} + */ + @Override + public IQ processAuthentication( + ConferenceIq query, ConferenceIq response) + { + synchronized (syncRoot) + { + return processAuthLocked(query, response); + } + } + + /** + * Implements {@link AuthenticationAuthority# + * processAuthentication(ConferenceIq, ConferenceIq, boolean)}. Runs in + * synchronized section of authentication sessions storage lock. + */ + protected abstract IQ processAuthLocked( + ConferenceIq query, ConferenceIq response); + + /** + * Utility method to by used by implementing classes in order to + * verify authentication session's identifier during authentication + * request handling process({@link #processAuthentication(ConferenceIq, + * ConferenceIq)}). + * + * @param query ConferenceIq that contains(or not) session id + * for the verification. + * + * @return null if no problems where discovered during + * verification process or XMPP error that should be returned as response + * to given query. + */ + protected IQ verifySession(ConferenceIq query) + { + String sessionId = query.getSessionId(); + AuthenticationSession session = getSession(sessionId); + + if (isNotBlank(sessionId)) + { + // Session not found: session-invalid + if (session == null) + { + // session-invalid + return ErrorFactory.createSessionInvalidResponse(query); + } + // Check if session is used with the same machine UID + String sessionMUID = session.getMachineUID(); + String queryMUID = query.getMachineUID(); + if (!sessionMUID.equals(queryMUID)) + { + // not-acceptable + return ErrorFactory.createNotAcceptableError( + query, "machine UID mismatch or empty"); + } + } + return null; + } + + /** + * This method should be called during authentication request processing + * in order to authenticate JID found in the 'from' field of the request + * with given AuthenticationSession(based on supplied session-id). + * When called it means that the request sent by peerJid is + * valid and has been authenticated for given session. + * Authentication process is specific to {@link AuthenticationAuthority} + * implementation. + * + * @param session AuthenticationSession which corresponds to the + * session id supplied in the request. + * @param peerJid user's JID that will be associated with + * AuthenticationSession. + * @param response the response instance that will be sent back to the user. + * It will be updated with required information to describe + * the session. + */ + protected void authenticateJidWithSession( + AuthenticationSession session, + Jid peerJid, + ConferenceIq response) + { + session.setUserJabberId(peerJid); + + logger.info("Authenticated jid: " + peerJid + " with session: " + session); + + notifyUserAuthenticated(peerJid, session.getUserIdentity(), session.getSessionId()); + + // Re-new session activity timestamp + session.touch(); + + // Update response + response.setIdentity(session.getUserIdentity()); + response.setSessionId(session.getSessionId()); + } + + /** + * {@inheritDoc} + */ + @Override + @NotNull + public IQ processLogoutIq(LogoutIq iq) + { + //String peerJid = iq.getFrom(); + String sessionId = iq.getSessionId(); + + if (getSession(sessionId) == null) + { + return ErrorFactory.createSessionInvalidResponse(iq); + } + + LogoutIq result = new LogoutIq(); + + result.setType(org.jivesoftware.smack.packet.IQ.Type.result); + result.setStanzaId(iq.getStanzaId()); + result.setFrom(iq.getTo()); + result.setTo(iq.getFrom()); + + // Logout URL for external system + String logoutUrl = createLogoutUrl(sessionId); + result.setLogoutUrl(logoutUrl); + + // Destroy local application session + destroySession(sessionId); + + return result; + } + + /** + * Returns URL which should be visited by the user in order to complete + * the logout process. If this step is not required then null + * is returned. + * + * @param sessionId authentication session identifier string. + */ + protected abstract String createLogoutUrl(String sessionId); + + /** + * Start this authentication authority instance. + */ + public void start() + { + expireTimer = new Timer("AuthenticationExpireTimer", true); + expireTimer.scheduleAtFixedRate(new ExpireTask(), EXPIRE_POLLING_INTERVAL, EXPIRE_POLLING_INTERVAL); + } + + /** + * Stops this authentication authority instance. + */ + public void shutdown() + { + if (expireTimer != null) + { + expireTimer.cancel(); + expireTimer = null; + } + } + + /** + * Task expires tokens and authentications. + */ + private class ExpireTask extends TimerTask + { + @Override + public void run() + { + synchronized (syncRoot) + { + Iterator sessionsIter + = authenticationSessions.values().iterator(); + while (sessionsIter.hasNext()) + { + AuthenticationSession session = sessionsIter.next(); + if (System.currentTimeMillis() + - session.getActivityTimestamp() + > authenticationLifetime) + { + logger.info("Expiring session:" + session); + sessionsIter.remove(); + } + } + } + } + } +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/auth/AuthenticationAuthority.java b/jicofo/src/main/java/org/jitsi/jicofo/auth/AuthenticationAuthority.java new file mode 100644 index 0000000..8732730 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/auth/AuthenticationAuthority.java @@ -0,0 +1,119 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.auth; + +import org.jetbrains.annotations.*; +import org.jitsi.xmpp.extensions.jitsimeet.*; +import org.jivesoftware.smack.packet.*; +import org.jxmpp.jid.*; + +/** + * This interface is intended to encapsulate authorization method like XMPP domain. + * + * @author Pawel Domas + */ +public interface AuthenticationAuthority +{ + /** + * Creates the URL that should be visited by the user in order to login. + * + * @param machineUID unique identifier of the user's machine that will be + * associated with the session created. + * @param peerFullJid peer's JID that will be associated with the session + * (but it's up to the implementation). + * @param roomName name of the conference room in the form of + * true if this URL will be opened in the popup + * (used for live authentication once the conference has + * been started). + * + * @return the URL that must be visited by the user in order to login to + * the authentication system. + */ + String createLoginUrl( + String machineUID, EntityFullJid peerFullJid, + EntityBareJid roomName, boolean popup); + + /** + * Process given ConferenceIq query in order to verify user's + * authentication session and eventual permissions for creating new room. + * + * @param query the ConferenceIq requested that should be + * authenticated. + * @param response the ConferenceIq response that will be + * returned to the user. Implementing classes can fill + * some information in order to describe authentication + * session. + * @return XMPP error if query has failed authentication process. + */ + IQ processAuthentication(ConferenceIq query, ConferenceIq response); + + + /** + * Process given LogoutIq and eventually destroy + * AuthenticationSession described in the request. + * + * @param iq the LogoutIq that described authentication session + * to be destroyed. + * + * @return XMPP error that will be sent to the user if we do not accept + * the request. + */ + @NotNull + IQ processLogoutIq(LogoutIq iq); + + /** + * Registers to the list of AuthenticationListeners. + * @param l the AuthenticationListener to be added to listeners + * list. + */ + void addAuthenticationListener(AuthenticationListener l); + + /** + * Unregisters from the list of AuthenticationListeners. + * @param l the AuthenticationListener that will be removed from + * authentication listeners list. + */ + void removeAuthenticationListener(AuthenticationListener l); + + /** + * Returns authentication session ID string for given jabberId if + * it is authenticated. + * @param jabberId the Jabber ID of the user to be verified. + */ + String getSessionForJid(Jid jabberId); + + /** + * Returns user login associated with given jabberId. + * @param jabberId the Jabber ID of the connection for which we want to + * get user's identity. + * @return user login associated with given jabberId or + * null + */ + String getUserIdentity(Jid jabberId); + + /** + * Returns true if this is external authentication method that + * requires user to visit authentication URL. + */ + boolean isExternal(); + + void start(); + + void shutdown(); +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/auth/AuthenticationListener.java b/jicofo/src/main/java/org/jitsi/jicofo/auth/AuthenticationListener.java new file mode 100644 index 0000000..39739d1 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/auth/AuthenticationListener.java @@ -0,0 +1,40 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.auth; + +import org.jxmpp.jid.*; + +/** + * Interface used to listen to authentication notification fired by + * {@link AuthenticationAuthority}. + * + * @author Pawel Domas + */ +public interface AuthenticationListener +{ + /** + * Called by {@link AuthenticationAuthority} when the user identified by + * given userJid gets confirmed identity by external authentication + * component. + * @param userJid the real user JID(not MUC JID which can be faked). + * @param authenticatedIdentity the identity of the user confirmed by + */ + void jidAuthenticated(Jid userJid, + String authenticatedIdentity, + String sessionId); +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/auth/AuthenticationSession.java b/jicofo/src/main/java/org/jitsi/jicofo/auth/AuthenticationSession.java new file mode 100644 index 0000000..32b8ed0 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/auth/AuthenticationSession.java @@ -0,0 +1,173 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.auth; + +import org.jxmpp.jid.*; + +import java.util.*; + +/** + * Class represents stores information about single user's authentication + * session. + * + * @author Pawel Domas + */ +public class AuthenticationSession +{ + /** + * Machine unique identifier used to associate session with particular + * user's machine, so that single user can login from different locations at + * the same time. + */ + private final String machineUID; + + /** + * Authentication session identifier. + */ + private final String sessionId; + + /** + * Authenticated user's identity name(usually login name). + */ + private final String userIdentity; + + /** + * Timestamp in millis used to track the moment of last activity on + * session instance, so that it can eventually expire after exceeding + * time limit of inactivity. + */ + private long activityTimestamp = System.currentTimeMillis(); + + /** + * User's jabber ID recently used with this session. Used to bind + * anonymous JID to {@link #userIdentity}. + */ + private Jid userJabberId; + + /** + * Optional room name to which this session ID is bound. + */ + private EntityBareJid roomName; + + /** + * Creates new instance of AuthenticationSession. + * @param machineUID unique machine identifier that will be used to + * distinguish between session for the same user on + * different machines. + * @param sessionId unique session identifier. + * @param userIdentity user's identity in the scope of authentication + * system, usually login name or email address. + * @param roomName full name of MUC room which hosts the conference for + */ + public AuthenticationSession(String machineUID, String sessionId, String + userIdentity, EntityBareJid roomName) + { + this.machineUID = Objects.requireNonNull(machineUID, "machineUID"); + this.sessionId = Objects.requireNonNull(sessionId, "sessionId"); + this.userIdentity = Objects.requireNonNull(userIdentity, "userIdentity"); + + this.roomName = roomName; + } + + /** + * Returns session identifier. + */ + public String getSessionId() + { + return sessionId; + } + + /** + * Returns user authenticated identity name in the scope of authentication + * system(usually login name or email address). + */ + public String getUserIdentity() + { + return userIdentity; + } + + /** + * Timestamp in millis of last activity on this session instance. + */ + public long getActivityTimestamp() + { + return activityTimestamp; + } + + /** + * Re-news activity timestamp to {@link System#currentTimeMillis()}. + */ + public void touch() + { + activityTimestamp = System.currentTimeMillis(); + } + + /** + * Returns Jabber ID assigned to this session. + */ + public Jid getUserJabberId() + { + return userJabberId; + } + + /** + * Assigns new Jabber ID to this session instance. + * @param userJabberId the Jabber ID of the user that will be associated + * with this session from now on. + */ + public void setUserJabberId(Jid userJabberId) + { + this.userJabberId = userJabberId; + } + + /** + * Returns machine unique identifier which identifies owner machine of + * this AuthenticationSession. + * @return String machine UID. + */ + public String getMachineUID() + { + return machineUID; + } + + /** + * Returns ful name of MUC room for which this session has been created. + */ + public EntityBareJid getRoomName() + { + return roomName; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() + { + StringBuilder builder = new StringBuilder("AuthSession["); + builder.append("ID=").append(userIdentity); + builder.append(", JID=").append(userJabberId); + builder.append(", SID=").append(sessionId); + builder.append(", MUID=").append(machineUID); + long lifetime = System.currentTimeMillis() - activityTimestamp; + builder.append(", LIFE_TM_SEC=").append((lifetime/1000L)); + builder.append(", R=").append(roomName); + builder.append("]@").append(hashCode()); + return builder.toString(); + } +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/auth/ErrorFactory.java b/jicofo/src/main/java/org/jitsi/jicofo/auth/ErrorFactory.java new file mode 100644 index 0000000..9408806 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/auth/ErrorFactory.java @@ -0,0 +1,88 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.auth; + +import org.jitsi.xmpp.extensions.jitsimeet.*; +import org.jivesoftware.smack.packet.*; + +/** + * Utility class for creating XMPP error responses. + * + * @author Pawel Domas + */ +public class ErrorFactory +{ + /** + * Creates 'not-authorized' XMPP error response to given query. + * + * @param query the IQ for which error response wil be created. + * @param msg optional error description to be included in the response. + * + * @return XMPP 'not-authorized' error response to given query. + */ + public static IQ createNotAuthorizedError(IQ query, String msg) + { + final StanzaError error + = StanzaError.from(StanzaError.Condition.not_authorized, msg).build(); + + return IQ.createErrorResponse(query, error); + } + + /** + * Creates 'not-acceptable' XMPP error response with application specific + * 'session-invalid' error extension. + * + * @param query the IQ for which error response will be created. + * + * @return XMPP 'not-acceptable' error response to given query + * with application specific 'session-invalid' extension. + */ + public static IQ createSessionInvalidResponse(IQ query) + { + final StanzaError error + = StanzaError.from( + StanzaError.Condition.not_acceptable, + "invalid session") + // session-invalid application specific error + .addExtension(new SessionInvalidPacketExtension()) + .build(); + + return IQ.createErrorResponse(query, error); + } + + /** + * Creates 'not-acceptable' XMPP error response to given query. + * + * @param query the IQ for which error response will be created. + * + * @param errorMessage application specific error message included in + * error response. + * + * @return 'not-acceptable' XMPP error response to given query with + * included errorMessage. + */ + public static IQ createNotAcceptableError(IQ query, String errorMessage) + { + // not acceptable + final StanzaError error + = StanzaError.from(StanzaError.Condition.not_acceptable, errorMessage) + .build(); + + return IQ.createErrorResponse(query, error); + } +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/auth/ExternalJWTAuthority.java b/jicofo/src/main/java/org/jitsi/jicofo/auth/ExternalJWTAuthority.java new file mode 100644 index 0000000..b145d58 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/auth/ExternalJWTAuthority.java @@ -0,0 +1,72 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.auth; + +import org.jxmpp.jid.*; + +import java.time.*; + +/** + * Special case of XMPPDomainAuthAuthority where the user is + * authenticated in Prosody with JWT token authentication method. The name of + * XMPP domain should be passed to the constructor, which will happen when + * {@link AuthConfig#getLoginUrl()} is set to + * "EXT_JWT:auth.server.net", where 'auth.server.net' is the Prosody domain with + * JWT token authentication enabled. + * In order to obtain JWT, the user visits external "login" service from where + * is redirected back to the app with the token. That's why + * {@link #isExternal()} is overridden to return true. + * + * @author Pawel Domas + */ +public class ExternalJWTAuthority + extends XMPPDomainAuthAuthority +{ + /** + * Creates new instance of {@link ExternalJWTAuthority}. + * @param domain the name of the Prosody domain with JWT authentication + * enabled. + */ + public ExternalJWTAuthority(DomainBareJid domain) + { + // For external JWT type of authentication we do not want to persist + // the session IDs longer than the duration of the conference. + // Also session duration is limited to 1 minuted. This is how long it + // can be used for "on the fly" user role upgrade. That is the case when + // the user starts from anonymous domain and then authenticates in + // the popup window. + super(false /* enable auto login */, + Duration.ofMinutes(1) /* limit session duration to 1 minute */, + domain); + } + + @Override + public String createLoginUrl( + String machineUID, EntityFullJid peerFullJid, + EntityBareJid roomName, boolean popup) + { + // Login URL is configured/generated in the client + return null; + } + + @Override + public boolean isExternal() + { + return true; + } +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/auth/XMPPDomainAuthAuthority.java b/jicofo/src/main/java/org/jitsi/jicofo/auth/XMPPDomainAuthAuthority.java new file mode 100644 index 0000000..d44e53f --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/auth/XMPPDomainAuthAuthority.java @@ -0,0 +1,127 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.auth; + +import org.jitsi.xmpp.extensions.jitsimeet.*; +import org.jivesoftware.smack.packet.*; +import org.jxmpp.jid.*; + +import java.time.*; + +import static org.apache.commons.lang3.StringUtils.*; + +/** + * XMPP domain authentication authority that authorizes user who are logged + * in on specified domain. + * + * FIXME move to separate package + * + * @author Pawel Domas + */ +public class XMPPDomainAuthAuthority + extends AbstractAuthAuthority +{ + /** + * Trusted domain for which users are considered authenticated. + */ + private final DomainBareJid domain; + + /** + * Creates new instance of XMPPDomainAuthAuthority. + * + * @param enableAutoLogin disables auto login feature. Authentication + * sessions are destroyed immediately when the conference ends. + * @param authenticationLifetime specifies how long authentication sessions + * will be stored in Jicofo's memory. Interval in milliseconds. + * @param domain a string with XMPP domain name for which users will be + * considered authenticated. + */ + public XMPPDomainAuthAuthority(boolean enableAutoLogin, + Duration authenticationLifetime, + DomainBareJid domain) + { + super(enableAutoLogin, authenticationLifetime); + + this.domain = domain; + } + + private boolean verifyJid(Jid fullJid) + { + return fullJid.asDomainBareJid().equals(domain); + } + + @Override + protected IQ processAuthLocked(ConferenceIq query, ConferenceIq response) + { + Jid peerJid = query.getFrom(); + String sessionId = query.getSessionId(); + AuthenticationSession session = getSession(sessionId); + + // Check for invalid session + IQ error = verifySession(query); + if (error != null) + { + return error; + } + + // Create new session if JID is valid + if (session == null && verifyJid(peerJid)) + { + // Create new session + BareJid bareJid = peerJid.asBareJid(); + String machineUID = query.getMachineUID(); + if (isBlank(machineUID)) + { + return ErrorFactory.createNotAcceptableError(query, + "Missing mandatory attribute '" + + ConferenceIq.MACHINE_UID_ATTR_NAME + "'"); + } + session = createNewSession(machineUID, bareJid.toString(), query.getRoom()); + } + + // Authenticate JID with session(if it exists) + if (session != null) + { + authenticateJidWithSession(session, peerJid, response); + } + + return null; + } + + @Override + public String createLoginUrl( + String machineUID, + EntityFullJid peerFullJid, + EntityBareJid roomName, + boolean popup) + { + return "./" + roomName.getLocalpartOrThrow() + "?login=true"; + } + + @Override + public boolean isExternal() + { + return false; + } + + @Override + protected String createLogoutUrl(String sessionId) + { + return null; + } +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/codec/JingleOfferFactory.java b/jicofo/src/main/java/org/jitsi/jicofo/codec/JingleOfferFactory.java new file mode 100644 index 0000000..ba8c6f7 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/codec/JingleOfferFactory.java @@ -0,0 +1,187 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.codec; + +import org.jitsi.jicofo.*; +import org.jitsi.xmpp.extensions.jingle.*; + +import java.util.*; + +/** + * Contains factory methods for creating Jingle offer sent in 'session-invite' + * by Jitsi Meet conference focus. + * + * @author Pawel Domas + * @author George Politis + * @author Boris Grozev + * @author Lyubomir Marinov + */ +public class JingleOfferFactory +{ + public static final JingleOfferFactory INSTANCE = new JingleOfferFactory(); + + public List createOffer(OfferOptions options) + { + List contents = new ArrayList<>(); + if (options.getAudio()) + { + contents.add(createAudioContent(options)); + } + if (options.getVideo()) + { + contents.add(createVideoContent(options)); + } + if (JicofoConfig.config.enableSctp() && options.getSctp()) + { + contents.add(createDataContent(options)); + } + + return contents; + } + + /** + * Creates a {@link ContentPacketExtension} for the audio media type that + * will be included in initial conference offer. + * + * @return ContentPacketExtension for given media type that will be + * used in initial conference offer. + */ + private ContentPacketExtension createAudioContent(OfferOptions options) + { + ContentPacketExtension content = createContentPacketExtension("audio"); + addAudioToContent(content, options); + + return content; + } + + /** + * Creates a {@link ContentPacketExtension} for the data media type that + * will be included in initial conference offer. + * + * @return ContentPacketExtension for given media type that will be + * used in initial conference offer. + */ + private ContentPacketExtension createDataContent(OfferOptions options) + { + ContentPacketExtension content = createContentPacketExtension("data"); + addDataToContent(content); + + return content; + } + + /** + * Creates a {@link ContentPacketExtension} for the video media type that + * will be included in initial conference offer. + * + * @return ContentPacketExtension for given media type that will be + * used in initial conference offer. + */ + private ContentPacketExtension createVideoContent(OfferOptions options) + { + ContentPacketExtension videoContentPe = createContentPacketExtension("video"); + addVideoToContent(videoContentPe, options); + + return videoContentPe; + } + + /** + * Creates ContentPacketExtension initialized with type of + * the media and basic transport information based on given parameters. + * The creator attribute is set to "initiator" and "senders" to "both". + * + * @param name the Jingle name for the content + * + * @return new, parametrized instance of ContentPacketExtension. + */ + private static ContentPacketExtension createContentPacketExtension(String name) + { + ContentPacketExtension content + = new ContentPacketExtension(ContentPacketExtension.CreatorEnum.initiator, name); + + content.setSenders(ContentPacketExtension.SendersEnum.both); + + IceUdpTransportPacketExtension iceUdpTransportPacketExtension = new IceUdpTransportPacketExtension(); + iceUdpTransportPacketExtension.addChildExtension(new DtlsFingerprintPacketExtension()); + + content.addChildExtension(iceUdpTransportPacketExtension); + + return content; + } + + /** + * Adds the video-related extensions for an offer to a + * {@link ContentPacketExtension}. + * @param content the {@link ContentPacketExtension} to add extensions to. + */ + private void addVideoToContent(ContentPacketExtension content, OfferOptions options) + { + RtpDescriptionPacketExtension rtpDesc = new RtpDescriptionPacketExtension(); + + rtpDesc.setMedia("video"); + + CodecUtil.Companion.createVideoPayloadTypeExtensions(options).forEach(rtpDesc::addPayloadType); + CodecUtil.Companion.createVideoRtpHdrExtExtensions(options).forEach(rtpDesc::addExtmap); + if (Config.config.getExtmapAllowMixed()) + { + rtpDesc.setExtmapAllowMixed(new ExtmapAllowMixedPacketExtension()); + } + + content.addChildExtension(rtpDesc); + } + + /** + * Adds the audio-related extensions for an offer to a + * {@link ContentPacketExtension}. + * @param content the {@link ContentPacketExtension} to add extensions to. + */ + private static void addAudioToContent(ContentPacketExtension content, OfferOptions options) + { + RtpDescriptionPacketExtension rtpDesc = new RtpDescriptionPacketExtension(); + rtpDesc.setMedia("audio"); + + CodecUtil.Companion.createAudioRtpHdrExtExtensions(options).forEach(rtpDesc::addExtmap); + CodecUtil.Companion.createAudioPayloadTypeExtensions(options).forEach(rtpDesc::addPayloadType); + if (Config.config.getExtmapAllowMixed()) + { + rtpDesc.setExtmapAllowMixed(new ExtmapAllowMixedPacketExtension()); + } + + // a=maxptime:60 + rtpDesc.setAttribute("maxptime", "60"); + content.addChildExtension(rtpDesc); + } + + /** + * Adds the data-related extensions for an offer to a + * {@link ContentPacketExtension}. + * @param content the {@link ContentPacketExtension} to add extensions to. + */ + private static void addDataToContent(ContentPacketExtension content) + { + //SctpMapExtension sctpMap = new SctpMapExtension(); + //sctpMap.setPort(5000); + //sctpMap.setProtocol(SctpMapExtension.Protocol.WEBRTC_CHANNEL); + //sctpMap.setStreams(1024); + //content.addChildExtension(sctpMap); + + RtpDescriptionPacketExtension rdpe = new RtpDescriptionPacketExtension(); + rdpe.setMedia("application"); + + content.addChildExtension(rdpe); + } +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/conference/JitsiMeetConference.java b/jicofo/src/main/java/org/jitsi/jicofo/conference/JitsiMeetConference.java new file mode 100644 index 0000000..86844ae --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/conference/JitsiMeetConference.java @@ -0,0 +1,189 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2016-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference; + +import org.jetbrains.annotations.*; +import org.jitsi.jicofo.MediaType; +import org.jitsi.jicofo.bridge.*; +import org.jitsi.jicofo.jibri.*; +import org.jitsi.jicofo.xmpp.*; +import org.jitsi.jicofo.xmpp.muc.*; +import com.fasterxml.jackson.databind.*; +import org.jitsi.xmpp.extensions.jibri.*; +import org.jivesoftware.smack.packet.*; +import org.jxmpp.jid.*; + +import java.util.*; + +/** + * The conference interface extracted from {@link JitsiMeetConferenceImpl} for + * the unit tests purpose. There are many method that would be a good candidate + * for ending up in this interface, but until the only reason for this interface + * are tests, only methods actually used there are listed here. + * + * @author Pawel Domas + */ +public interface JitsiMeetConference extends XmppProvider.Listener +{ + /** + * Checks how many {@link Participant}s are in the conference. This includes visitors. + * @return an integer equal to or greater than 0. + */ + int getParticipantCount(); + + /** + * @return the JID of the main room if this is is breakout room, and null otherwise. + */ + EntityBareJid getMainRoomJid(); + + /** + * @return the JIDs of the connected visitor rooms. + */ + List getVisitorRoomsJids(); + + /** Return the number of visitors in the conference */ + long getVisitorCount(); + + /** Whether stats for this conference should be exported to rtcstats. */ + boolean isRtcStatsEnabled(); + + /** Get the meeting ID associated with the conference */ + @Nullable + String getMeetingId(); + + /** + * Find {@link Participant} for given MUC JID. + * + * @param mucJid participant's MUC jid (ex. "room@muc.server.com/nickname"). + * + * @return {@link Participant} instance or null if not found. + */ + Participant getParticipant(Jid mucJid); + + /** + * @return the set of regions of the bridges currently in the conference. + */ + @NotNull Set getBridgeRegions(); + + /** + * Returns the name of conference multi-user chat room. + */ + EntityBareJid getRoomName(); + + /** + * Returns ChatRoom2 instance for the MUC this instance is + * currently in or null if it isn't in any. + */ + @Nullable ChatRoom getChatRoom(); + + @Nullable + default JibriRecorder getJibriRecorder() + { + return null; + } + + @Nullable + default JibriSipGateway getJibriSipGateway() + { + return null; + } + + /** + * Gets the role of a member in the conference. + * @param jid the member whose role is to be determined. + * @return The member's role or null if the JID is not a member. + */ + MemberRole getRoleForMucJid(Jid jid); + + /** + * Whether this conference should be considered when generating statistics. + */ + boolean includeInStatistics(); + + /** + * Process a Jibri-related IQ. This could be a request coming from the client, or from a jibri instance. + * If the request is not related to this conference, this should return {@link IqProcessingResult.NotProcessed}. + */ + @NotNull IqProcessingResult handleJibriRequest(@NotNull IqRequest request); + + /** + * Used for av moderation, when we want to mute all participants. + * @param mediaType the media type we want to mute. + * @param actor the entity that requested the mute. + */ + void muteAllParticipants(MediaType mediaType, EntityFullJid actor); + + /** + * Return {@code true} if the user with the given JID should be allowed to invite jigasi to this conference. + */ + boolean acceptJigasiRequest(@NotNull Jid from); + + /** + * Handle a request to mute or unmute a participant. May block for a response from jitsi-videobridge. + * @param muterJid MUC jid of the participant that requested mute status change. + * @param toBeMutedJid MUC jid of the participant whose mute status will be changed. + * @param doMute {@code true} to mute, {@code false} to unmute. + * @param mediaType the {@link MediaType} of the channel to mute, either AUDIO or VIDEO. + * @return {@link MuteResult#NOT_ALLOWED} if {@code muterJid} is not allowed to mute/unmute, + * {@link MuteResult#ERROR} if the operation was not successful, and + * {@link MuteResult#SUCCESS} if it was successful. + */ + @NotNull + MuteResult handleMuteRequest( + @NotNull Jid muterJid, + @NotNull Jid toBeMutedJid, + boolean doMute, + @NotNull MediaType mediaType); + + @NotNull + JsonNode getDebugState(); + + /** Get the stats for this conference that should be exported to rtcstats. */ + @NotNull + JsonNode getRtcstatsState(); + + /** Move (reinvite) an endpoint in this conference. Return true if the endpoint was moved. */ + boolean moveEndpoint(@NotNull String endpointId, Bridge bridge); + + /** + * Move (reinvite) a specific number of endpoints from the conference from a specific bridge. The implementation + * decides which endpoints to move. + * + * @param bridge the bridge from which to move endpoints. + * @param numEps the number of endpoints to move. + * @return the number of endpoints moved. + */ + int moveEndpoints(@NotNull Bridge bridge, int numEps); + + /** Get information about the bridges currently used by this conference. */ + Map getBridges(); + + boolean isStarted(); + + @Nullable + String redirectVisitor(boolean visitorRequested, @Nullable String userId, @Nullable String groupId) + throws Exception; + + /** + * Notify this conference that the XMPP stream to the visitor node {@code node} was re-established without being + * resumed, so the MUC that this conference joined on that node (if any) is not joined anymore. + */ + void visitorConnectionReset(@NotNull String node); + + void setPresenceExtension(@NotNull ExtensionElement extension); +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/conference/JitsiMeetConferenceImpl.java b/jicofo/src/main/java/org/jitsi/jicofo/conference/JitsiMeetConferenceImpl.java new file mode 100644 index 0000000..f713586 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/conference/JitsiMeetConferenceImpl.java @@ -0,0 +1,3142 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference; + +import io.opentelemetry.api.trace.*; +import io.opentelemetry.context.*; +import kotlin.*; +import org.jetbrains.annotations.*; +import org.jitsi.jicofo.*; +import org.jitsi.jicofo.MediaType; +import org.jitsi.jicofo.auth.*; +import org.jitsi.jicofo.bridge.*; +import org.jitsi.jicofo.bridge.colibri.*; +import org.jitsi.jicofo.conference.source.*; +import org.jitsi.jicofo.conference.translation.*; +import org.jitsi.jicofo.metrics.IceRestartMetrics; +import org.jitsi.jicofo.util.*; +import org.jitsi.jicofo.util.TracingUtil; +import org.jitsi.jicofo.version.*; +import org.jitsi.jicofo.visitors.*; +import org.jitsi.jicofo.xmpp.*; +import org.jitsi.jicofo.xmpp.UtilKt; +import org.jitsi.jicofo.xmpp.jingle.JingleSession; +import org.jitsi.jicofo.xmpp.muc.*; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.node.*; +import org.jitsi.tracing.*; +import org.jitsi.utils.*; +import org.jitsi.utils.logging2.*; +import org.jitsi.utils.logging2.Logger; +import org.jitsi.xmpp.extensions.colibri2.*; +import org.jitsi.xmpp.extensions.jibri.*; +import org.jitsi.xmpp.extensions.jingle.*; + +import org.jitsi.xmpp.extensions.jitsimeet.*; +import org.jitsi.jicofo.jibri.*; + +import org.jitsi.xmpp.extensions.visitors.*; +import org.jivesoftware.smack.*; +import org.jivesoftware.smack.packet.*; +import org.jivesoftware.smack.tcp.*; +import org.jivesoftware.smackx.caps.*; +import org.jivesoftware.smackx.caps.packet.*; +import org.jxmpp.jid.*; + +import javax.xml.namespace.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.logging.*; +import java.util.stream.*; + +import static org.jitsi.jicofo.conference.ConferenceUtilKt.getVisitorMucJid; +import static org.jitsi.jicofo.xmpp.IqProcessingResult.*; +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.jitsi.jicofo.xmpp.MuteIqHandlerKt.createMuteIq; + +/** + * Represents a Jitsi Meet conference. Manages the Jingle sessions with the + * participants, as well as the COLIBRI session with the jitsi-videobridge + * instances used for the conference. + *

+ * A note on synchronization: this class uses a lot of 'synchronized' blocks, + * on 3 different objects {@link #participantLock}, {@code this} and {@code BridgeSession#octoParticipant}). + *

+ * This seems safe, but it is hard to maintain this way, and we should + * re-factor to simplify. + * + * @author Pawel Domas + * @author Boris Grozev + */ +public class JitsiMeetConferenceImpl + implements JitsiMeetConference, XmppProvider.Listener +{ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * Status used by participants when they are switching from a room to a breakout room. + */ + private static final String BREAKOUT_SWITCHING_STATUS = "switch_room"; + + /** + * Name of MUC room that is hosting Jitsi Meet conference. + */ + @NotNull + private final EntityBareJid roomName; + + /** + * {@link ConferenceListener} that will be notified + * about conference events. + */ + private final JitsiMeetConferenceImpl.ConferenceListener listener; + + @NotNull + private final Logger logger; + private final Tracer tracer = TracingGlobal.Companion.getSdk().getTracer("org.jitsi.jicofo.conference"); + + /** + * The instance of conference configuration. + */ + @NotNull + private final JitsiMeetConfig config; + + private final ChatRoomListener chatRoomListener = new ChatRoomListenerImpl(); + + /** + * Checks that the endpoints in this conference advertise the capabilities that the deployment requires, and + * notifies the ones that do not. + */ + private final ClientRequirementsHandler clientRequirementsHandler; + + /** + * Conference room chat instance. + */ + private volatile ChatRoom chatRoom; + + /** + * The JID of the main room if this is a breakout room, and {@code null} otherwise. + */ + @Nullable + private EntityBareJid mainRoomJid = null; + + /** + * Maps a visitor node ID (one of the values from {@link XmppConfig#getVisitors()}'s keys) to the {@link ChatRoom} + * on that node. + */ + private final Map visitorChatRooms = new ConcurrentHashMap<>(); + + /** + * Map of occupant JID to Participant. + */ + private final Map participants = new ConcurrentHashMap<>(); + + /** + * This lock is used to synchronise write access to {@link #participants}. + */ + private final Object participantLock = new Object(); + + /** + * A stat number of conference participants with a visitor muc role. + */ + private final RateLimitedStat visitorCount = new RateLimitedStat(VisitorsConfig.config.getNotificationInterval(), + (numVisitors) -> { + setConferenceProperty( + ConferenceProperties.KEY_VISITOR_COUNT, + Integer.toString(numVisitors)); + return null; + }); + + /** + * The aggregated count of visitors' supported codecs + */ + private final PreferenceAggregator visitorCodecs; + + /** + * The {@link JibriRecorder} instance used to provide live streaming through + * Jibri. + */ + private JibriRecorder jibriRecorder; + + /** + * The {@link JibriSipGateway} instance which provides video SIP gateway + * services for this conference. + */ + private JibriSipGateway jibriSipGateway; + + private ChatRoomRoleManager chatRoomRoleManager; + + /** + * Indicates if this instance has been started (initialized). + */ + private final AtomicBoolean started = new AtomicBoolean(false); + + /** + * A timeout task which will terminate media session of the user who is + * sitting alone in the room for too long. + */ + private Future singleParticipantTout; + + /** + * A task to stop the conference if no participants or breakout rooms are present after a timeout. + * It's triggered when the conference is first created, or when the last participant leaves with an indication + * that it will join a breakout room. + */ + private Future conferenceStartTimeout; + + private final Object conferenceStartTimeoutLock = new Object(); + + /** + * Reconnect timer. Used to stop the conference if XMPP connection is not restored in a given time. + */ + private Future reconnectTimeout; + + /** + * Whether participants being invited to the conference as a result of joining (as opposed to having already + * joined) should be invited with the "start audio muted" option. + */ + private boolean startAudioMuted = false; + + /** + * Whether participants being invited to the conference as a result of joining (as opposed to having already + * joined) should be invited with the "start video muted" option. + */ + private boolean startVideoMuted = false; + + /** + * The name of shared Etherpad document. Is advertised through MUC Presence + * by Jicofo user. + */ + private final String etherpadName; + + /** + * Maintains all colibri sessions for this conference. + */ + private ColibriSessionManager colibriSessionManager; + + /** + * Listener for events from {@link #colibriSessionManager}. + */ + private final ColibriSessionManagerListener colibriSessionManagerListener = new ColibriSessionManagerListener(); + + /** + * The conference properties that we advertise in presence in the XMPP MUC. + */ + private final ConcurrentHashMap conferenceProperties = new ConcurrentHashMap<>(); + + /** + * See {@link JitsiMeetConference#includeInStatistics()} + */ + private final boolean includeInStatistics; + + private final BridgeSelectorEventHandler bridgeSelectorEventHandler = new BridgeSelectorEventHandler(); + + @NotNull private final XmppServices xmppServices; + @NotNull private final BridgeSelector bridgeSelector; + @Nullable private final JibriDetector jibriDetector; + @Nullable private final JibriDetector sipJibriDetector; + @Nullable private final AuthenticationAuthority authenticationAuthority; + @NotNull private final FocusManager focusManager; + + /** Whether to enable transcription via a colibri export. */ + private boolean enableTranscription = false; + + /** + * Stores the sources advertised by all participants in the conference, mapped by their JID. + */ + private final ValidatingConferenceSourceMap conferenceSources = new ValidatingConferenceSourceMap( + ConferenceConfig.config.getMaxSsrcsPerUser(), + ConferenceConfig.config.getMaxSsrcGroupsPerUser() + ); + + /** + * Manages live-translation synthetic sources and the translator connect, driven by the + * {@code audioTranslationRequests} room metadata. + */ + private final ConferenceTranslationManager translationManager; + + /** + * Whether the limit on the number of audio senders is currently hit. + */ + private boolean audioLimitReached = false; + + /** + * Whether the limit on the number of video senders is currently hit. + */ + private boolean videoLimitReached = false; + + /** + * Requested bridge version from a pin. null if not pinned. + */ + private final String jvbVersion; + + /** + * Whether broadcasting to visitors is currently enabled. + * TODO: support changing it. + */ + private final boolean visitorsBroadcastEnabled = VisitorsConfig.config.getAutoEnableBroadcast(); + + @NotNull private final Instant createdInstant = Instant.now(); + + /** + * The unique meeting ID for this conference. We expect this to be set by the XMPP server in the MUC config form. + * If for some reason it's not present, we'll generate a local UUID. The field is nullable, but always non-null + * after the MUC has been joined. + */ + @Nullable + private String meetingId; + + /** Presence extensions set from the outside which are to be added to the presence in each MUC. */ + private final Map presenceExtensions = new ConcurrentHashMap<>(); + + /** + * Creates new instance of {@link JitsiMeetConferenceImpl}. + * + * @param roomName name of MUC room that is hosting the conference. + * @param listener the listener that will be notified about this instance events. + * @param logLevel (optional) the logging level to be used by this instance. See {@link #logger} for more details. + */ + public JitsiMeetConferenceImpl( + @NotNull EntityBareJid roomName, + ConferenceListener listener, + @NotNull Map properties, + Level logLevel, + String jvbVersion, + boolean includeInStatistics, + @NotNull XmppServices xmppServices, + @NotNull BridgeSelector bridgeSelector, + @Nullable JibriDetector jibriDetector, + @Nullable JibriDetector sipJibriDetector, + @Nullable AuthenticationAuthority authenticationAuthority, + @NotNull FocusManager focusManager) + { + logger = new LoggerImpl(JitsiMeetConferenceImpl.class.getName(), logLevel); + logger.addContext("room", roomName.toString()); + + translationManager = new ConferenceTranslationManager(conferenceSources, logger); + clientRequirementsHandler = new ClientRequirementsHandler(logger); + + this.config = new JitsiMeetConfig(properties); + + this.roomName = roomName; + this.listener = listener; + this.etherpadName = createSharedDocumentName(); + this.includeInStatistics = includeInStatistics; + + this.xmppServices = xmppServices; + this.bridgeSelector = bridgeSelector; + this.jibriDetector = jibriDetector; + this.sipJibriDetector = sipJibriDetector; + this.authenticationAuthority = authenticationAuthority; + this.focusManager = focusManager; + this.jvbVersion = jvbVersion; + + scheduleConferenceStartTimeout(); + + visitorCodecs = new PreferenceAggregator( + logger, + (codecs) -> { + setConferenceProperty( + ConferenceProperties.KEY_VISITOR_CODECS, + String.join(",", codecs) + ); + return null; + }); + + logger.info("Created new conference."); + } + + @Override + @Nullable + public String getMeetingId() + { + return meetingId; + } + + @Nullable + @Override + public EntityBareJid getMainRoomJid() + { + return mainRoomJid; + } + + @Override + public List getVisitorRoomsJids() + { + return this.visitorChatRooms.values().stream().map(ChatRoom::getRoomJid) + .collect(Collectors.toList()); + } + + /** + * @return the colibri session manager, late init. + */ + private ColibriSessionManager getColibriSessionManager() + { + if (colibriSessionManager == null) + { + // We initialize colibriSessionManager only after having joined the room, so meetingId must be set. + String meetingId = Objects.requireNonNull(this.meetingId); + colibriSessionManager = new ColibriV2SessionManager( + xmppServices.getServiceConnection().getXmppConnection(), + bridgeSelector, + getRoomName().toString(), + meetingId, + config.getRtcStatsEnabled(), + jvbVersion, + logger); + colibriSessionManager.addListener(colibriSessionManagerListener); + + // Configure transcription if enabled, including custom headers and URL params + if (enableTranscription) + { + TemplatedUrl uri = TranscriptionConfig.config.getUrl(meetingId); + if (uri != null) + { + Pair, Map> transcriptionParams = + TranscriptionConfig.processTranscriptionMetadata( + chatRoom != null ? chatRoom.getTranscription() : null, + TranscriptionConfig.config.getHttpHeaders()); + colibriSessionManager.setTranscriberUrl( + uri, + transcriptionParams.getFirst(), + transcriptionParams.getSecond()); + + // Apply the text-translation languages the room already asked for before colibri existed. + if (chatRoom != null) + { + colibriSessionManager.setTextTranslationLanguages(chatRoom.getTranslationLanguages()); + } + } + } + + // Apply any live-translation requests received before colibri was initialized. + translationManager.reapply(colibriSessionManager, meetingId); + } + return colibriSessionManager; + } + + /** + * Starts conference focus processing, bind listeners and so on... + * + * @throws Exception if error occurs during initialization. Instance is + * considered broken in that case. Its stop method will be called + * before throwing the exception to perform deinitialization where + * possible. {@link ConferenceListener}s will be notified that this + * conference has ended. + */ + public void start() + throws Exception + { + if (!started.compareAndSet(false, true)) + { + return; + } + + try + { + XmppProvider clientXmppProvider = getClientXmppProvider(); + + bridgeSelector.addHandler(bridgeSelectorEventHandler); + + if (clientXmppProvider.getRegistered()) + { + joinTheRoom(); + } + + if (jibriDetector != null) + { + jibriRecorder + = new JibriRecorder( + this, + jibriDetector, + logger); + } + + if (sipJibriDetector != null) + { + jibriSipGateway + = new JibriSipGateway( + this, + sipJibriDetector, + logger); + } + } + catch (Exception e) + { + try + { + stop(); + } + catch (Exception x) + { + logger.warn("An exception was caught while invoking stop()", x); + } + + throw e; + } + } + + /** + * Stops the conference, disposes colibri channels and releases all + * resources used by the focus. + */ + public void stop() + { + if (!started.compareAndSet(true, false)) + { + return; + } + + visitorCount.stop(); + + if (jibriSipGateway != null) + { + try + { + jibriSipGateway.shutdown(); + } + catch (Exception e) + { + logger.error("jibriSipGateway.shutdown error", e); + } + jibriSipGateway = null; + } + + if (jibriRecorder != null) + { + try + { + jibriRecorder.shutdown(); + } + catch (Exception e) + { + logger.error("jibriRecorder.shutdown error", e); + } + jibriRecorder = null; + } + + bridgeSelector.removeHandler(bridgeSelectorEventHandler); + + if (colibriSessionManager != null) + { + colibriSessionManager.removeListener(colibriSessionManagerListener); + } + + try + { + expireBridgeSessions(); + } + catch (Exception e) + { + logger.error("disposeConference error", e); + } + + try + { + leaveTheRoom(); + } + catch (Exception e) + { + logger.error("leaveTheRoom error", e); + } + + logger.info("Stopped."); + if (includeInStatistics()) + { + ConferenceMetrics.conferenceSeconds.addAndGet( + Duration.between(createdInstant, Instant.now()).toMillis() / 1000.0); + } + if (listener != null) + { + listener.conferenceEnded(this); + } + } + + /** + * Returns true if the conference has been successfully started. + */ + @Override + public boolean isStarted() + { + return started.get(); + } + + /** + * Initialize {@link #meetingId}, given an optional value coming from the chat room configuration. If no valid + * meeting ID is provided, a random UUID will be generated. + * @param chatRoomMeetingId the meeting ID that was set in the chat room configuration. + * @throws RuntimeException if the meeting ID is already in use by another conference. + */ + private void setMeetingId(String chatRoomMeetingId) + { + if (meetingId != null) + { + logger.error("Meeting ID is already set: " + meetingId + ", will not replace."); + return; + } + + String meetingId; + if (isBlank(chatRoomMeetingId)) + { + meetingId = UUID.randomUUID().toString(); + if (includeInStatistics) + { + logger.warn("No meetingId set for the MUC. Generating one locally."); + } + } + else + { + meetingId = chatRoomMeetingId; + } + + if (!listener.meetingIdSet(this, meetingId)) + { + logger.error("Failed to set a unique meeting ID."); + throw new RuntimeException("Failed to set a unique meeting ID."); + } + + this.meetingId = meetingId; + logger.addContext("meeting_id", meetingId); + } + /** + * Joins the conference room. + * + * @throws Exception if we have failed to join the room for any reason + */ + private void joinTheRoom() + throws Exception + { + logger.info("Joining " + roomName); + + ChatRoom chatRoom = getClientXmppProvider().findOrCreateRoom(roomName, logger.getLevel()); + this.chatRoom = chatRoom; + chatRoom.addListener(chatRoomListener); + + ChatRoomInfo chatRoomInfo = chatRoom.join(); + setMeetingId(chatRoomInfo.getMeetingId()); + + mainRoomJid = chatRoomInfo.getMainRoomJid(); + + if (authenticationAuthority != null) + { + chatRoomRoleManager = new AuthenticationRoleManager(chatRoom, authenticationAuthority); + chatRoom.addListener(chatRoomRoleManager); + chatRoomRoleManager.grantOwnership(); + } + // We do not use auto-owner in breakout rooms. + else if (ConferenceConfig.config.enableAutoOwner() && mainRoomJid == null) + { + chatRoomRoleManager = new AutoOwnerRoleManager(chatRoom); + chatRoom.addListener(chatRoomRoleManager); + chatRoomRoleManager.grantOwnership(); + } + + Collection presenceExtensions = new ArrayList<>(); + + // Advertise shared Etherpad document + presenceExtensions.add(EtherpadPacketExt.forDocumentName(etherpadName)); + + ComponentVersionsExtension versionsExtension = new ComponentVersionsExtension(); + versionsExtension.addComponentVersion( + ComponentVersionsExtension.COMPONENT_FOCUS, + CurrentVersionImpl.VERSION.toString()); + presenceExtensions.add(versionsExtension); + + setConferenceProperty( + ConferenceProperties.KEY_SUPPORTS_SESSION_RESTART, + Boolean.TRUE.toString(), + false); + + if (VisitorsConfig.config.getEnabled()) + { + setConferenceProperty( + ConferenceProperties.KEY_VISITORS_ENABLED, + Boolean.TRUE.toString(), + false + ); + } + + presenceExtensions.add(createConferenceProperties()); + this.presenceExtensions.forEach((qName, extension) -> presenceExtensions.add(extension)); + + // updates presence with presenceExtensions and sends it + chatRoom.addPresenceExtensions(presenceExtensions); + } + + /** + * Sets a conference property and sends an updated presence stanza in the + * MUC. + * @param key the key of the property. + * @param value the value of the property. + */ + private void setConferenceProperty(@NotNull String key, @NotNull String value) + { + setConferenceProperty(key, value, true); + } + + /** + * Sets a conference property and optionally (depending on + * {@code updatePresence}) sends an updated presence stanza in the + * MUC. + * @param key the key of the property. + * @param value the value of the property. + * @param updatePresence {@code true} to send an updated presence stanza, + * and {@code false} to only add the property locally. This is useful to + * allow updating multiple properties but sending a single presence update. + */ + private void setConferenceProperty(@NotNull String key, @NotNull String value, boolean updatePresence) + { + String oldValue = conferenceProperties.put(key, value); + ChatRoom chatRoom = this.chatRoom; + if (updatePresence && chatRoom != null && !value.equals(oldValue)) + { + ConferenceProperties newProps = createConferenceProperties(); + chatRoom.setPresenceExtension(newProps); + + for (final ChatRoom visitorChatRoom: visitorChatRooms.values()) + { + visitorChatRoom.setPresenceExtension(newProps); + } + } + } + + @Override + public void setPresenceExtension(@NotNull ExtensionElement extension) + { + presenceExtensions.put(extension.getQName(), extension); + + ChatRoom chatRoom = this.chatRoom; + if (chatRoom != null) + { + chatRoom.setPresenceExtension(extension); + } + for (final ChatRoom visitorChatRoom: visitorChatRooms.values()) + { + visitorChatRoom.setPresenceExtension(extension); + } + } + + private ConferenceProperties createConferenceProperties() + { + ConferenceProperties conferenceProperties = new ConferenceProperties(); + this.conferenceProperties.forEach(conferenceProperties::put); + return conferenceProperties; + } + + /** + * Process the new number of audio senders reported by the chat room. + */ + private void onNumAudioSendersChanged(int numAudioSenders) + { + boolean newValue = numAudioSenders >= ConferenceConfig.config.getMaxAudioSenders(); + if (audioLimitReached != newValue) + { + audioLimitReached = newValue; + setConferenceProperty("audio-limit-reached", String.valueOf(audioLimitReached)); + } + } + + /** + * Process the new number of video senders reported by the chat room. + */ + private void onNumVideoSendersChanged(int numVideoSenders) + { + boolean newValue = numVideoSenders >= ConferenceConfig.config.getMaxVideoSenders(); + if (videoLimitReached != newValue) + { + videoLimitReached = newValue; + setConferenceProperty("video-limit-reached", String.valueOf(videoLimitReached)); + } + } + + /** + * Leaves the conference room. + */ + private void leaveTheRoom() + { + if (chatRoom == null) + { + logger.error("Chat room already left!"); + return; + } + + if (chatRoomRoleManager != null) + { + chatRoom.removeListener(chatRoomRoleManager); + chatRoomRoleManager.stop(); + } + + // first disconnect vnodes before leaving + final List disconnectVnodeExtensions; + final List visitorChatRoomsToLeave; + synchronized (visitorChatRooms) + { + disconnectVnodeExtensions = visitorChatRooms.keySet().stream() + .map(DisconnectVnodePacketExtension::new).collect(Collectors.toList()); + visitorChatRoomsToLeave = new ArrayList<>(visitorChatRooms.values()); + visitorChatRooms.clear(); + } + + final ChatRoom chatRoomToLeave = chatRoom; + chatRoom.removeListener(chatRoomListener); + chatRoom = null; + + TaskPools.getIoPool().submit(() -> + { + if (!disconnectVnodeExtensions.isEmpty()) + { + xmppServices.getVisitorsManager() + .sendIqToComponentAndGetResponse(roomName, disconnectVnodeExtensions); + } + + visitorChatRoomsToLeave.forEach(visitorChatRoom -> + { + try + { + visitorChatRoom.removeAllListeners(); + visitorChatRoom.leave(); + } + catch (Exception e) + { + logger.error("Failed to leave visitor room", e); + } + }); + + chatRoomToLeave.leave(); + }); + } + + /** + * Handles a new {@link ChatRoomMember} joining the {@link ChatRoom}: invites it as a {@link Participant} to the + * conference if there are enough members. + */ + private void onMemberJoined(@NotNull ChatRoomMember chatRoomMember) + { + // Detect a race condition in which this thread runs before EntityCapsManager's async StanzaListener that + // populates the JID to NodeVerHash cache. If that's the case calling getFeatures() would result in an + // unnecessary disco#info request being sent. That's not an unrecoverable problem, but just yielding should + // avoid sending disco#info in most cases. + Presence presence = chatRoomMember.getPresence(); + CapsExtension caps = presence == null ? null : presence.getExtension(CapsExtension.class); + if ((caps != null) && caps.getHash() != null + && EntityCapsManager.getNodeVerHashByJid(chatRoomMember.getOccupantJid()) == null) + { + logger.info("Caps extension present, but JID does not exist in EntityCapsManager."); + Thread.yield(); + } + + // Trigger feature discovery before we acquire the lock. The features will be saved in the ChatRoomMember + // instance, and the call might block for a disco#info request. + chatRoomMember.getFeatures(); + + synchronized (participantLock) + { + cancelConferenceStartTimeout(); + // Make sure it's still a member of the room. + if (chatRoomMember.getChatRoom().getChatMember(chatRoomMember.getOccupantJid()) != chatRoomMember) + { + logger.warn("ChatRoomMember is no longer a member of its room. Will not invite."); + return; + } + + if (chatRoomMember.getRole() == MemberRole.VISITOR && !VisitorsConfig.config.getEnabled()) + { + logger.warn("Ignoring a visitor because visitors are not configured:" + chatRoomMember.getName()); + return; + } + + String room = ", room="; + if (chatRoomMember.getChatRoom() == chatRoom) + { + room += "main"; + } + else + { + room += chatRoomMember.getChatRoom().getRoomJid(); + } + logger.info( + "Member joined:" + chatRoomMember.getName() + + " stats-id=" + chatRoomMember.getStatsId() + + " region=" + chatRoomMember.getRegion() + + " audioMuted=" + chatRoomMember.isAudioMuted() + + " videoMuted=" + chatRoomMember.isVideoMuted() + + " role=" + chatRoomMember.getRole() + + " isJibri=" + chatRoomMember.isJibri() + + " isJigasi=" + chatRoomMember.isJigasi() + + " isTranscriber=" + chatRoomMember.isTranscriber() + + room); + + // Are we ready to start ? + if (!checkMinParticipants()) + { + return; + } + + // Cancel single participant timeout when someone joins ? + cancelSingleParticipantTimeout(); + + if (participants.isEmpty()) + { + inviteAllChatMembers(); + } + // Only the one who has just joined + else + { + inviteChatMember(chatRoomMember); + } + } + } + + private void inviteAllChatMembers() + { + for (final ChatRoomMember member : chatRoom.getMembers()) + { + inviteChatMember(member); + } + for (final ChatRoom visitorChatRoom: visitorChatRooms.values()) + { + for (final ChatRoomMember member : visitorChatRoom.getMembers()) + { + if (member.getRole() == MemberRole.VISITOR) + { + inviteChatMember(member); + } + } + } + } + + /** + * Adds a {@link ChatRoomMember} to the conference. Creates the + * {@link Participant} instance corresponding to the {@link ChatRoomMember}. + * established and videobridge channels being allocated. + * + * @param chatRoomMember the chat member to be invited into the conference. + */ + private void inviteChatMember(ChatRoomMember chatRoomMember) + { + synchronized (participantLock) + { + // Participant already connected ? + if (participants.get(chatRoomMember.getOccupantJid()) != null) + { + return; + } + + // Discover the supported features early, so that any code that depends on the Participant's features works + // with the correct values. Note that this operation will block waiting for a disco#info response when + // the hash is not cached. In practice this should happen rarely (once for each unique set of features), + // and when it does happen we only block the Smack thread processing presence *for this conference/MUC*. + Set features = chatRoomMember.getFeatures(); + + // An endpoint which does not advertise the capabilities that the deployment requires is not invited. It + // stays in the MUC, so it can still use the features which do not need a media session (e.g. chat). + if (!clientRequirementsHandler.checkAndNotify(chatRoomMember)) + { + return; + } + + logger.info("Creating participant " + chatRoomMember.getName() + " with features=" + features); + final Participant participant = new Participant( + chatRoomMember, + this, + xmppServices.getJingleHandler(), + logger, + features); + + ConferenceMetrics.participants.inc(); + if (!features.contains(Features.START_MUTED_RMD)) + { + ConferenceMetrics.participantsNoStartMutedRmd.inc(); + } + + boolean added = (participants.put(chatRoomMember.getOccupantJid(), participant) == null); + if (added) + { + if (participant.getChatMember().getRole() == MemberRole.VISITOR) + { + visitorAdded(participant.getChatMember().getVideoCodecs()); + } + } + + inviteParticipant(participant, false, true); + } + } + + /** + * Invites a {@link Participant} to the conference. Selects the bridge to use and starts a new + * {@link ParticipantInviteRunnable} to allocate COLIBRI channels and initiate + * a Jingle session with the {@link Participant}. + * @param participant the participant to invite. + * @param reInvite whether the participant is to be re-invited or invited for the first time. + */ + private void inviteParticipant(@NotNull Participant participant, boolean reInvite, boolean justJoined) + { + // Colibri channel allocation and jingle invitation take time, so schedule them on a separate thread. + ParticipantInviteRunnable channelAllocator = new ParticipantInviteRunnable( + this, + getColibriSessionManager(), + participant, + hasToStartAudioMuted(justJoined), + hasToStartVideoMuted(justJoined), + reInvite, + logger + ); + + participant.setInviteRunnable(channelAllocator); + TaskPools.getIoPool().execute(Context.current().wrap(channelAllocator)); + } + + @NotNull EndpointSourceSet getSourcesForParticipant(@NotNull Participant participant) + { + EndpointSourceSet s = conferenceSources.get(participant.getEndpointId()); + return s != null ? s : EndpointSourceSet.EMPTY; + } + + /** + * Returns true if a participant should be invited with the "start audio muted" option given that they just + * joined or are being re-invited (depending on the value of {@code justJoined}. + */ + private boolean hasToStartAudioMuted(boolean justJoined) + { + if (startAudioMuted && justJoined) + { + return true; + } + + int limit = ConferenceConfig.config.getMaxAudioSenders(); + Integer startAudioMutedInt = config.getStartAudioMuted(); + if (startAudioMutedInt != null) + { + limit = Math.min(limit, startAudioMutedInt); + } + return getParticipantCount() > limit; + } + + /** + * Returns true if a participant should be invited with the "start video muted" option given that they just + * joined or are being re-invited (depending on the value of {@code justJoined}. + */ + private boolean hasToStartVideoMuted(boolean justJoined) + { + if (startVideoMuted && justJoined) + { + return true; + } + + int limit = ConferenceConfig.config.getMaxVideoSenders(); + Integer startVideoMutedInt = config.getStartVideoMuted(); + if (startVideoMutedInt != null) + { + limit = Math.min(limit, startVideoMutedInt); + } + return getParticipantCount() > limit; + } + + /** + * Returns {@code true} if there are enough participants in the room to start a conference. + */ + private boolean checkMinParticipants() + { + ChatRoom chatRoom = getChatRoom(); + if (chatRoom == null) + { + return false; + } + int minParticipants = ConferenceConfig.config.getMinParticipants(); + // When transcribing is enabled, start sessions immediately without waiting for min-participants. + if (enableTranscription) + { + minParticipants = 1; + } + // Members which were not invited because they miss a required capability do not count, because they can not + // send or receive media. + int memberCount = chatRoom.getMemberCount() + + visitorChatRooms.values().stream().mapToInt(ChatRoom::getMemberCount).sum() + - clientRequirementsHandler.getRejectedCount(); + return memberCount >= minParticipants; + } + + /** + * Expires all COLIBRI conferences. + */ + private void expireBridgeSessions() + { + // If the conference is being disposed the timeout is not needed + // anymore + cancelSingleParticipantTimeout(); + + if (colibriSessionManager != null) + { + colibriSessionManager.expire(); + } + } + + private void onMemberKicked(ChatRoomMember chatRoomMember) + { + synchronized (participantLock) + { + logger.info("Member kicked: " + chatRoomMember.getName()); + + onMemberLeft(chatRoomMember); + } + } + + private void onMemberLeft(ChatRoomMember chatRoomMember) + { + synchronized (participantLock) + { + logger.info("Member left:" + chatRoomMember.getName()); + boolean wasRejected = clientRequirementsHandler.memberLeft(chatRoomMember); + Participant leftParticipant = participants.get(chatRoomMember.getOccupantJid()); + if (leftParticipant != null) + { + // We don't send source-remove, because the participant leaving the MUC will notify other participants + // that the sources need to be removed (and we want to minimize signaling in large conferences). + terminateParticipant( + leftParticipant, + Reason.GONE, + null, + /* no need to send session-terminate - gone */ false, + /* no need to send source-remove */ false, + /* not reinviting */ false); + } + else if (!wasRejected) + { + logger.warn("Participant not found for " + chatRoomMember.getName() + + ". Terminated already or never started?"); + } + + if (participants.size() == 1) + { + rescheduleSingleParticipantTimeout(); + } + else if (participants.isEmpty()) + { + expireBridgeSessions(); + } + } + + maybeStop(chatRoomMember); + } + + /** + * Stop the conference if there are no members and there are no associated breakout room. + * @param chatRoomMember The participant leaving if any. + */ + private void maybeStop(ChatRoomMember chatRoomMember) + { + ChatRoom chatRoom = this.chatRoom; + if (chatRoom == null || chatRoom.getMemberCount() == 0) + { + if (focusManager.hasBreakoutRooms(roomName)) + { + logger.info("Breakout rooms still present, will not stop."); + } + else if (chatRoomMember != null + && chatRoomMember.getPresence() != null + && BREAKOUT_SWITCHING_STATUS.equals(chatRoomMember.getPresence().getStatus())) + { + logger.info("Member moving to breakout room, will not stop."); + scheduleConferenceStartTimeout(); + } + else + { + logger.info("Last member left, stopping."); + stop(); + } + } + } + + /** + * Signal to this conference that one of its associated breakout conferences ended. + */ + public void breakoutConferenceEnded() + { + maybeStop(null); + } + + private void terminateParticipant( + Participant participant, + @NotNull Reason reason, + String message, + boolean sendSessionTerminate, + boolean sendSourceRemove, + boolean willReinvite) + { + logger.info(String.format( + "Terminating %s, reason: %s, send session-terminate: %s", + participant.getChatMember().getName(), + reason, + sendSessionTerminate)); + + synchronized (participantLock) + { + participant.terminateJingleSession(reason, message, sendSessionTerminate); + + // We can use updateParticipant=false here, because we'll call removeParticipant below. + removeParticipantSources(participant, sendSourceRemove, false); + + Participant removed = participants.remove(participant.getChatMember().getOccupantJid()); + logger.info( + "Removed participant " + participant.getChatMember().getName() + " removed=" + (removed != null)); + if (!willReinvite && removed != null) + { + if (includeInStatistics()) + { + ConferenceMetrics.endpointSeconds.addAndGet(participant.durationSeconds()); + } + if (removed.getChatMember().getRole() == MemberRole.VISITOR) + { + visitorRemoved(removed.getChatMember().getVideoCodecs()); + } + } + } + + getColibriSessionManager().removeParticipant(participant.getEndpointId()); + } + + @Override + public void componentsChanged(@NotNull Set components) + { + } + + @Override + public void registrationChanged(boolean registered) + { + if (registered) + { + logger.info("XMPP reconnected"); + + if (this.reconnectTimeout != null) + { + this.reconnectTimeout.cancel(true); + this.reconnectTimeout = null; + } + else + { + logger.error("Reconnected but not supposed to be here:" + roomName); + } + + XMPPConnection connection = chatRoom.getXmppProvider().getXmppConnection(); + if (connection instanceof XMPPTCPConnection && !((XMPPTCPConnection) connection).streamWasResumed()) + { + logger.error("Reconnected without resuming, give up and stop."); + + // This is a connect without resumption, so make sure we fix the state, by stopping + // all clients will reload the state will be fine when they invite us again. + stop(); + } + } + else + { + logger.info("XMPP disconnected."); + XMPPConnection connection = chatRoom.getXmppProvider().getXmppConnection(); + + if (connection instanceof XMPPTCPConnection + && ((XMPPTCPConnection) connection).isSmEnabled()) + { + logger.info("XMPP will wait for a reconnect."); + reconnectTimeout = TaskPools.getScheduledPool().schedule( + this::stop, + XmppConfig.client.getReplyTimeout().toMillis(), + TimeUnit.MILLISECONDS); + } + else + { + stop(); + } + } + } + + @Nullable + public Participant getParticipant(@NotNull Jid occupantJid) + { + return participants.get(occupantJid); + } + + @Override + public MemberRole getRoleForMucJid(Jid mucJid) + { + if (chatRoom == null) + { + return null; + } + + for (ChatRoomMember member : chatRoom.getMembers()) + { + if (member.getOccupantJid().equals(mucJid)) + { + return member.getRole(); + } + } + return null; + } + + /** + * Handles a notification that a participant's ICE session failed. + * + * @param participant the participant whose ICE session failed. + * @param bridgeSessionId the ID of the bridge session that failed. + */ + public void iceFailed(@NotNull Participant participant, String bridgeSessionId) + { + Pair existingBridgeSession + = getColibriSessionManager().getBridgeSessionId(participant.getEndpointId()); + if (Objects.equals(bridgeSessionId, existingBridgeSession.getSecond())) + { + logger.info(String.format( + "Received ICE failed notification from %s, bridge-session ID: %s", + participant.getEndpointId(), + bridgeSessionId)); + if (existingBridgeSession.getFirst() != null) + { + existingBridgeSession.getFirst().endpointRequestedRestart(); + } + reInviteParticipant(participant); + } + else + { + logger.info(String.format( + "Ignored ICE failed notification for invalid session, participant: %s, bridge session ID: %s", + participant.getEndpointId(), + bridgeSessionId)); + } + } + + /** + * Handles a request from a {@link Participant} for an in-place ICE restart. The bridge is asked to create a new + * ICE agent with fresh credentials while the existing one keeps carrying media; its response is handled + * asynchronously and relayed back to the participant via + * {@link ColibriSessionManager.Listener#endpointIceRestarted}. + * + * @param bridgeSessionId the ID of the bridge session the participant wants restarted. + * + * @throws InvalidBridgeSessionIdException if bridgeSessionId doesn't match the ID of the (colibri) session that + * the participant currently has. + */ + void iceRestart(@NotNull Participant participant, String bridgeSessionId) + throws InvalidBridgeSessionIdException + { + Pair existingBridgeSession + = getColibriSessionManager().getBridgeSessionId(participant.getEndpointId()); + if (!Objects.equals(bridgeSessionId, existingBridgeSession.getSecond())) + { + throw new InvalidBridgeSessionIdException(bridgeSessionId + " is not a currently active session"); + } + + // Note that unlike a full restart request we intentionally do not call Bridge.endpointRequestedRestart(): an + // in-place ICE restart is normally triggered by the client's network changing and is not a signal that the + // bridge is failing ICE. + logger.info("ICE restart: requesting one from the bridge for " + participant.getEndpointId() + + ", bridge-session ID: " + bridgeSessionId); + getColibriSessionManager().restartIce(participant.getEndpointId()); + } + + /** + * Handles a request from a {@link Participant} to terminate its session and optionally start it again. + * + * @param bridgeSessionId the ID of the bridge session that the participant requested to be terminated. + * @param reinvite whether to start a new session after the current one is terminated. + * + * @throws InvalidBridgeSessionIdException if bridgeSessionId doesn't match the ID of the (colibri) session that + * the participant currently has. + */ + void terminateSession( + @NotNull Participant participant, + String bridgeSessionId, + boolean reinvite) + throws InvalidBridgeSessionIdException + { + // TODO: maybe move the bridgeSessionId logic to Participant + Pair existingBridgeSession + = getColibriSessionManager().getBridgeSessionId(participant.getEndpointId()); + if (!Objects.equals(bridgeSessionId, existingBridgeSession.getSecond())) + { + throw new InvalidBridgeSessionIdException(bridgeSessionId + " is not a currently active session"); + } + + if (reinvite && existingBridgeSession.getFirst() != null) + { + existingBridgeSession.getFirst().endpointRequestedRestart(); + } + + synchronized (participantLock) + { + terminateParticipant( + participant, + Reason.SUCCESS, + (reinvite) ? "reinvite requested" : null, + /* do not send session-terminate */ false, + /* do send source-remove */ true, + reinvite); + + if (reinvite) + { + participants.put(participant.getChatMember().getOccupantJid(), participant); + inviteParticipant(participant, false, false); + } + } + } + + /** + * Advertises new sources across all conference participants by using + * 'source-add' Jingle notification. + * + * @param sourceOwner the Participant who owns the sources. + * @param sources the sources to propagate. + */ + private void propagateNewSources(Participant sourceOwner, EndpointSourceSet sources) + { + if (sources.isEmpty()) + { + logger.debug("No new sources to propagate."); + return; + } + + final ConferenceSourceMap conferenceSourceMap = new ConferenceSourceMap(sourceOwner.getEndpointId(), sources); + + participants.values().stream() + .filter(otherParticipant -> otherParticipant != sourceOwner) + .forEach(participant -> participant.addRemoteSources(conferenceSourceMap)); + } + + + /** + * Update the transport information for a participant. Callback called when we receive a 'transport-info', the info + * is forwarded to the videobridge. + */ + public void updateTransport(@NotNull Participant participant, @NotNull IceUdpTransportPacketExtension transport) + { + getColibriSessionManager().updateParticipant( + participant.getEndpointId(), + transport, + null /* no change in sources, just transport */, + null, + false); + } + + /** + * Attempts to add sources from {@code participant} to the conference. + * + * @param participant the participant that is adding the sources. + * @param sourcesAdvertised the sources that the participant is adding + * + * @throws SenderCountExceededException if the sender limits in the conference have been exceeded + * @throws ValidationFailedException if the addition of the sources would result in an invalid state of the + * conference sources (e.g. if there is a conflict with another participant, or the resulting source set for the + * participant is invalid). + */ + public void addSource( + @NotNull Participant participant, + @NotNull EndpointSourceSet sourcesAdvertised) + throws SenderCountExceededException, ValidationFailedException + { + boolean rejectedAudioSource = sourcesAdvertised.getHasAudio() && + chatRoom.getAudioSendersCount() >= ConferenceConfig.config.getMaxAudioSenders(); + boolean rejectedVideoSource = sourcesAdvertised.getHasVideo() && + chatRoom.getVideoSendersCount() >= ConferenceConfig.config.getMaxVideoSenders(); + + if (rejectedAudioSource || rejectedVideoSource) + { + throw new SenderCountExceededException( + "Sender count exceeded for: " + (rejectedAudioSource ? "audio " : "") + + (rejectedVideoSource ? "video" : "")); + } + + EndpointSourceSet sourcesAccepted = conferenceSources.tryToAdd(participant.getEndpointId(), sourcesAdvertised); + logger.debug(() -> "Accepted sources from " + participant.getEndpointId() + ": " + sourcesAccepted); + + if (sourcesAccepted.isEmpty()) + { + // This shouldn't happen as the sources were non-empty, but none were accepted (there should have been an + // exception above) + logger.warn("Stop processing source-add, no new sources added: " + participant.getEndpointId()); + return; + } + + // Updates source groups on the bridge + // We may miss the notification, but the state will be synced up after conference has been relocated to the new + // bridge + getColibriSessionManager().updateParticipant( + participant.getEndpointId(), + null, + participant.getSources(), + null, + false); + propagateNewSources(participant, sourcesAccepted); + + // A newly-added audio source may be the base source for a pending translation request. + translationManager.reapply(colibriSessionManager, meetingId); + } + + /** + * Handles a request from a participant to remove sources. + * @throws ValidationFailedException if the request failed because the resulting source set for the participant + * is invalid, or the participant was not allowed to remove some of the sources. + */ + public void removeSources( + @NotNull Participant participant, + @NotNull EndpointSourceSet sourcesRequestedToBeRemoved) + throws ValidationFailedException + { + String participantId = participant.getEndpointId(); + EndpointSourceSet sourcesAcceptedToBeRemoved + = conferenceSources.tryToRemove(participantId, sourcesRequestedToBeRemoved); + + logger.debug( + () -> "Received source removal request from " + participantId + ": " + sourcesRequestedToBeRemoved); + logger.debug(() -> "Accepted sources to remove from " + participantId + ": " + sourcesAcceptedToBeRemoved); + + if (sourcesAcceptedToBeRemoved.isEmpty()) + { + logger.warn( + "No sources or groups to be removed from " + participantId + + ". The requested sources to remove: " + sourcesRequestedToBeRemoved); + return; + } + + getColibriSessionManager().updateParticipant( + participant.getEndpointId(), + null, + participant.getSources(), + null, + false); + + sendSourceRemove(new ConferenceSourceMap(participantId, sourcesAcceptedToBeRemoved), participant); + + // A sender may have removed the audio source being translated; re-evaluate synthetic translation sources. + translationManager.reapply(colibriSessionManager, meetingId); + } + + /** + * Handles a "session-accept" or "transport-accept" request from a participant. + */ + void acceptSession( + @NotNull Participant participant, + @NotNull EndpointSourceSet sourcesAdvertised, + IceUdpTransportPacketExtension transport, + @Nullable InitialLastN initialLastN) + throws ValidationFailedException + { + String participantId = participant.getEndpointId(); + + EndpointSourceSet sourcesAccepted = EndpointSourceSet.EMPTY; + if (!sourcesAdvertised.isEmpty()) + { + sourcesAccepted = conferenceSources.tryToAdd(participantId, sourcesAdvertised); + } + + getColibriSessionManager().updateParticipant( + participantId, + transport, + getSourcesForParticipant(participant), + initialLastN, + false); + + if (!sourcesAccepted.isEmpty()) + { + logger.info("Accepted initial sources from " + participantId + ": " + sourcesAccepted); + // Propagate [participant]'s sources to the other participants. + propagateNewSources(participant, sourcesAccepted); + } + else + { + logger.debug("Session accepted with no sources."); + } + + // The initial sources may include the base audio source for a pending translation request. + translationManager.reapply(colibriSessionManager, meetingId); + + // Now that the Jingle session is ready, signal any sources from other participants to [participant]. + participant.sendQueuedRemoteSources(); + } + + /** + * Removes a participant's sources from the conference. + * + * @param participant the participant whose sources are to be removed. + * @param sendSourceRemove Whether to send source-remove IQs to the remaining participants. + */ + private void removeParticipantSources( + @NotNull Participant participant, + boolean sendSourceRemove, + boolean updateParticipant) + { + String participantId = participant.getEndpointId(); + EndpointSourceSet sourcesRemoved = conferenceSources.remove(participantId); + + if (sourcesRemoved != null && !sourcesRemoved.isEmpty()) + { + if (updateParticipant) + { + getColibriSessionManager().updateParticipant( + participant.getEndpointId(), + null, + participant.getSources(), + null, + true); + } + + if (sendSourceRemove) + { + sendSourceRemove(new ConferenceSourceMap(participantId, sourcesRemoved), participant); + } + } + + // The participant (a potential translation sender) is gone; drop any synthetic translation sources for it. + translationManager.reapply(colibriSessionManager, meetingId); + } + + /** + * Send a source-remove message to all participant except for {@code except}. + * @param sources the sources to be contained in the source-remove message. + * @param except a participant to not send a source-remove to. + */ + private void sendSourceRemove(ConferenceSourceMap sources, Participant except) + { + if (sources.isEmpty()) + { + logger.debug("No sources to remove."); + return; + } + + participants.values().stream() + .filter(participant -> participant != except) + .forEach(participant -> participant.removeRemoteSources(sources)); + } + + /** + * @return all sources in the conference. + */ + @NotNull + public ConferenceSourceMap getSources() + { + return conferenceSources.unmodifiable(); + } + + /** + * {@inheritDoc} + */ + @Override + @NotNull + public EntityBareJid getRoomName() + { + return roomName; + } + + @NotNull + public XmppProvider getClientXmppProvider() + { + return xmppServices.getClientConnection(); + } + + /** + * Checks if this conference has a member with a specific occupant JID. Note that we check for the existence of a + * member in the chat room instead of a {@link Participant} (it's not clear whether the distinction is important). + * @param jid the occupant JID of the member. + * @return true if the conference has a member with occupant JID {@code jid}. + */ + public boolean hasMember(Jid jid) + { + ChatRoom chatRoom = this.chatRoom; + return hasMember(jid, chatRoom) || visitorChatRooms.values().stream().anyMatch(c -> hasMember(jid, c)); + } + + private boolean hasMember(Jid jid, ChatRoom chatRoom) + { + return chatRoom != null + && (jid instanceof EntityFullJid) + && chatRoom.getChatMember((EntityFullJid) jid) != null; + } + + /** + * {@inheritDoc} + */ + @Override + @NotNull + public MuteResult handleMuteRequest( + @NotNull Jid muterJid, + @NotNull Jid toBeMutedJid, + boolean doMute, + @NotNull MediaType mediaType) + { + Participant muter = getParticipant(muterJid); + if (muter == null) + { + logger.warn("Muter participant not found, jid=" + muterJid); + return MuteResult.ERROR; + } + // Only moderators can mute others + if (!muterJid.equals(toBeMutedJid) && !MemberRoleKt.hasModeratorRights(muter.getChatMember().getRole())) + { + logger.warn("Mute not allowed for non-moderator " + muterJid); + return MuteResult.NOT_ALLOWED; + } + + Participant participant = getParticipant(toBeMutedJid); + if (participant == null) + { + logger.warn("Participant to be muted not found, jid=" + toBeMutedJid); + return MuteResult.ERROR; + } + + // process unmuting + if (!doMute) + { + // do not allow unmuting other participants even for the moderator + if (!muterJid.equals(toBeMutedJid)) + { + logger.warn("Unmute not allowed, muterJid=" + muterJid + ", toBeMutedJid=" + toBeMutedJid); + return MuteResult.NOT_ALLOWED; + } + // Moderators are allowed to unmute without being in the whitelist + else if (!participant.hasModeratorRights() + && !this.chatRoom.isMemberAllowedToUnmute(toBeMutedJid, mediaType)) + { + logger.warn("Unmute not allowed due to av moderation for jid=" + toBeMutedJid); + return MuteResult.NOT_ALLOWED; + } + } + + if (participant.shouldSuppressForceMute()) + { + logger.warn("Force mute suppressed, returning NOT_ALLOWED:" + participant); + return MuteResult.NOT_ALLOWED; + } + + logger.info("Will " + (doMute ? "mute" : "unmute") + " " + toBeMutedJid + " on behalf of " + muterJid + + " for " + mediaType); + + boolean colibriMuteState; + MediaType colibriMediaType; + if (mediaType == MediaType.AUDIO) + { + colibriMuteState = !chatRoom.isMemberAllowedToUnmute(toBeMutedJid, MediaType.AUDIO); + colibriMediaType = MediaType.AUDIO; + } + else + { + colibriMuteState = !chatRoom.isMemberAllowedToUnmute(toBeMutedJid, MediaType.VIDEO) && + !chatRoom.isMemberAllowedToUnmute(toBeMutedJid, MediaType.DESKTOP); + colibriMediaType = MediaType.VIDEO; + } + getColibriSessionManager().mute(participant.getEndpointId(), colibriMuteState, colibriMediaType); + + return MuteResult.SUCCESS; + } + + @Override + @NotNull + public JsonNode getRtcstatsState() + { + return getDebugState(false); + } + + @Override + @NotNull + public JsonNode getDebugState() + { + return getDebugState(true); + } + + /** + * @param full when false some high volume fields that aren't needed for rtcstats are suppressed. + */ + private ObjectNode getDebugState(boolean full) + { + ObjectNode o = JsonNodeFactory.instance.objectNode(); + o.put("name", roomName.toString()); + String meetingId = this.meetingId; + if (meetingId != null) + { + o.put("meeting_id", meetingId); + } + o.set("config", config.getDebugState()); + ChatRoom chatRoom = this.chatRoom; + if (chatRoom == null) + { + o.putNull("chat_room"); + } + else + { + o.set("chat_room", chatRoom.getDebugState()); + } + ObjectNode participantsJson = JsonNodeFactory.instance.objectNode(); + for (Participant participant : participants.values()) + { + participantsJson.set(participant.getEndpointId(), participant.getDebugState(full)); + } + o.set("participants", participantsJson); + o.set("client_requirements", clientRequirementsHandler.getDebugState()); + //o.set("jibri_recorder", jibriRecorder.getDebugState()); + //o.set("jibri_sip_gateway", jibriSipGateway.getDebugState()); + ChatRoomRoleManager chatRoomRoleManager = this.chatRoomRoleManager; + if (chatRoomRoleManager == null) + { + o.putNull("chat_room_role_manager"); + } + else + { + o.set("chat_room_role_manager", chatRoomRoleManager.getDebugState()); + } + o.put("started", started.get()); + o.put("start_audio_muted", startAudioMuted); + o.put("start_video_muted", startVideoMuted); + if (colibriSessionManager != null) + { + o.set("colibri_session_manager", colibriSessionManager.getDebugState()); + } + o.set("conference_properties", MAPPER.valueToTree(conferenceProperties)); + o.put("include_in_statistics", includeInStatistics); + o.set("conference_sources", conferenceSources.toJson()); + o.put("audio_limit_reached", audioLimitReached); + o.put("video_limit_reached", videoLimitReached); + + int visitorCount = 0; + int participantCount = 0; + int jibriCount = 0; + int jigasiCount = 0; + int transcriberCount = 0; + synchronized (participantLock) + { + for (Participant p : participants.values()) + { + participantCount++; + ChatRoomMember member = p.getChatMember(); + if (member.getRole() == MemberRole.VISITOR) + { + visitorCount++; + } + if (member.isJibri()) + { + jibriCount++; + } + if (member.isTranscriber()) + { + transcriberCount++; + } + // Only count non-transcribing jigasis + else if (member.isJigasi()) + { + jigasiCount++; + } + } + } + o.put("visitor_count", visitorCount); + o.set("visitor_codecs", visitorCodecs.debugState()); + o.put("participant_count", participantCount); + o.put("jibri_count", jibriCount); + o.put("jigasi_count", jigasiCount); + o.put("transcriber_count", transcriberCount); + + return o; + } + + /** + * Mutes all participants (except jibri or jigasi without "audioMute" support). Will block for colibri responses. + */ + @Override + public void muteAllParticipants(@NotNull MediaType mediaType, EntityFullJid actor) + { + Set participantsToMute = new HashSet<>(); + synchronized (participantLock) + { + for (Participant participant : participants.values()) + { + if (participant.shouldSuppressForceMute()) + { + logger.info("Will not mute a trusted participant without unmute support (jibri, jigasi): " + + participant); + continue; + } + + // we skip the participant that enabled the av moderation + if (participant.getMucJid().equals(actor)) + { + continue; + } + + participantsToMute.add(participant); + } + } + + // Sync the colibri force mute state with the AV moderation state. + // We assume this is successful. If for some reason it wasn't the colibri layer should handle it (e.g. remove a + // broken bridge). + MediaType colibriMediaType = mediaType == MediaType.AUDIO ? MediaType.AUDIO : MediaType.VIDEO; + Set mediaTypes = mediaType == MediaType.AUDIO + ? Collections.singleton(MediaType.AUDIO) + : EnumSet.of(MediaType.VIDEO, MediaType.DESKTOP); + Set participantIdsToMute = new HashSet<>(); + Set participantIdsToUnmute = new HashSet<>(); + for (Participant p : participantsToMute) + { + if (ChatRoomKt.isMemberAllowedToUnmute(chatRoom, p.getMucJid(), mediaTypes)) + { + participantIdsToUnmute.add(p.getEndpointId()); + } + else + { + participantIdsToMute.add(p.getEndpointId()); + } + } + + if (!participantIdsToMute.isEmpty()) + { + getColibriSessionManager().mute( + participantIdsToMute, + true, + colibriMediaType); + } + if (!participantIdsToUnmute.isEmpty()) + { + getColibriSessionManager().mute( + participantIdsToUnmute, + false, + colibriMediaType); + } + + // Signal to the participants that they are being muted. + for (Participant participant : participantsToMute) + { + AbstractMuteIq muteIq = createMuteIq(mediaType); + muteIq.setType(IQ.Type.set); + muteIq.setTo(participant.getMucJid()); + muteIq.setMute(true); + UtilKt.tryToSendStanza(getClientXmppProvider().getXmppConnection(), muteIq); + } + } + + /** + * Returns current participants count. A participant is chat member who has + * some videobridge and media state assigned(not just raw chat room member). + * For example chat member which belongs to the focus never becomes + * a participant. + */ + @Override + public int getParticipantCount() + { + return participants.size(); + } + + @Override + public long getVisitorCount() + { + synchronized (participantLock) + { + return participants.values().stream() + .filter(p -> p.getChatMember().getRole() == MemberRole.VISITOR) + .count(); + } + } + + public Map getBridges() + { + ColibriSessionManager colibriSessionManager = this.colibriSessionManager; + if (colibriSessionManager == null) + { + return Collections.emptyMap(); + } + return colibriSessionManager.getBridges(); + } + + @Override + public boolean moveEndpoint(@NotNull String endpointId, Bridge bridge) + { + if (bridge != null) + { + List bridgeParticipants = colibriSessionManager.getParticipants(bridge); + if (!bridgeParticipants.contains(endpointId)) + { + logger.warn("Endpoint " + endpointId + " is not connected to bridge " + bridge.getJid()); + return false; + } + } + ColibriSessionManager colibriSessionManager = this.colibriSessionManager; + if (colibriSessionManager == null) + { + return false; + } + + colibriSessionManager.removeParticipant(endpointId); + return reInviteParticipantsById(Collections.singletonList(endpointId)) == 1; + } + + @Override + public int moveEndpoints(@NotNull Bridge bridge, int numEps) + { + logger.info("Moving " + numEps + " endpoints from " + bridge.getJid()); + ColibriSessionManager colibriSessionManager = this.colibriSessionManager; + if (colibriSessionManager == null) + { + return 0; + } + List participantIds + = colibriSessionManager.getParticipants(bridge).stream().limit(numEps).collect(Collectors.toList()); + for (String participantId : participantIds) + { + colibriSessionManager.removeParticipant(participantId); + } + return reInviteParticipantsById(participantIds); + } + + /** + * Checks whether a request for a new endpoint to join this conference should be redirected to a visitor node. + * @return the name of the visitor node if it should be redirected, and null otherwise. + */ + @Override + @Nullable + public String redirectVisitor(boolean visitorRequested, @Nullable String userId, @Nullable String groupId) + throws Exception + { + logger.debug("redirectVisitor visitorRequested=" + visitorRequested + ", userId=" + userId + + ", groupId=" + groupId); + if (!VisitorsConfig.config.getEnabled()) + { + return null; + } + + // We don't support both visitors and a lobby. Once a lobby is enabled we don't use visitors anymore. + ChatRoom chatRoom = this.chatRoom; + if (chatRoom != null) + { + if (chatRoom.getLobbyEnabled()) + { + logger.debug("Lobby enabled, not redirecting."); + return null; + } + if (Boolean.FALSE.equals(chatRoom.getVisitorsEnabled())) + { + logger.warn("Visitors are disabled, not redirecting."); + return null; + } + } + if (VisitorsConfig.config.getRequireMucConfigFlag()) + { + if (chatRoom == null || !Boolean.TRUE.equals(chatRoom.getVisitorsEnabled())) + { + logger.debug("RequireMucConfigFlag is set, and the room does not have the flag, not redirecting."); + return null; + } + } + // We don't support visitors in breakout rooms. + if (mainRoomJid != null) + { + logger.debug("This is a breakout room, not redirecting."); + return null; + } + + long participantCount = getUserParticipantCount(); + boolean visitorsAlreadyUsed; + synchronized (visitorChatRooms) + { + visitorsAlreadyUsed = !visitorChatRooms.isEmpty(); + } + + int participantsSoftLimit = VisitorsConfig.config.getMaxParticipants(); + if (chatRoom != null && chatRoom.getParticipantsSoftLimit() != null && chatRoom.getParticipantsSoftLimit() > 0) + { + participantsSoftLimit = chatRoom.getParticipantsSoftLimit(); + } + + logger.debug("redirectVisitor: participantsSoftLimit=" + participantsSoftLimit + + ", visitorsAlreadyUsed=" + visitorsAlreadyUsed + + ", visitorRequested=" + visitorRequested + + ", participantCount=" + participantCount + + ", participantsSoftLimit=" + participantsSoftLimit); + if (visitorsAlreadyUsed || visitorRequested || participantCount >= participantsSoftLimit) + { + return selectVisitorNode(); + } + + return null; + } + + /** + * Get the number of participants in the main room for the purpose of visitor node selection. Exclude participants + * for jibri and transcribers, because they shouldn't count towards the max participant limit. + */ + private long getUserParticipantCount() + { + ChatRoom chatRoom = this.chatRoom; + if (chatRoom == null) + { + return 0; + } + + return chatRoom.getMembers() + .stream() + .filter(m -> !m.isJibri() && !m.isTranscriber()) + .count(); + } + + /** + * Selects a visitor node for a new participant, and joins the associated chat room if not already joined + * @return the ID of the selected node, or null if the endpoint is to be sent to the main room. + * @throws Exception if joining the chat room failed. + */ + @Nullable + private String selectVisitorNode() + throws Exception + { + ChatRoom chatRoomToJoin; + String node; + + synchronized (visitorChatRooms) + { + node = ConferenceUtilKt.selectVisitorNode( + visitorChatRooms, + xmppServices.getVisitorConnections()); + if (node == null) + { + logger.warn("Visitor node required, but none available."); + return null; + } + if (visitorChatRooms.containsKey(node)) + { + visitorChatRooms.get(node).visitorInvited(); + // Already joined. + return node; + } + + // Join a new visitor chat room on the selected [node]. + XmppProvider xmppProvider = xmppServices.getXmppVisitorConnectionByName(node); + if (xmppProvider == null) + { + logger.error("No XMPP provider for node " + node); + return null; + } + + XmppVisitorConnectionConfig config = XmppConfig.getVisitors().get(node); + if (config == null) + { + logger.error("No XMPP config for node " + node); + return null; + } + + EntityBareJid visitorMucJid = getVisitorMucJid( + roomName, + xmppServices.getClientConnection(), + xmppProvider); + + // Will call join after releasing the lock + chatRoomToJoin = xmppProvider.findOrCreateRoom(visitorMucJid, logger.getLevel()); + + chatRoomToJoin.addListener(new VisitorChatRoomListenerImpl(chatRoomToJoin)); + + visitorChatRooms.put(node, chatRoomToJoin); + chatRoomToJoin.visitorInvited(); + } + + try + { + chatRoomToJoin.join(); + } + catch (Exception e) + { + // Do not keep a room that we failed to join. We would never join it, and we would keep sending visitors + // to it because of the check above. + logger.error("Failed to join the visitor room on node " + node, e); + synchronized (visitorChatRooms) + { + visitorChatRooms.remove(node, chatRoomToJoin); + } + chatRoomToJoin.removeAllListeners(); + chatRoomToJoin.leave(); + throw e; + } + + // The room may have been discarded while we were joining it, for example because the node was restarted. + // Leave it, otherwise we stay in a MUC that we do not track anymore. + synchronized (visitorChatRooms) + { + if (visitorChatRooms.get(node) != chatRoomToJoin) + { + logger.warn("The visitor room on node " + node + " was discarded while we were joining it."); + chatRoomToJoin.removeAllListeners(); + chatRoomToJoin.leave(); + return null; + } + } + + Collection presenceExtensions = new ArrayList<>(); + + ComponentVersionsExtension versionsExtension = new ComponentVersionsExtension(); + versionsExtension.addComponentVersion( + ComponentVersionsExtension.COMPONENT_FOCUS, + CurrentVersionImpl.VERSION.toString()); + presenceExtensions.add(versionsExtension); + + // TODO: what do we want to include in presence in visitor MUCs? Do we need to keep their conference + // properties up to date? + presenceExtensions.add(createConferenceProperties()); + + this.presenceExtensions.forEach((qName, extension) -> presenceExtensions.add(extension)); + // updates presence with presenceExtensions and sends it + chatRoomToJoin.addPresenceExtensions(presenceExtensions); + + if (this.visitorsBroadcastEnabled) + { + VisitorsManager visitorsManager = xmppServices.getVisitorsManager(); + visitorsManager.sendIqToComponent( + roomName, + Collections.singletonList(new ConnectVnodePacketExtension(node))); + } + else + { + logger.info("Redirected visitor, broadcast not enabled yet."); + } + return node; + } + + @Override + public void visitorConnectionReset(@NotNull String node) + { + final ChatRoom staleChatRoom; + synchronized (visitorChatRooms) + { + staleChatRoom = visitorChatRooms.remove(node); + } + + if (staleChatRoom == null) + { + // This conference does not use the node. + return; + } + + logger.info("The connection to visitor node " + node + " was reset, discarding the visitor room."); + discardVisitorChatRoom(node, staleChatRoom); + } + + /** + * Clean up after a visitor {@link ChatRoom} that we are not joined in anymore. Terminate the visitors that were + * in the room, leave the room, and tell the visitors component to stop sending visitors to the node. + * + * The caller must remove the room from {@link #visitorChatRooms} first. + * + * @param node the ID of the visitor node that the room is on. + * @param staleChatRoom the room to discard. + */ + private void discardVisitorChatRoom(@NotNull String node, @NotNull ChatRoom staleChatRoom) + { + TaskPools.getIoPool().submit(() -> + { + try + { + // We are not in the room anymore, so we do not receive presence for the visitors leaving it. Without + // this they leak, because each one keeps a Participant and an endpoint on a bridge. + for (ChatRoomMember member : staleChatRoom.getMembers()) + { + if (member.getRole() == MemberRole.VISITOR) + { + onMemberLeft(member); + } + } + + staleChatRoom.removeAllListeners(); + staleChatRoom.leave(); + } + catch (Exception e) + { + logger.error("Failed to discard the visitor room on node " + node, e); + } + }); + + xmppServices.getVisitorsManager().sendIqToComponent( + roomName, Collections.singletonList(new DisconnectVnodePacketExtension(node))); + } + + private void onBridgeUp(Jid bridgeJid) + { + // Check if we're not shutting down + if (!started.get()) + { + return; + } + + //TODO: if one of our bridges failed, we should have invited its + // participants to another one. Here we should re-invite everyone if + // the conference is not running (e.g. there was a single bridge and + // it failed, then in was brought up). + if (chatRoom != null && checkMinParticipants() && getColibriSessionManager().getBridgeCount() == 0) + { + logger.info("New bridge available, will try to restart: " + bridgeJid); + + synchronized (participantLock) + { + reInviteParticipants(participants.values()); + } + } + } + + private int reInviteParticipantsById(@NotNull List participantIdsToReinvite) + { + return reInviteParticipantsById(participantIdsToReinvite, true); + } + + private int reInviteParticipantsById(@NotNull List participantIdsToReinvite, boolean updateParticipant) + { + int n = participantIdsToReinvite.size(); + if (n == 0) + { + return 0; + } + + List participantsToReinvite = new ArrayList<>(); + synchronized (participantLock) + { + for (Participant participant : participants.values()) + { + if (participantsToReinvite.size() == n) + { + break; + } + if (participantIdsToReinvite.contains(participant.getEndpointId())) + { + participantsToReinvite.add(participant); + } + } + if (participantsToReinvite.size() != participantIdsToReinvite.size()) + { + logger.error("Can not re-invite all participants, no Participant object for some of them."); + } + reInviteParticipants(participantsToReinvite, updateParticipant); + } + ConferenceMetrics.participantsMoved.addAndGet(participantsToReinvite.size()); + return participantsToReinvite.size(); + } + + /** + * A callback called by {@link ParticipantInviteRunnable} when + * establishing the Jingle session with its participant fails. + * @param channelAllocator the channel allocator which failed. + */ + public void onInviteFailed(ParticipantInviteRunnable channelAllocator) + { + terminateParticipant( + channelAllocator.getParticipant(), + Reason.GENERAL_ERROR, + "jingle session failed", + /* send session-terminate */ true, + /* send source-remove */ true, + /* not reinviting */ false); + } + + /** + * {@inheritDoc} + */ + @Override + @Nullable + public ChatRoom getChatRoom() + { + return chatRoom; + } + + /** + * Creates the shared document name by either using the conference room name + * or a random string, depending on configuration. + * + * @return the shared document name. + */ + private String createSharedDocumentName() + { + if (ConferenceConfig.config.useRandomSharedDocumentName()) + { + return UUID.randomUUID().toString().replaceAll("-", ""); + } + else + { + return roomName.getLocalpart().toString(); + } + } + + /** + * An adapter for {@link #reInviteParticipants(Collection)}. + * + * @param participant the {@link Participant} to be re invited into the + * conference. + */ + private void reInviteParticipant(Participant participant) + { + ArrayList l = new ArrayList<>(1); + + l.add(participant); + + reInviteParticipants(l); + } + + /** + * Re-invites {@link Participant}s into the conference. + * + * @param participants the list of {@link Participant}s to be re-invited. + */ + private void reInviteParticipants(Collection participants) + { + reInviteParticipants(participants, true); + } + + /** + * Re-invites {@link Participant}s into the conference. + * + * @param participants the list of {@link Participant}s to be re-invited. + * @param updateParticipant flag to check if update participant call should be called. + */ + private void reInviteParticipants(Collection participants, boolean updateParticipant) + { + synchronized (participantLock) + { + for (Participant participant : participants) + { + participant.setInviteRunnable(null); + boolean restartJingle = ConferenceConfig.config.getReinviteMethod() == ReinviteMethod.RestartJingle; + + if (restartJingle) + { + removeParticipantSources(participant, true, updateParticipant); + participant.terminateJingleSession(Reason.SUCCESS, "moving", true); + } + + // If were restarting the jingle session it's a fresh invite (reInvite = false), otherwise it's a + // transport-replace (reInvite = true) + inviteParticipant(participant, !restartJingle, false); + } + } + } + + /** + * (Re)schedules {@link SinglePersonTimeout}. + */ + private void rescheduleSingleParticipantTimeout() + { + cancelSingleParticipantTimeout(); + + long timeout = ConferenceConfig.config.getSingleParticipantTimeout().toMillis(); + + singleParticipantTout = TaskPools.getScheduledPool().schedule( + new SinglePersonTimeout(), timeout, TimeUnit.MILLISECONDS); + + logger.info("Scheduled single person timeout."); + } + + private void cancelConferenceStartTimeout() + { + synchronized (conferenceStartTimeoutLock) + { + if (conferenceStartTimeout != null) + { + conferenceStartTimeout.cancel(true); + conferenceStartTimeout = null; + } + } + } + + /** + * If there is a scheduled "conference start" timeout, cancel it and re-schedule with the configured delay. If + * there isn't one scheduled, doesn't do anything. + */ + public void rescheduleConferenceStartTimeout() + { + synchronized (conferenceStartTimeoutLock) + { + if (conferenceStartTimeout != null) + { + cancelConferenceStartTimeout(); + scheduleConferenceStartTimeout(); + } + } + } + + /** + * Schedules conference start timeout. + */ + private void scheduleConferenceStartTimeout() + { + synchronized (conferenceStartTimeoutLock) + { + cancelConferenceStartTimeout(); + conferenceStartTimeout = TaskPools.getScheduledPool().schedule( + () -> + { + if (includeInStatistics) + { + logger.info("Expiring due to initial timeout."); + } + + // in case of last participant leaving to join a breakout room, we want to skip destroy + if (focusManager.hasBreakoutRooms(roomName)) + { + logger.info("Breakout rooms present, will not stop."); + return; + } + + stop(); + }, + ConferenceConfig.config.getConferenceStartTimeout().toMillis(), + TimeUnit.MILLISECONDS); + } + } + + /** Called when a new visitor has been added to the conference. */ + private void visitorAdded(List codecs) + { + visitorCount.adjustValue(+1); + if (codecs != null) + { + visitorCodecs.addPreference(codecs); + } + } + + /** Called when a new visitor has been added to the conference. */ + private void visitorRemoved(List codecs) + { + visitorCount.adjustValue(-1); + if (codecs != null) + { + visitorCodecs.removePreference(codecs); + } + } + + /** + * Cancels {@link SinglePersonTimeout}. + */ + private void cancelSingleParticipantTimeout() + { + if (singleParticipantTout != null) + { + // This log is printed also when it's executed by the timeout thread itself + logger.debug("Cancelling single person timeout."); + + singleParticipantTout.cancel(false); + singleParticipantTout = null; + } + } + + /** + * {@inheritDoc} + */ + @Override + @NotNull + public Set getBridgeRegions() + { + return colibriSessionManager != null ? colibriSessionManager.getBridgeRegions() : Collections.emptySet(); + } + + @Override + public boolean includeInStatistics() + { + return includeInStatistics; + } + + @Override + @NotNull + public IqProcessingResult handleJibriRequest(@NotNull IqRequest request) + { + IqProcessingResult result = new NotProcessed(); + if (started.get()) + { + if (jibriRecorder != null) + { + result = jibriRecorder.handleJibriRequest(request); + } + if (result instanceof NotProcessed && jibriSipGateway != null) + { + result = jibriSipGateway.handleJibriRequest(request); + } + } + return result; + } + + @Override + public boolean acceptJigasiRequest(@NotNull Jid from) + { + if (ConferenceConfig.config.getEnableModeratorChecks()) + { + return MemberRoleKt.hasModeratorRights(getRoleForMucJid(from)); + } + + return true; + } + + @Override + public JibriRecorder getJibriRecorder() + { + return jibriRecorder; + } + + @Override + public JibriSipGateway getJibriSipGateway() + { + return jibriSipGateway; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() + { + return String.format("JitsiMeetConferenceImpl[name=%s]", getRoomName()); + } + + @Override + public boolean isRtcStatsEnabled() + { + return config.getRtcStatsEnabled(); + } + + private void setEnableTranscribing(boolean enable) + { + if (enableTranscription == enable) + { + return; + } + + logger.info("Setting enableTranscribing=" + enable); + enableTranscription = enable; + setConferenceProperty(ConferenceProperties.KEY_AUDIO_RECORDING_ENABLED, enable ? "true" : "false"); + + // If transcription just got enabled and we have members but no invited participants yet (e.g. we were waiting + // for min-participants), trigger invitations now. + if (enable && chatRoom != null && chatRoom.getMemberCount() > 0) + { + synchronized (participantLock) + { + if (participants.isEmpty()) + { + logger.info("Transcribing enabled with existing participants, starting sessions."); + inviteAllChatMembers(); + } + } + } + + String meetingId = JitsiMeetConferenceImpl.this.meetingId; + ColibriSessionManager colibriSessionManager = JitsiMeetConferenceImpl.this.colibriSessionManager; + + if (meetingId == null || colibriSessionManager == null) + { + // The new value will take effect when colibriSessionManager is initialized (after the room is joined and + // meetingId is set). + return; + } + + TemplatedUrl uri = enable ? TranscriptionConfig.config.getUrl(meetingId) : null; + if (enable && uri == null) + { + logger.info("Transcription enabled, but no URL is configured."); + return; + } + + Pair, Map> transcriptionParams = enable && chatRoom != null + ? TranscriptionConfig.processTranscriptionMetadata( + chatRoom.getTranscription(), + TranscriptionConfig.config.getHttpHeaders()) + : new Pair<>(null, null); + + colibriSessionManager.setTranscriberUrl( + uri, + transcriptionParams.getFirst(), + transcriptionParams.getSecond()); + + // The transcriber connect was just created or removed; (re)apply the requested languages to it. + updateTextTranslationLanguages(); + } + + /** + * Signal the set of languages the transcriber should translate its transcripts into: the languages the members + * request in their presence, together with the visitors' (which reach us through the room metadata). + * + * This is text translation. It is unrelated to the speech-to-speech translation that + * {@link ConferenceTranslationManager} drives. + * + * Signaling an empty set stops translation, so this is also the path that releases languages when the last + * member that wanted them leaves, or when transcription is turned off. + */ + private void updateTextTranslationLanguages() + { + ColibriSessionManager colibriSessionManager = this.colibriSessionManager; + if (colibriSessionManager == null) + { + // The languages are read from the room when the session manager is initialized. + return; + } + + ChatRoom chatRoom = this.chatRoom; + Set languages = enableTranscription && chatRoom != null + ? chatRoom.getTranslationLanguages() + : Collections.emptySet(); + + colibriSessionManager.setTextTranslationLanguages(languages); + } + + /** + * The interface used to listen for conference events. + */ + public interface ConferenceListener + { + /** + * Event fired when conference has ended. + * @param conference the conference instance that has ended. + */ + void conferenceEnded(@NotNull JitsiMeetConferenceImpl conference); + + /** + * Fire an event attempting to set the meeting ID for the conference. The implementation should return `false` + * in case another meeting with the same ID already exists, which will result in an exception. + * @param conference the conference. + * @param meetingId the meetingId to attempt. + * @return true if the given meetingId was free and was associated with the conference, false otherwise. + */ + boolean meetingIdSet(@NotNull JitsiMeetConferenceImpl conference, @NotNull String meetingId); + } + + /** + * The task is scheduled with some delay when we end up with single + * Participant in the room to terminate its media session. There + * is no point in streaming media to the videobridge and using + * the bandwidth when nobody is receiving it. + */ + private class SinglePersonTimeout + implements Runnable + { + @Override + public void run() + { + synchronized (participantLock) + { + if (participants.size() == 1) + { + Participant p = participants.values().stream().findFirst().orElse(null); + logger.info("Timing out single participant: " + p.getChatMember().getName()); + + terminateParticipant( + p, + Reason.EXPIRED, + "Idle session timeout", + /* send session-terminate */ true, + /* send source-remove */ false, + /* not reinviting */ false); + + expireBridgeSessions(); + } + else + { + logger.error("Should never execute if more than 1 participant?"); + } + singleParticipantTout = null; + } + } + } + + private class BridgeSelectorEventHandler implements BridgeSelector.EventHandler + { + @Override + public void bridgeIsShuttingDown(@NotNull Bridge bridge) + { + List participantIdsToReinvite + = colibriSessionManager != null + ? colibriSessionManager.removeBridge(bridge) : Collections.emptyList(); + if (!participantIdsToReinvite.isEmpty()) + { + logger.info("Bridge " + bridge.getJid() + " is shutting down, re-inviting " + participantIdsToReinvite); + reInviteParticipantsById(participantIdsToReinvite); + } + } + + @Override + public void bridgeFailedHealthCheck(@NotNull Bridge bridge) + { + removeBridge(bridge, "failed health check"); + } + + @Override + public void bridgeRemoved(@NotNull Bridge bridge) + { + removeBridge(bridge, "was removed"); + } + + @Override + public void bridgeAdded(Bridge bridge) + { + onBridgeUp(bridge.getJid()); + } + + private void removeBridge(@NotNull Bridge bridge, @NotNull String reason) + { + List participantIdsToReinvite + = colibriSessionManager != null + ? colibriSessionManager.removeBridge(bridge) : Collections.emptyList(); + if (!participantIdsToReinvite.isEmpty()) + { + logger.info("Re-inviting " + participantIdsToReinvite + " because " + bridge.getJid() + " " + reason); + reInviteParticipantsById(participantIdsToReinvite); + } + } + + } + + /** + * Handle events from members in one of the visitor MUCs. + */ + private class VisitorChatRoomListenerImpl extends DefaultChatRoomListener + { + private final Logger logger = JitsiMeetConferenceImpl.this.logger.createChildLogger( + VisitorChatRoomListenerImpl.class.getSimpleName()); + private final ChatRoom chatRoom; + + private VisitorChatRoomListenerImpl(ChatRoom chatRoom) + { + this.chatRoom = chatRoom; + logger.addContext("visitor_muc", chatRoom.getRoomJid().toString()); + } + + @Override + public void roomDestroyed(String reason) + { + logger.info("Visitor room destroyed with reason=" + reason); + String vnode = null; + synchronized (visitorChatRooms) + { + Map.Entry entry + = visitorChatRooms.entrySet().stream() + .filter(e -> e.getValue() == chatRoom).findFirst().orElse(null); + if (entry != null) + { + vnode = entry.getKey(); + visitorChatRooms.remove(vnode); + } + } + + if (vnode != null) + { + discardVisitorChatRoom(vnode, chatRoom); + } + } + + @Override + public void memberJoined(@NotNull ChatRoomMember member) + { + if (member.getRole() != MemberRole.VISITOR) + { + logger.debug("Ignoring non-visitor member of visitor room: " + member); + return; + } + // Run in the IO pool because feature discovery may send disco#info and block for a response, and shouldn't + // run in Smack's thread. + TaskPools.getIoPool().submit(() -> onMemberJoined(member)); + } + + @Override + public void memberKicked(@NotNull ChatRoomMember member) + { + if (member.getRole() != MemberRole.VISITOR) + { + logger.debug("Member kicked for non-visitor member of visitor room: " + member); + return; + } + onMemberKicked(member); + } + + @Override + public void memberLeft(@NotNull ChatRoomMember member) + { + if (member.getRole() != MemberRole.VISITOR) + { + logger.debug("Member left for non-visitor member of visitor room: " + member); + return; + } + onMemberLeft(member); + } + } + + private class ChatRoomListenerImpl implements ChatRoomListener + { + @Override + public void roomDestroyed(String reason) + { + logger.info("Room destroyed with reason=" + reason); + Span span = tracer.spanBuilder("conference.room-destroyed") + .setAllAttributes(TracingUtil.roomAttributes(chatRoom)) + .setAttribute("conference.id", Objects.toString(meetingId)) + .setAttribute("reason", Objects.toString(reason)) + .startSpan(); + try (Scope s = span.makeCurrent()) + { + stop(); + } + catch (Throwable e) + { + span.setStatus(StatusCode.ERROR, Objects.toString(e.getMessage(), "")); + throw e; + } + finally + { + span.end(); + } + } + + @Override + public void startMutedChanged(boolean startAudioMuted, boolean startVideoMuted) + { + JitsiMeetConferenceImpl.this.startAudioMuted = startAudioMuted; + JitsiMeetConferenceImpl.this.startVideoMuted = startVideoMuted; + } + + @Override + public void memberJoined(@NotNull ChatRoomMember member) + { + // Run in the IO pool because feature discovery may send disco#info and block for a response, and shouldn't + // run in Smack's thread. + TaskPools.getIoPool().submit(() -> { + Span span = tracer.spanBuilder("conference.member-joined") + .setAllAttributes(TracingUtil.memberAttributes(member)) + .setAllAttributes(TracingUtil.roomAttributes(chatRoom)) + .setAttribute("conference.id", Objects.toString(meetingId)) + .startSpan(); + try (Scope s = span.makeCurrent()) + { + onMemberJoined(member); + } + catch (Throwable e) + { + span.setStatus(StatusCode.ERROR, Objects.toString(e.getMessage(), "")); + throw e; + } + finally + { + span.end(); + } + }); + } + + @Override + public void memberKicked(@NotNull ChatRoomMember member) + { + onMemberKicked(member); + } + + @Override + public void memberLeft(@NotNull ChatRoomMember member) + { + Span span = tracer.spanBuilder("conference.member-left") + .setAllAttributes(TracingUtil.memberAttributes(member)) + .setAllAttributes(TracingUtil.roomAttributes(chatRoom)) + .setAttribute("conference.id", Objects.toString(meetingId)) + .startSpan(); + try (Scope s = span.makeCurrent()) + { + onMemberLeft(member); + } + catch (Throwable e) + { + span.setStatus(StatusCode.ERROR, Objects.toString(e.getMessage(), "")); + throw e; + } + finally + { + span.end(); + } + } + + @Override + public void localRoleChanged(@NotNull MemberRole newRole) + { + if (newRole != MemberRole.OWNER) + { + logger.warn( + "Stopping, because the local role changed to " + newRole + " (owner privileges are required)."); + stop(); + } + } + + @Override + public void memberPresenceChanged(@NotNull ChatRoomMember member) + { + } + + @Override + public void numAudioSendersChanged(int numAudioSenders) + { + onNumAudioSendersChanged(numAudioSenders); + } + + @Override + public void numVideoSendersChanged(int numVideoSenders) + { + onNumVideoSendersChanged(numVideoSenders); + } + + @Override + public void transcribingEnabledChanged(boolean enabled) + { + setEnableTranscribing(enabled); + } + + @Override + public void translationLanguagesChanged() + { + updateTextTranslationLanguages(); + } + + @Override + public void audioTranslationRequestsChanged(@NotNull Map> requests) + { + // Merge any per-room translator headers (e.g. a per-customer usage token delivered on the + // admin-only metadata path) over the static config headers; null falls back to config headers. + Map translationHeaders = TranslationConfig.processTranslationMetadata( + chatRoom != null ? chatRoom.getTranslation() : null, + TranslationConfig.config.getHttpHeaders()); + //noinspection unchecked + translationManager.setRequests( + (Map>) (Map) requests, + translationHeaders, + colibriSessionManager, + meetingId); + } + } + + /** + * Listener for events from {@link ColibriSessionManager}. + */ + private class ColibriSessionManagerListener implements ColibriSessionManager.Listener + { + @Override + public void bridgeCountChanged(int bridgeCount) + { + // Update the state in presence. + setConferenceProperty( + ConferenceProperties.KEY_BRIDGE_COUNT, + Integer.toString(bridgeCount) + ); + } + + /** + * Bridge selection failed, update jicofo's presence in the room to reflect it. + */ + @Override + public void bridgeSelectionFailed() + { + ChatRoom chatRoom = getChatRoom(); + if (chatRoom != null) + { + chatRoom.addPresenceExtensionIfMissing(new BridgeNotAvailablePacketExt()); + } + } + + /** + * Bridge selection was successful, update jicofo's presence in the room to reflect it. + */ + @Override + public void bridgeSelectionSucceeded() + { + // Remove "bridge not available" from Jicofo's presence + ChatRoom chatRoom = JitsiMeetConferenceImpl.this.chatRoom; + if (chatRoom != null) + { + // Remove any "bridge not available" extensions. + chatRoom.removePresenceExtensions(e -> e instanceof BridgeNotAvailablePacketExt); + } + } + + @Override + public void bridgeRemoved(@NotNull Bridge bridge, @NotNull List participantIds) + { + ConferenceMetrics.bridgesRemoved.inc(); + logger.info("Bridge " + bridge + " was removed from the conference. Re-inviting its participants: " + + participantIds); + reInviteParticipantsById(participantIds); + } + + @Override + public void endpointRemoved(@NotNull String endpointId) + { + logger.info("Endpoint " + endpointId + " was removed from the conference. Re-inviting participant."); + reInviteParticipantsById(Collections.singletonList(endpointId), false); + } + + @Override + public void endpointIceRestarted( + @NotNull String endpointId, + @NotNull IceUdpTransportPacketExtension transport) + { + Participant participant = null; + synchronized (participantLock) + { + for (Participant p : participants.values()) + { + if (endpointId.equals(p.getEndpointId())) + { + participant = p; + break; + } + } + } + + if (participant == null) + { + logger.warn("ICE restart: can not signal the bridge's transport, no participant for " + endpointId); + IceRestartMetrics.failed.inc(); + return; + } + + JingleSession jingleSession = participant.getJingleSession(); + if (jingleSession == null) + { + logger.warn("ICE restart: can not signal the bridge's transport, no Jingle session for " + endpointId); + IceRestartMetrics.failed.inc(); + return; + } + + logger.info("ICE restart: relaying the bridge's transport to " + endpointId + + ", generation=" + transport.getIceGeneration()); + jingleSession.sendTransportInfo(transport); + IceRestartMetrics.relayed.inc(); + } + + @Override + public void endpointIceRestartFailed(@NotNull String endpointId) + { + // The participant asked for an in-place ICE restart and the bridge declined it, so nothing will be + // signalled back and the participant would sit on a broken connection until its own timeout. Recover + // it the way it would have been recovered if it had never asked: a full re-invite. + // + // As in iceRestart(), Bridge.endpointRequestedRestart() is intentionally not called - the bridge + // declining a restart (typically because the feature is disabled on it) is not evidence that it is + // failing ICE, and counting it would feed the bridge-selection penalty. + logger.info("ICE restart: the bridge did not restart ICE for " + endpointId + + ", re-inviting the participant."); + reInviteParticipantsById(Collections.singletonList(endpointId)); + } + } + + public static class SenderCountExceededException extends Exception + { + SenderCountExceededException(String message) + { + super(message); + } + } + + static class InvalidBridgeSessionIdException extends Exception + { + InvalidBridgeSessionIdException(String message) + { + super(message); + } + } +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/conference/MuteResult.java b/jicofo/src/main/java/org/jitsi/jicofo/conference/MuteResult.java new file mode 100644 index 0000000..3f12848 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/conference/MuteResult.java @@ -0,0 +1,26 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference; + +/** The result of a mute operation. */ +public enum MuteResult +{ + SUCCESS, + NOT_ALLOWED, + ERROR +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/conference/ParticipantInviteRunnable.java b/jicofo/src/main/java/org/jitsi/jicofo/conference/ParticipantInviteRunnable.java new file mode 100644 index 0000000..25577c8 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/conference/ParticipantInviteRunnable.java @@ -0,0 +1,496 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference; + +import io.opentelemetry.api.trace.*; +import io.opentelemetry.context.*; +import org.jetbrains.annotations.*; +import org.jitsi.jicofo.*; +import org.jitsi.jicofo.bridge.colibri.*; +import org.jitsi.jicofo.codec.*; +import org.jitsi.jicofo.conference.source.*; +import org.jitsi.jicofo.jibri.*; +import org.jitsi.jicofo.jigasi.*; +import org.jitsi.jicofo.util.*; +import org.jitsi.jicofo.xmpp.jingle.*; +import org.jitsi.jicofo.xmpp.muc.*; +import org.jitsi.tracing.*; +import org.jitsi.xmpp.extensions.colibri2.*; +import org.jitsi.xmpp.extensions.jingle.*; +import org.jitsi.xmpp.extensions.jingle.JingleUtils; +import org.jitsi.xmpp.extensions.jitsimeet.*; +import org.jitsi.utils.logging2.*; +import org.jivesoftware.smack.*; +import org.jivesoftware.smack.packet.*; +import org.jxmpp.jid.*; + +import java.util.*; + +/** + * An {@link Runnable} which invites a participant to a conference. + * + * @author Pawel Domas + * @author Boris Grozev + */ +public class ParticipantInviteRunnable implements Runnable, Cancelable +{ + private final Logger logger; + private final Tracer tracer = TracingGlobal.Companion.getSdk().getTracer("org.jitsi.jicofo.conference"); + + /** + * The {@link JitsiMeetConferenceImpl} into which a participant will be + * invited. + */ + private final JitsiMeetConferenceImpl meetConference; + + @NotNull private final ColibriSessionManager colibriSessionManager; + + /** + * A flag which indicates whether channel allocation is canceled. Raising + * this makes the allocation thread discontinue the allocation process and + * return. + */ + private volatile boolean canceled = false; + + /** + * Whether to include a "start audio muted" extension when sending session-initiate. + */ + private final boolean startAudioMuted; + + /** + * Whether to include a "start video muted" extension when sending session-initiate. + */ + private final boolean startVideoMuted; + + /** + * Whether the participant should be force muted (audio). + */ + private final boolean forceMuteAudio; + + /** + * Whether the participant should be force muted (video). + */ + private final boolean forceMuteVideo; + + /** + * Indicates whether or not this task will be doing a "re-invite". It + * means that we're going to replace a previous conference which has failed. + * Channels are allocated on new JVB and peer is re-invited with + * 'transport-replace' Jingle action as opposed to 'session-initiate' in + * regular invite. + */ + private final boolean reInvite; + + /** + * Override super's AbstractParticipant + */ + @NotNull private final Participant participant; + + /** + * {@inheritDoc} + */ + public ParticipantInviteRunnable( + JitsiMeetConferenceImpl meetConference, + @NotNull ColibriSessionManager colibriSessionManager, + @NotNull Participant participant, + boolean startAudioMuted, + boolean startVideoMuted, + boolean reInvite, + Logger parentLogger) + { + this.meetConference = meetConference; + this.colibriSessionManager = colibriSessionManager; + + boolean forceMuteAudio = false; + boolean forceMuteVideo = false; + ChatRoom chatRoom = meetConference.getChatRoom(); + if (chatRoom != null && !participant.hasModeratorRights() && !participant.shouldSuppressForceMute()) + { + if (chatRoom.isAvModerationEnabled(MediaType.AUDIO)) + { + forceMuteAudio = true; + } + + if (chatRoom.isAvModerationEnabled(MediaType.VIDEO)) + { + forceMuteVideo = true; + } + } + + this.forceMuteAudio = forceMuteAudio; + this.forceMuteVideo = forceMuteVideo; + + // If the participant is force muted, communicate it from the start instead of sending MuteIqs later. + this.startAudioMuted = startAudioMuted || forceMuteAudio; + this.startVideoMuted = startVideoMuted || forceMuteVideo; + this.reInvite = reInvite; + this.participant = participant; + logger = parentLogger.createChildLogger(getClass().getName()); + logger.addContext("participant", participant.getChatMember().getName()); + } + + /** + * Entry point for the {@link ParticipantInviteRunnable} task. + */ + @Override + public void run() + { + Span span = tracer.spanBuilder("conference.participant-invite") + .startSpan(); + try (Scope s = span.makeCurrent()) + { + doRun(); + } + catch (Throwable e) + { + logger.error("Channel allocator failed: ", e); + span.setStatus(StatusCode.ERROR, Objects.toString(e.getMessage(), "")); + cancel(); + } + finally + { + participant.inviteRunnableCompleted(this); + span.end(); + } + } + + private void doRun() + { + Offer offer = createOffer(); + if (canceled) + { + return; + } + + ColibriAllocation colibriAllocation; + try + { + Set medias = new HashSet<>(); + offer.getContents().forEach(content -> { + // Ignore the "data" content here (SCTP). + if (!"audio".equals(content.getName()) && !"video".equals(content.getName())) + { + return; + } + Media media = ConferenceUtilKt.toMedia(content); + if (media != null) + { + medias.add(media); + } + else + { + logger.warn("Failed to convert ContentPacketExtension to Media: " + content.toXML()); + } + }); + // This makes the bridge signal its private host candidates. We enable them for backend components, because + // they may be in the same network as the bridge, and disable them for endpoints to avoid checking + // unnecessary pairs (unless the endpoints explicitly signal the feature). + boolean privateAddresses = + (participant.getChatMember().isJigasi() && JigasiConfig.config.getPrivateAddressConnectivity()) || + (participant.getChatMember().isJibri() && JibriConfig.config.getPrivateAddressConnectivity()); + ParticipantAllocationParameters participantOptions = new ParticipantAllocationParameters( + participant.getEndpointId(), + participant.getStatId(), + participant.getChatMember().getRegion(), + participant.getSources(), + participant.useSsrcRewriting(), + participant.useRtpMidDemux(), + forceMuteAudio, + forceMuteVideo, + offer.getContents().stream().anyMatch(c -> c.getName() == "data"), + (participant.getChatMember().getRole() == MemberRole.VISITOR), + privateAddresses, + participant.getChatMember().getDiarize(), + medias); + colibriAllocation = colibriSessionManager.allocate(participantOptions); + } + catch (BridgeSelectionFailedException e) + { + logger.error("Can not invite participant, no bridge available."); + cancel(); + return; + } + catch (ConferenceAlreadyExistsException e) + { + logger.warn("Can not allocate colibri channels, conference already exists."); + cancel(); + return; + } + catch (ColibriAllocationFailedException e) + { + logger.error("Failed to allocate colibri channels", e); + cancel(); + return; + } + + + if (canceled) + { + return; + } + + offer = updateOffer(offer, colibriAllocation); + if (canceled) + { + return; + } + + try + { + invite(offer, colibriAllocation); + } + catch (SmackException.NotConnectedException e) + { + logger.error("Failed to invite participant: ", e); + colibriSessionManager.removeParticipant(participant.getEndpointId()); + cancel(); + } + } + + /** + * Raises the {@code canceled} flag, which causes the thread to not continue + * with the allocation process. + */ + @Override + public void cancel() + { + canceled = true; + } + + @Override + public String toString() + { + return String.format( + "%s[%s]@%d", + this.getClass().getSimpleName(), + participant, + hashCode()); + } + + /** + * {@inheritDoc} + */ + private Offer createOffer() + { + OfferOptions offerOptions = new OfferOptions(); + OfferOptionsUtilKt.applyConstraints(offerOptions, participant); + // Enable REMB only when TCC is not enabled. + if (!offerOptions.getTcc() && participant.hasRembSupport()) + { + offerOptions.setRemb(true); + } + + return new Offer(new ConferenceSourceMap(), JingleOfferFactory.INSTANCE.createOffer(offerOptions)); + } + + /** + * {@inheritDoc} + */ + private void invite(Offer offer, ColibriAllocation colibriAllocation) + throws SmackException.NotConnectedException + { + /* + This check makes sure that when we're trying to invite + new participant: + - the conference has not been disposed in the meantime + - he's still in the room + - we have managed to send Jingle session-initiate + We usually expire channels when participant leaves the MUC, but we + may not have channel information set, so we have to expire it + here. + */ + boolean expireChannels = false; + Jid address = participant.getMucJid(); + + ChatRoom chatRoom = meetConference.getChatRoom(); + if (chatRoom == null) + { + // Conference disposed + logger.info("Expiring " + address + " channels - conference disposed"); + + expireChannels = true; + } + else if (!meetConference.hasMember(address)) + { + // Participant has left the room + logger.info("Expiring " + address + " channels - participant has left"); + + expireChannels = true; + } + else if (!canceled) + { + if (!doInviteOrReinvite(offer, colibriAllocation)) + { + expireChannels = true; + } + } + + if (expireChannels || canceled) + { + // Whether another thread intentionally canceled us, or there was + // a failure to invite the participant on the jingle level, we will + // not trigger a retry here. + meetConference.onInviteFailed(this); + } + } + + /** + * Invites or re-invites (based on the value of {@link #reInvite}) the + * {@code participant} to the jingle session. + * Creates and sends the appropriate Jingle IQ ({@code session-initiate} for + * and invite or {@code transport-replace} for a re-invite) and sends it to + * the {@code participant}. Blocks until a response is received or a timeout + * occurs. + * + * @param offer The description of the offer to send (sources and a list of {@link ContentPacketExtension}s). + * @return {@code false} on failure. + * @throws SmackException.NotConnectedException if we are unable to send a packet because the XMPP connection is not + * connected. + */ + private boolean doInviteOrReinvite(Offer offer, ColibriAllocation colibriAllocation) + throws SmackException.NotConnectedException + { + JingleSession jingleSession = participant.getJingleSession(); + + // If we're trying to re-invite, but there's no existing jingle session, start a new one. + boolean initiateSession = !reInvite || jingleSession == null; + boolean ack; + List additionalExtensions = new ArrayList<>(); + + if (startAudioMuted || startVideoMuted) + { + StartMutedPacketExtension startMutedExt = new StartMutedPacketExtension(); + startMutedExt.setAudioMute(startAudioMuted); + startMutedExt.setVideoMute(startVideoMuted); + additionalExtensions.add(startMutedExt); + } + + // Include info about the BridgeSession which provides the transport + additionalExtensions.add(new BridgeSessionPacketExtension( + colibriAllocation.getBridgeSessionId(), + colibriAllocation.getRegion())); + + // We're about to send a jingle message that will initialize or reset the sources signaled to the participant. + // Reflect this in the participant state. + ConferenceSourceMap sources = participant.resetSignaledSources(offer.getSources()); + if (initiateSession) + { + jingleSession = participant.createNewJingleSession(); + logger.info("Sending session-initiate to: " + participant.getMucJid() + " sources=" + sources); + ack = jingleSession.initiateSession( + offer.getContents(), + additionalExtensions, + sources + ); + } + else + { + ack = jingleSession.replaceTransport(offer.getContents(), additionalExtensions, sources); + } + + if (!ack) + { + // Failed to invite + logger.info( + "Expiring " + participant.getMucJid() + " channels - no RESULT for " + + (initiateSession ? "session-initiate" + : "transport-replace")); + return false; + } + + return true; + } + + private @NotNull Offer updateOffer(Offer offer, ColibriAllocation colibriAllocation) + { + ConferenceSourceMap conferenceSources; + + if (!participant.useSsrcRewriting()) + { + // Take all sources from participants in the conference. + conferenceSources = meetConference.getSources().copy(); + } + else + { + // Bridge will signal sources in this case. + conferenceSources = new ConferenceSourceMap(); + } + + // Add the bridge's feedback sources. + conferenceSources.add(colibriAllocation.getSources()); + // Remove the participant's own sources (if they're present) + conferenceSources.remove(participant.getEndpointId()); + + for (ContentPacketExtension cpe : offer.getContents()) + { + try + { + // Remove empty transport PE + IceUdpTransportPacketExtension empty = cpe.getFirstChildOfType(IceUdpTransportPacketExtension.class); + cpe.getChildExtensions().remove(empty); + + IceUdpTransportPacketExtension copy = + IceUdpTransportPacketExtension.cloneTransportAndCandidates( + colibriAllocation.getTransport(), + true); + + if ("data".equalsIgnoreCase(cpe.getName())) + { + SctpMapExtension sctpMap = new SctpMapExtension(); + Integer sctpPort = colibriAllocation.getSctpPort(); + // The SCTP port is either hard-coded non-null (colibri1) or verified while parsing the response + // (colibri2). + if (sctpPort == null) + { + throw new IllegalStateException("SCTP port must not be null"); + } + sctpMap.setPort(sctpPort); + sctpMap.setProtocol(SctpMapExtension.Protocol.WEBRTC_CHANNEL); + sctpMap.setStreams(1024); + + copy.addChildExtension(sctpMap); + } + + cpe.addChildExtension(copy); + } + catch (Exception e) + { + logger.error(e, e); + } + + RtpDescriptionPacketExtension rtpDescPe = JingleUtils.getRtpDescription(cpe); + if (rtpDescPe != null) + { + // rtcp-mux is always used + rtpDescPe.addChildExtension(new JingleRtcpmuxPacketExtension()); + } + } + + + return new Offer(conferenceSources, offer.getContents()); + } + + /** + * @return the {@link Participant} associated with this + * {@link ParticipantInviteRunnable}. + */ + public @NotNull Participant getParticipant() + { + return participant; + } +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/jibri/JibriSession.java b/jicofo/src/main/java/org/jitsi/jicofo/jibri/JibriSession.java new file mode 100644 index 0000000..4279f5a --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/jibri/JibriSession.java @@ -0,0 +1,966 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2017-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jibri; + +import edu.umd.cs.findbugs.annotations.*; +import io.opentelemetry.api.trace.*; +import io.opentelemetry.context.*; +import org.jetbrains.annotations.Nullable; +import org.jitsi.jicofo.*; +import org.jitsi.jicofo.xmpp.*; +import org.jitsi.xmpp.extensions.*; +import org.jitsi.xmpp.extensions.jibri.*; +import org.jitsi.xmpp.extensions.jibri.JibriIq.*; +import org.jetbrains.annotations.*; +import org.jitsi.utils.logging2.*; +import org.jivesoftware.smack.*; +import org.jivesoftware.smack.packet.*; +import org.jxmpp.jid.*; + +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +import static org.apache.commons.lang3.StringUtils.*; + +/** + * Class holds the information about Jibri session. It can be either live + * streaming or SIP gateway session {@link #isSIP}. Encapsulates the retry logic + * which is supposed to try another instance when the current one fails. To make + * this happen it needs to cache all the information required to start new + * session. It uses {@link JibriDetector} to select new Jibri. + * + * @author Pawel Domas + * + * This is not meant to be `public`, but has to be exposed because of compatibility with kotlin (JibriStats.kt takes + * a parameter type that exposes JibriSession and it can not be restricted to java's "package private"). + */ +public class JibriSession +{ + /** + * Returns true if given status indicates that Jibri is in + * the middle of starting of the recording process. + */ + static private boolean isStartingStatus(JibriIq.Status status) + { + return JibriIq.Status.PENDING.equals(status); + } + + /** + * The JID of the Jibri currently being used by this session or + * null otherwise. + * TODO: Fix the inconsistent synchronization + */ + @SuppressFBWarnings("IS2_INCONSISTENT_SYNC") + private Jid currentJibriJid; + + /** + * The display name Jibri attribute received from Jitsi Meet to be passed + * further to Jibri instance that will be used. + */ + private final String displayName; + + /** + * Indicates whether this session is for a SIP Jibri (true) or for + * regular Jibri (false). + */ + private final boolean isSIP; + + /** + * {@link JibriDetector} instance used to select a Jibri which will be used + * by this session. + */ + private final JibriDetector jibriDetector; + + /** + * Helper class that registers for events from the {@link JibriDetector}. + */ + private final JibriDetectorEventHandler jibriEventHandler = new JibriDetectorEventHandler(); + + /** + * Current Jibri recording status. + */ + private JibriIq.Status jibriStatus = JibriIq.Status.UNDEFINED; + + private final Logger logger; + + /** + * The listener which will be notified about status changes of this session. + */ + private final StateListener stateListener; + + /** + * Reference to scheduled {@link PendingStatusTimeout} + */ + @NotNull + private final AtomicReference> pendingTimeoutTask = new AtomicReference<>(); + + /** + * How long this session can stay in "pending" status, before retry is made + * (given in seconds). + */ + private final long pendingTimeout; + + /** + * The (bare) JID of the MUC room. + */ + private final EntityBareJid roomName; + + /** + * The SIP address attribute received from Jitsi Meet which is to be used to + * start a SIP call. This field's used only if {@link #isSIP} is set to + * true. + */ + private final String sipAddress; + + /** + * The id of the live stream received from Jitsi Meet, which will be used to + * start live streaming session (used only if {@link #isSIP is set to + * true}. + */ + private final String streamID; + + private final String sessionId; + + /** + * The broadcast id of the YouTube broadcast, if available. This is used + * to generate and distribute the viewing url of the live stream + */ + private final String youTubeBroadcastId; + + /** + * A JSON-encoded string containing arbitrary application data for Jibri + */ + private final String applicationData; + + /** + * Whether rtcstats is enabled for this conference. + */ + private final boolean rtcStatsEnabled; + + /** + * The maximum amount of retries we'll attempt + */ + private final int maxNumRetries; + + /** + * How many times we've retried this request to another Jibri + */ + private int numRetries = 0; + + /** + * The full JID of the entity that has initiated the recording flow. + */ + private final Jid initiator; + + /** + * The full JID of the entity that has initiated the stop of the recording. + */ + private Jid terminator; + + /** + * Creates new {@link JibriSession} instance. + * @param stateListener the listener to be notified about this session state changes. + * @param roomName the name if the XMPP MUC room (full address). + * @param pendingTimeout how many seconds this session can wait in pending + * state, before trying another Jibri instance or failing with an error. + * @param jibriDetector the Jibri detector which will be used to select + * Jibri instance. + * @param isSIP true if it's a SIP session or false for + * a regular live streaming Jibri type of session. + * @param sipAddress a SIP address if it's a SIP session + * @param displayName a display name to be used by Jibri participant + * entering the conference once the session starts. + * @param streamID a live streaming ID if it's not a SIP session + * @param youTubeBroadcastId the YouTube broadcast id (optional) + * @param applicationData a JSON-encoded string containing application-specific + * data for Jibri + * @param parentLogger the parent logger whose context will be inherited by {@link #logger}. + */ + JibriSession( + StateListener stateListener, + EntityBareJid roomName, + Jid initiator, + long pendingTimeout, + int maxNumRetries, + JibriDetector jibriDetector, + boolean isSIP, + String sipAddress, + String displayName, + String streamID, + String youTubeBroadcastId, + String sessionId, + String applicationData, + boolean rtcStatsEnabled, + Logger parentLogger) + { + this.stateListener = stateListener; + this.roomName = roomName; + this.initiator = initiator; + this.pendingTimeout = pendingTimeout; + this.maxNumRetries = maxNumRetries; + this.isSIP = isSIP; + this.jibriDetector = jibriDetector; + this.sipAddress = sipAddress; + this.displayName = displayName; + this.streamID = streamID; + this.youTubeBroadcastId = youTubeBroadcastId; + this.sessionId = sessionId; + this.applicationData = applicationData; + this.rtcStatsEnabled = rtcStatsEnabled; + jibriDetector.addHandler(jibriEventHandler); + logger = parentLogger.createChildLogger(getClass().getName()); + } + + /** + * Used internally to call + * {@link StateListener#onSessionStateChanged(JibriSession, Status, FailureReason)}. + * @param newStatus the new status to dispatch. + * @param failureReason the failure reason associated with the state + * transition if any. + */ + private void dispatchSessionStateChanged(Status newStatus, FailureReason failureReason) + { + if (failureReason != null) + { + JibriStats.sessionFailed(getJibriType()); + } + stateListener.onSessionStateChanged(this, newStatus, failureReason); + } + + /** + * @return The {@link JibriSession.Type} of this session. + */ + public Type getJibriType() + { + if (isSIP) + { + return Type.SIP_CALL; + } + else if (isBlank(streamID)) + { + return Type.RECORDING; + } + else + { + return Type.LIVE_STREAMING; + } + } + + /** + * @return {@code true} if this sessions is active or {@code false} + * otherwise. + */ + public boolean isActive() + { + return Status.ON.equals(jibriStatus); + } + + /** + * @return {@code true} if this session is pending or {@code false} + * otherwise. + */ + public boolean isPending() + { + return Status.UNDEFINED.equals(jibriStatus) || Status.PENDING.equals(jibriStatus); + } + + /** + * Starts this session. A new Jibri instance will be selected and start + * request will be sent (in non blocking mode). + * @throws StartException if failed to start. + */ + synchronized public void start() + throws StartException + { + try + { + startInternal(); + } + catch (Exception e) + { + JibriStats.sessionFailed(getJibriType()); + + if (e instanceof StartException.NotAvailable || e instanceof StartException.AllBusy) + { + JibriStats.noInstanceFailed(); + } + + throw e; + } + } + + /** + * Does the actual start logic. + * + * @throws StartException if fails to start. + */ + private void startInternal() + throws StartException + { + final Jid jibriJid = jibriDetector.selectJibri(); + + if (jibriJid == null) + { + logger.error("Unable to find an available Jibri, can't start"); + + if (jibriDetector.isAnyInstanceConnected()) + { + throw new StartException.AllBusy(); + } + + throw new StartException.NotAvailable(); + } + + try + { + logger.info("Starting session with Jibri " + jibriJid); + + sendJibriStartIq(jibriJid); + } + catch (Exception e) + { + logger.error("Failed to send start Jibri IQ: " + e, e); + jibriDetector.instanceFailed(jibriJid); + if (!maxRetriesExceeded()) + { + retryRequestWithAnotherJibri(); + } + else + { + throw new StartException.InternalServerError(); + } + } + } + + /** + * Stops this session if it's not already stopped. Sends a stop IQ to the jibri instance and immediately + * cleans up without waiting for or processing any response. This prevents any retry logic from being + * triggered and ensures back references are broken immediately. + * + * @param initiator The jid of the initiator of the stop request. + */ + synchronized public void stop(Jid initiator) + { + if (currentJibriJid == null) + { + return; + } + this.terminator = initiator; + + logger.info("Stopping session for room: " + roomName); + + // Remove the detector event handler immediately to prevent any presence/offline events from + // triggering retry logic + jibriDetector.removeHandler(jibriEventHandler); + + // Clear the pending timeout task + clearPendingTimeout(true); + + // Send a fire-and-forget stop IQ to the jibri instance + JibriIq stopRequest = new JibriIq(); + stopRequest.setType(IQ.Type.set); + stopRequest.setTo(currentJibriJid); + stopRequest.setAction(JibriIq.Action.STOP); + stopRequest.setSessionId(this.sessionId); + + Span span = Span.fromContextOrNull(Context.current()); + if (span != null) + { + stopRequest.addExtension( + new TraceParent( + span.getSpanContext().getTraceId(), + span.getSpanContext().getSpanId(), + span.getSpanContext().getTraceFlags().asHex() + ) + ); + } + + logger.info("Sending stop IQ (fire-and-forget): " + stopRequest.toXML()); + + try + { + jibriDetector.getXmppConnection().trySendStanza(stopRequest); + } + catch (Exception e) + { + logger.warn("Failed to send stop IQ: " + e); + } + + // Immediately notify the listener that the session is stopped (this will publish status to MUC for + // user-initiated stops, or be a no-op for conference shutdown since jibriSession is set to null) + dispatchSessionStateChanged(Status.OFF, null); + + // Clean up immediately - this makes any in-flight callbacks from previous operations ignore this session + currentJibriJid = null; + numRetries = 0; + } + + private void cleanupSession() + { + logger.info("Cleaning up current JibriSession"); + currentJibriJid = null; + numRetries = 0; + jibriDetector.removeHandler(jibriEventHandler); + } + + /** + * Accept only XMPP packets which are coming from the Jibri currently used + * by this session. + * {@inheritDoc} + */ + public boolean accept(JibriIq packet) + { + return currentJibriJid != null && (packet.getFrom().equals(currentJibriJid)); + } + + /** + * @return a string describing this session instance, used for logging + * purpose + */ + private String nickname() + { + return this.isSIP ? "SIP Jibri" : "Jibri"; + } + + /** + * Process a {@link JibriIq} *request* from Jibri + * @return the response + */ + IQ processJibriIqRequestFromJibri(JibriIq request) + { + processJibriIqFromJibri(request); + return IQ.createResultIQ(request); + } + + /** + * Process a {@link JibriIq} from Jibri (note that this + * may be an IQ request or an IQ response) + */ + private void processJibriIqFromJibri(JibriIq iq) + { + // We have something from Jibri - let's update recording status + JibriIq.Status status = iq.getStatus(); + if (!JibriIq.Status.UNDEFINED.equals(status)) + { + logger.info("Updating status from JIBRI: " + iq.toXML() + " for " + roomName); + + handleJibriStatusUpdate(iq.getFrom(), status, iq.getFailureReason(), iq.getShouldRetry()); + } + else + { + logger.error("Received UNDEFINED status from jibri: " + iq); + } + } + + /** + * Gets the recording mode of this jibri session + * @return the recording mode for this session (STREAM, FILE or UNDEFINED + * in the case that this isn't a recording session but actually a SIP + * session) + */ + JibriIq.RecordingMode getRecordingMode() + { + if (sipAddress != null) + { + return RecordingMode.UNDEFINED; + } + else if (streamID != null) + { + return RecordingMode.STREAM; + } + return RecordingMode.FILE; + } + + /** + * Sends an IQ to the given Jibri instance and asks it to start + * recording/SIP call. + */ + private void sendJibriStartIq(final Jid jibriJid) + throws SmackException.NotConnectedException, + StartException + { + // Store Jibri JID to make the packet filter accept the response + currentJibriJid = jibriJid; + logger.info( + "Starting Jibri " + jibriJid + + (isSIP + ? ("for SIP address: " + sipAddress) + : (" for stream ID: " + streamID)) + + " in room: " + roomName); + + final JibriIq startIq = new JibriIq(); + + startIq.setTo(jibriJid); + startIq.setType(IQ.Type.set); + startIq.setAction(JibriIq.Action.START); + startIq.setSessionId(sessionId); + logger.debug( + "Passing on jibri application data: " + this.applicationData); + startIq.setAppData(this.applicationData); + if (streamID != null) + { + startIq.setStreamId(streamID); + startIq.setRecordingMode(RecordingMode.STREAM); + if (youTubeBroadcastId != null) + { + startIq.setYouTubeBroadcastId(youTubeBroadcastId); + } + } + else + { + startIq.setRecordingMode(RecordingMode.FILE); + } + startIq.setSipAddress(sipAddress); + startIq.setDisplayName(displayName); + startIq.setRtcStatsEnabled(rtcStatsEnabled); + + // Insert name of the room into Jibri START IQ + startIq.setRoom(roomName); + + Span span = Span.fromContextOrNull(Context.current()); + if (span != null) + { + startIq.addExtension( + new TraceParent( + span.getSpanContext().getTraceId(), + span.getSpanContext().getSpanId(), + span.getSpanContext().getTraceFlags().asHex() + ) + ); + } + // We will not wait forever for the Jibri to start. This method can be + // run multiple times on retry, so we want to restart the pending + // timeout each time. + reschedulePendingTimeout(); + + IQ reply = UtilKt.sendIqAndGetResponse(jibriDetector.getXmppConnection(), startIq); + + if (reply == null) + { + logger.error("Jibri start request timed out, sending a stop command."); + + // The timeout may be due to internal jibri processing and not because of the network (e.g. chrome + // taking a long time to start up). Since we won't be keeping a track of this jibri session anymore, send + // a "stop" to prevent it from staying in the conference (if it succeeds to get there). + JibriIq stopRequest = new JibriIq(); + + stopRequest.setType(IQ.Type.set); + stopRequest.setTo(jibriJid); + stopRequest.setAction(JibriIq.Action.STOP); + stopRequest.setSessionId(sessionId); + + span = Span.fromContextOrNull(Context.current()); + if (span != null) + { + stopRequest.addExtension( + new TraceParent( + span.getSpanContext().getTraceId(), + span.getSpanContext().getSpanId(), + span.getSpanContext().getTraceFlags().asHex() + ) + ); + } + + try + { + jibriDetector.getXmppConnection().trySendStanza(stopRequest); + } + catch (SmackException.NotConnectedException e) + { + logger.error("Can't send stop IQ, not connected"); + } + + throw new StartException.UnexpectedResponse(); + } + if (!(reply instanceof JibriIq)) + { + logger.error("Unexpected response to start request: " + reply.toXML()); + + throw new StartException.UnexpectedResponse(); + } + + JibriIq jibriIq = (JibriIq) reply; + if (isBusyResponse(jibriIq)) + { + logger.info("Jibri " + jibriIq.getFrom() + " was busy"); + throw new StartException.OneBusy(); + } + if (!isPendingResponse(jibriIq)) + { + logger.error( + "Unexpected status received in response to the start IQ: " + jibriIq.toXML()); + + throw new StartException.UnexpectedResponse(); + } + + processJibriIqFromJibri(jibriIq); + } + + /** + * Method schedules/reschedules {@link PendingStatusTimeout} which will clear recording state after a timeout of + * {@link JibriConfig#getPendingTimeout()}. + */ + private void reschedulePendingTimeout() + { + // If pendingTimeout <= 0, no tasks are ever scheduled, so there is nothing to cancel. + if (pendingTimeout > 0) + { + ScheduledFuture newTask + = TaskPools.getScheduledPool().schedule(new PendingStatusTimeout(), pendingTimeout, TimeUnit.SECONDS); + ScheduledFuture oldTask = pendingTimeoutTask.getAndSet(newTask); + if (oldTask != null) + { + logger.info("Rescheduling pending timeout task for room: " + roomName); + oldTask.cancel(false); + } + } + } + + /** + * Clear the pending timeout task. + * + * @param cancel whether to cancel the previous task if it exists. + */ + private void clearPendingTimeout(boolean cancel) + { + ScheduledFuture oldTask = pendingTimeoutTask.getAndSet(null); + if (cancel) + { + if (oldTask != null) + { + logger.info("Jibri is no longer pending, cancelling pending timeout task"); + oldTask.cancel(false); + } + } + } + + /** + * Check whether or not we should retry the current request to another Jibri + * @return true if we've not exceeded the max amount of retries, + * false otherwise + */ + private boolean maxRetriesExceeded() + { + return (maxNumRetries >= 0 && numRetries >= maxNumRetries); + } + + /** + * Retry the current request with another Jibri (if one is available) + * @throws StartException if failed to start. + */ + private void retryRequestWithAnotherJibri() + throws StartException + { + numRetries++; + start(); + } + + /** + * Handle a Jibri status update (this could come from an IQ response, a new + * IQ from Jibri, an XMPP event, etc.). + * This will handle: + * 1) Retrying with a new Jibri in case of an error + * 2) Cleaning up the session when the Jibri session finished successfully + * (or there was an error but we have no more Jibris left to try) + * @param jibriJid the jid of the jibri for which this status update applies + * @param newStatus the jibri's new status + * @param failureReason the jibri's failure reason, if any (otherwise null) + * @param shouldRetryParam if {@code failureReason} is not null, shouldRetry + * denotes whether or not we should retry the same + * request with another Jibri + */ + private void handleJibriStatusUpdate( + @NotNull Jid jibriJid, + JibriIq.Status newStatus, + @Nullable JibriIq.FailureReason failureReason, + @Nullable Boolean shouldRetryParam) + { + jibriStatus = newStatus; + logger.info("Got Jibri status update: Jibri " + jibriJid + + " has status " + newStatus + + " and failure reason " + failureReason + + ", current Jibri jid is " + currentJibriJid); + if (currentJibriJid == null) + { + logger.info("Current session has already been cleaned up, ignoring"); + return; + } + if (jibriJid.compareTo(currentJibriJid) != 0) + { + logger.info("This status update is from " + jibriJid + + " but the current Jibri is " + currentJibriJid + ", ignoring"); + return; + } + // First: if we're no longer pending (regardless of the Jibri's new state), make sure we stop the pending + // timeout task. + if (!Status.PENDING.equals(newStatus)) + { + clearPendingTimeout(true); + } + + // Now, if there was a failure of any kind we'll try and find another + // Jibri to keep things going + if (failureReason != null) + { + boolean shouldRetry; + if (shouldRetryParam == null) + { + logger.warn("failureReason was non-null but shouldRetry wasn't set, will NOT retry"); + shouldRetry = false; + } + else + { + shouldRetry = shouldRetryParam; + } + // There was an error with the current Jibri, see if we should retry + if (shouldRetry && !maxRetriesExceeded()) + { + logger.info("Jibri failed, trying to fall back to another Jibri"); + + try + { + retryRequestWithAnotherJibri(); + + // The fallback to another Jibri succeeded. + logger.info("Successfully resumed session with another Jibri"); + } + catch (StartException exc) + { + logger.warn("Failed to fall back to another Jibri, this session has now failed: " + exc, exc); + // Propagate up that the session has failed entirely. + // We'll pass the original failure reason. + dispatchSessionStateChanged(newStatus, failureReason); + cleanupSession(); + } + } + else + { + if (!shouldRetry) + { + logger.info("Jibri failed and signaled that we should not retry the same request"); + } + else + { + // The Jibri we tried failed and we've reached the maxmium + // amount of retries we've been configured to attempt, so we'll + // give up trying to handle this request. + logger.info("Jibri failed, but max amount of retries (" + maxNumRetries + ") reached, giving up"); + } + dispatchSessionStateChanged(newStatus, failureReason); + cleanupSession(); + } + } + else if (Status.OFF.equals(newStatus)) + { + logger.info("Jibri session ended cleanly, notifying owner and " + + "cleaning up session"); + // The Jibri stopped for some non-error reason + dispatchSessionStateChanged(newStatus, null); + cleanupSession(); + } + else if (Status.ON.equals(newStatus)) + { + logger.info("Jibri session started, notifying owner"); + dispatchSessionStateChanged(newStatus, null); + } + } + + /** + * @return SIP address received from Jitsi Meet, which is used for SIP + * gateway session (makes sense only for SIP sessions). + */ + String getSipAddress() + { + return sipAddress; + } + + /** + * Get the unique ID for this session. This is used to uniquely + * identify a Jibri session instance, even of the same type (meaning, + * for example, that two file recordings would have different session + * IDs). It will be passed to Jibri and Jibri will put the session ID + * in its presence, so the Jibri user for a particular session can + * be identified by the clients. + * @return the session ID + */ + public String getSessionId() + { + return this.sessionId; + } + + /** + * Task scheduled after we have received RESULT response from Jibri and entered PENDING state. Will abort the + * recording if we do not transit to ON state, after a timeout of {@link JibriConfig#getPendingTimeout()}. + */ + private class PendingStatusTimeout implements Runnable + { + public void run() + { + synchronized (JibriSession.this) + { + // Clear this task reference, so it won't be + // cancelling itself on status change from PENDING + clearPendingTimeout(false); + + if (isStartingStatus(jibriStatus)) + { + logger.error(nickname() + " pending timeout! " + roomName); + // If a Jibri times out during the pending phase, it's + // likely hung or having some issue. We'll send a stop (so + // if/when it does 'recover', it knows to stop) and simulate + // an error status (like we do in + // JibriEventHandler#handleEvent when a Jibri goes offline) + // to trigger the fallback logic. + stop(null); + handleJibriStatusUpdate(currentJibriJid, Status.OFF, FailureReason.ERROR, true); + } + } + } + } + + /** + * The JID of the entity that has initiated the recording flow. + * @return The JID of the entity that has initiated the recording flow. + */ + public Jid getInitiator() + { + return initiator; + } + + /** + * The JID of the entity that has initiated the stop of the recording. + * @return The JID of the entity that has stopped the recording. + */ + public Jid getTerminator() + { + return terminator; + } + + /** + * Interface instance passed to {@link JibriSession} constructor which + * specifies the session owner which will be notified about any status + * changes. + */ + public interface StateListener + { + /** + * Called on {@link JibriSession} status update. + * @param jibriSession which status has changed + * @param newStatus the new status + * @param failureReason optional error for {@link JibriIq.Status#OFF}. + */ + void onSessionStateChanged( + JibriSession jibriSession, + JibriIq.Status newStatus, + JibriIq.FailureReason failureReason); + } + + static public abstract class StartException extends Exception + { + public StartException(String message) + { + super(message); + } + + static public class AllBusy extends StartException + { + public AllBusy() + { + super("All jibri instances are busy"); + } + } + static public class InternalServerError extends StartException + { + public InternalServerError() + { + super("Internal server error"); + } + } + static public class NotAvailable extends StartException + { + public NotAvailable() + { + super("No Jibris available"); + } + } + static public class UnexpectedResponse extends StartException + { + public UnexpectedResponse() + { + super("Unexpected response"); + } + } + static public class OneBusy extends StartException + { + public OneBusy() + { + super("This Jibri instance was busy"); + } + } + } + + /** + * A Jibri session type. + */ + public enum Type + { + /** + * SIP Jibri call. + */ + SIP_CALL, + /** + * Jibri live streaming session. + */ + LIVE_STREAMING, + /** + * Jibri recording session. + */ + RECORDING + } + + private class JibriDetectorEventHandler implements JibriDetector.EventHandler + { + @Override + public void instanceOffline(Jid jid) + { + if (jid.equals(currentJibriJid)) + { + logger.warn(nickname() + " went offline: " + jid + + " for room: " + roomName); + handleJibriStatusUpdate( + jid, Status.OFF, FailureReason.ERROR, true); + } + } + } + + /** + * Returns true if the given IQ represens a busy response from Jibri + */ + private boolean isBusyResponse(JibriIq iq) + { + return Status.OFF.equals(iq.getStatus()) && + iq.isFailure() && + FailureReason.BUSY.equals(iq.getFailureReason()); + } + + private boolean isPendingResponse(JibriIq iq) + { + return Status.PENDING.equals(iq.getStatus()); + } + +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/util/ErrorResponse.java b/jicofo/src/main/java/org/jitsi/jicofo/util/ErrorResponse.java new file mode 100644 index 0000000..d429060 --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/util/ErrorResponse.java @@ -0,0 +1,41 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util; + +import org.jivesoftware.smack.packet.*; + +/** + * Utility for Error IQ. + */ +public class ErrorResponse +{ + /** + * A convenience method for creating error responses. + * + * @param request the {@link IQ.Type#get IQ.Type.get} or + * {@link IQ.Type#set IQ.Type.set} IQ packet. + * @param condition the {@link StanzaError.Condition} for the error. + * @param text human readable error description. + * @return ErrorIQ + */ + static public ErrorIQ create( + IQ request, StanzaError.Condition condition, String text) + { + return IQ.createErrorResponse( + request, + StanzaError.from(condition, text).build()); + } +} diff --git a/jicofo/src/main/java/org/jitsi/jicofo/version/CurrentVersionImpl.java b/jicofo/src/main/java/org/jitsi/jicofo/version/CurrentVersionImpl.java new file mode 100644 index 0000000..36c789b --- /dev/null +++ b/jicofo/src/main/java/org/jitsi/jicofo/version/CurrentVersionImpl.java @@ -0,0 +1,101 @@ +/* + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.version; + +import org.jitsi.utils.version.*; + +import java.util.regex.*; + +/** + * Keeps constants for the application version. + * + * Note that the constants are modified at build time, so changes to this file + * must be synchronized with the build system. + * + * @author Pawel Domas + * @author Boris Grozev + */ +public class CurrentVersionImpl +{ + /** + * Default version values can be overwritten by the manifest Implementation + * Version in the format 2.1-build-id. + */ + private static int parsedMajor = 1; + private static int parsedMinor = 1; + private static String parsedBuildId = null; + + static { + String version = CurrentVersionImpl.class.getPackage() + .getImplementationVersion(); + if (version != null) + { + Matcher m + = Pattern.compile("(\\d*)\\.(\\d*)-(.*)").matcher(version); + if (m.find()) + { + try + { + parsedMajor = Integer.parseInt(m.group(1)); + } + catch (NumberFormatException nfe) {} + + try + { + parsedMinor = Integer.parseInt(m.group(2)); + } + catch (NumberFormatException nfe) {} + + parsedBuildId = m.group(3); + } + } + } + + /** + * The version major of the current application version. In an example + * 2.3.1 version string 2 is the version major. The version major number + * changes when a relatively extensive set of new features and possibly + * rearchitecturing have been applied to the Jicofo. + */ + public static final int VERSION_MAJOR = parsedMajor; + + /** + * Returns the version minor of the current application version. In an + * example 2.3.1 version string 3 is the version minor. The version minor + * number changes after adding enhancements and possibly new features to a + * given Jicofo version. + */ + public static final int VERSION_MINOR = parsedMinor; + + /** + * The version prerelease ID of the current application version. + */ + public static final String PRE_RELEASE_ID = null; // "beta1"; + + /** + * The nightly build ID. This file is auto-updated by build.xml. + */ + public static final String NIGHTLY_BUILD_ID + = parsedBuildId != null ? parsedBuildId : "build.git"; + + public static final Version VERSION + = new VersionImpl( + "JiCoFo", + VERSION_MAJOR, + VERSION_MINOR, + NIGHTLY_BUILD_ID, + PRE_RELEASE_ID); +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/ClientRequirementsConfig.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/ClientRequirementsConfig.kt new file mode 100644 index 0000000..0dbd43b --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/ClientRequirementsConfig.kt @@ -0,0 +1,152 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import com.typesafe.config.ConfigObject +import org.jitsi.config.JitsiConfig.Companion.newConfig +import org.jitsi.jicofo.xmpp.Features +import org.jitsi.metaconfig.config +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.clientrequirements.RequirementLevel + +/** + * A capability that clients are required to advertise. + */ +data class ClientRequirement( + val feature: Features, + val level: RequirementLevel, + /** Text (in English) which describes how to add support for the feature. Sent to the client. */ + val details: String? = null, + /** A URL with more information. Sent to the client. */ + val url: String? = null +) + +/** + * Configuration for the capabilities that clients are required to advertise (see [ClientRequirement]). + * + * The set of requirements is maintained in [defaultRequirements] below, and can be adjusted with configuration. + */ +class ClientRequirementsConfig { + private val logger = createLogger() + + val enabled: Boolean by config { + "jicofo.conference.client-requirements.enabled".from(newConfig) + } + + /** + * Whether to actually skip inviting endpoints which miss a requirement with a "hard" level. When this is false + * jicofo only logs and updates metrics, and notifies the endpoint as if the requirement were "soft". + */ + val enforce: Boolean by config { + "jicofo.conference.client-requirements.enforce".from(newConfig) + } + + /** + * The configured overrides, keyed by the name of a [Features]. A null [Override.level] means that the requirement + * is disabled (level "off" in configuration). + */ + private val overrides: Map by config { + "jicofo.conference.client-requirements.requirements".from(newConfig) + .convertFrom { cfg -> parseOverrides(cfg) } + } + + /** + * The effective list of requirements: [defaultRequirements] with the configured [overrides] applied. An override + * can change the level, details and url of a requirement, disable it (with level "off"), or add a new one. + */ + val requirements: List by lazy { + val map = defaultRequirements.associateBy { it.feature }.toMutableMap() + overrides.forEach { (feature, override) -> + val existing = map[feature] + val level = override.level + if (level == null) { + map.remove(feature) + } else { + map[feature] = ClientRequirement( + feature = feature, + level = level, + details = override.details ?: existing?.details, + url = override.url ?: existing?.url + ) + } + } + map.values.sortedBy { it.feature.name }.also { + logger.info("Client requirements: enabled=$enabled, enforce=$enforce, requirements=$it") + } + } + + private fun parseOverrides(cfg: ConfigObject): Map { + val result = mutableMapOf() + cfg.entries.forEach { entry -> + val feature = Features.values().find { it.name == entry.key } + if (feature == null) { + logger.error("Ignoring a requirement for an unknown feature: ${entry.key}") + return@forEach + } + val value = entry.value as? ConfigObject + if (value == null) { + logger.error("Ignoring a requirement which is not an object: ${entry.key}") + return@forEach + } + val config = value.toConfig() + val levelString = if (config.hasPath(LEVEL)) config.getString(LEVEL) else null + if (levelString != null && levelString != OFF && RequirementLevel.parseString(levelString) == null) { + logger.error("Ignoring a requirement with an invalid level: ${entry.key}=$levelString") + return@forEach + } + result[feature] = Override( + level = if (levelString == null || levelString == OFF) { + null + } else { + RequirementLevel.parseString(levelString) + }, + details = if (config.hasPath(DETAILS)) config.getString(DETAILS) else null, + url = if (config.hasPath(URL)) config.getString(URL) else null + ) + } + return result + } + + private data class Override(val level: RequirementLevel?, val details: String?, val url: String?) + + companion object { + @JvmField + val config = ClientRequirementsConfig() + + private const val LEVEL = "level" + private const val DETAILS = "details" + private const val URL = "url" + private const val OFF = "off" + + /** + * The capabilities that clients are required to advertise. Add entries here when the deployment depends on a + * client capability, e.g.: + * + * ClientRequirement( + * feature = Features.SSRC_REWRITING_V1, + * level = RequirementLevel.HARD, + * details = "Update to jitsi-meet 10.2 or later.", + * url = "https://jitsi.github.io/handbook/" + * ) + * + * The level, details and url of an entry can be changed with configuration, and an entry can be disabled with + * a level of "off" (see [Override]). + */ + val defaultRequirements: List = emptyList() + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceConfig.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceConfig.kt new file mode 100644 index 0000000..c4d6d02 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceConfig.kt @@ -0,0 +1,147 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import com.typesafe.config.ConfigObject +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import org.jitsi.config.JitsiConfig.Companion.legacyConfig +import org.jitsi.config.JitsiConfig.Companion.newConfig +import org.jitsi.metaconfig.config +import java.time.Duration +import java.util.TreeMap + +@SuppressFBWarnings(value = ["BX_UNBOXING_IMMEDIATELY_REBOXED"], justification = "False positive.") +class ConferenceConfig private constructor() { + val conferenceStartTimeout: Duration by config { + "org.jitsi.focus.IDLE_TIMEOUT".from(legacyConfig) + "jicofo.conference.initial-timeout".from(newConfig) + } + + val enableAutoOwner: Boolean by config { + "org.jitsi.jicofo.DISABLE_AUTO_OWNER".from(legacyConfig).transformedBy { !it } + "jicofo.conference.enable-auto-owner".from(newConfig) + } + fun enableAutoOwner(): Boolean = enableAutoOwner + + val enableModeratorChecks: Boolean by config { + "jicofo.conference.enable-moderator-checks".from(newConfig) + } + + val maxSsrcsPerUser: Int by config { + "org.jitsi.jicofo.MAX_SSRC_PER_USER".from(legacyConfig) + "jicofo.conference.max-ssrcs-per-user".from(newConfig) + } + + val maxSsrcGroupsPerUser: Int by config { + "jicofo.conference.max-ssrc-groups-per-user".from(newConfig) + } + + val singleParticipantTimeout: Duration by config { + "org.jitsi.jicofo.SINGLE_PARTICIPANT_TIMEOUT".from(legacyConfig) + "jicofo.conference.single-participant-timeout".from(newConfig) + } + + val minParticipants: Int by config { + "jicofo.conference.min-participants".from(newConfig) + } + + val maxAudioSenders: Int by config { + "jicofo.conference.max-audio-senders".from(newConfig) + } + + val maxVideoSenders: Int by config { + "jicofo.conference.max-video-senders".from(newConfig) + } + + val useSsrcRewriting: Boolean by config { + "jicofo.conference.use-ssrc-rewriting".from(newConfig) + } + + val useRtpMidDemux: Boolean by config { + "jicofo.conference.use-rtp-mid-demux".from(newConfig) + } + + val useJsonEncodedSources: Boolean by config { + "jicofo.conference.use-json-encoded-sources".from(newConfig) + } + + val useRandomSharedDocumentName: Boolean by config { + "jicofo.conference.shared-document.use-random-name".from(newConfig) + } + fun useRandomSharedDocumentName(): Boolean = useRandomSharedDocumentName + + private val sourceSignalingDelays: TreeMap by config { + "jicofo.conference.source-signaling-delays".from(newConfig) + .convertFrom { cfg -> + TreeMap(cfg.entries.associate { it.key.toInt() to it.value.unwrapped() as Int }) + } + } + + val restartRequestMinInterval: Duration by config { + "jicofo.conference.restart-request-rate-limits.min-interval".from(newConfig) + } + + val restartRequestMaxRequests: Int by config { + "jicofo.conference.restart-request-rate-limits.max-requests".from(newConfig) + } + + val restartRequestInterval: Duration by config { + "jicofo.conference.restart-request-rate-limits.interval".from(newConfig) + } + + /** + * Whether to honor in-place ICE restart requests (a `session-info` with ``). + * When disabled such requests are rejected and the client is expected to fall back to a full session restart. + */ + val enableIceRestart: Boolean by config { + "jicofo.conference.enable-ice-restart".from(newConfig) + } + + /** + * Get the number of milliseconds to delay signaling of Jingle sources given a certain [conferenceSize]. + */ + fun getSourceSignalingDelayMs(conferenceSize: Int) = sourceSignalingDelays.floorEntry(conferenceSize)?.value ?: 0 + + val reinviteMethod: ReinviteMethod by config { + "jicofo.conference.reinvite-method".from(newConfig) + } + + /** + * Whether to strip simulcast streams when signaling receivers. This option requires that jitsi-videobridge + * uses the first SSRC in the SIM group as the target SSRC when rewriting streams, as this is the only SSRC + * signaled to receivers. + * + * As an example, if a sender advertises simulcast with the following source groups: + * SIM(1, 2, 3), FID(1, 4), FID(2, 5), FID(3, 6) + * + * If this option is enabled jicofo removes sources 2, 3, 5 and 6 when signaling to receivers of the stream. + * This leaves just FID(1, 4), and assumes that jitsi-videobridge will use those two SSRCs for the rewritten stream. + * + * If the option is disabled, all sources are signaled to receivers. Lib-jitsi-meet has similar logic to strip + * simulcast from remote streams. + */ + val stripSimulcast: Boolean by config { + "jicofo.conference.strip-simulcast".from(newConfig) + } + fun stripSimulcast() = stripSimulcast + + companion object { + @JvmField + val config = ConferenceConfig() + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceRequest.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceRequest.kt new file mode 100644 index 0000000..390980a --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceRequest.kt @@ -0,0 +1,94 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.jitsi.xmpp.extensions.jitsimeet.ConferenceIq +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.stringprep.XmppStringprepException +import kotlin.jvm.Throws + +/** + * The initial request to create or join a conference, a generic version of [ConferenceIq]. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@JsonIgnoreProperties(ignoreUnknown = true) +class ConferenceRequest( + var room: String? = null, + var ready: Boolean? = null, + var focusJid: String? = null, + var sessionId: String? = null, + var machineUid: String? = null, + var identity: String? = null, + var vnode: String? = null, + val properties: MutableMap = mutableMapOf() +) { + @Throws(XmppStringprepException::class) + fun toConferenceIq(token: String? = null) = ConferenceIq().apply { + this@ConferenceRequest.room?.let { + room = JidCreate.entityBareFrom(it) + } + this@ConferenceRequest.ready?.let { + isReady = it + } + this@ConferenceRequest.focusJid?.let { + focusJid = it + } + this@ConferenceRequest.sessionId?.let { + sessionId = it + } + this@ConferenceRequest.machineUid?.let { + machineUID = it + } + this@ConferenceRequest.identity?.let { + identity = it + } + this@ConferenceRequest.vnode?.let { + vnode = it + } + token?.let { + setToken(it) + } + this@ConferenceRequest.properties.forEach { (k, v) -> addProperty(k, v) } + } + + fun toJson(): String = mapper.writeValueAsString(this) + + companion object { + fun fromConferenceIq(iq: ConferenceIq) = ConferenceRequest( + room = iq.room.toString(), + ready = iq.isReady, + focusJid = iq.focusJid, + sessionId = iq.sessionId, + machineUid = iq.machineUID, + identity = iq.identity, + vnode = iq.vnode, + properties = iq.propertiesMap + ) + + private val mapper = jacksonObjectMapper() + + @JvmStatic + @Throws(JsonProcessingException::class, JsonMappingException::class) + fun parseJson(s: String): ConferenceRequest = mapper.readValue(s, ConferenceRequest::class.java) + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceStore.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceStore.kt new file mode 100644 index 0000000..bd82f0d --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceStore.kt @@ -0,0 +1,47 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jxmpp.jid.EntityBareJid +import java.time.Duration + +interface ConferenceStore { + /** Get a list of all conferences. */ + fun getAllConferences(): List + + /** Get a conference for a specific [Jid] (i.e. name). */ + fun getConference(jid: EntityBareJid): JitsiMeetConference? + + fun getPinnedConferences(): List + fun pinConference(roomName: EntityBareJid, jvbVersion: String, duration: Duration) + fun unpinConference(roomName: EntityBareJid) + + fun addListener(listener: Listener) {} + fun removeListener(listener: Listener) {} + + interface Listener { + fun conferenceEnded(name: EntityBareJid) + } +} + +data class PinnedConference( + val conferenceId: String, + val jvbVersion: String, + val expiresAt: String +) diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/FocusManager.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/FocusManager.kt new file mode 100644 index 0000000..ba3fdf0 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/FocusManager.kt @@ -0,0 +1,399 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.conference.ConferenceMetrics +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jitsi.jicofo.conference.JitsiMeetConferenceImpl +import org.jitsi.jicofo.conference.JitsiMeetConferenceImpl.ConferenceListener +import org.jitsi.jicofo.jibri.JibriSession +import org.jitsi.jicofo.jibri.JibriStats +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jitsi.utils.logging2.createLogger +import org.jitsi.utils.queue.QueueStatistics.Companion.getStatistics +import org.jitsi.utils.stats.ConferenceSizeBuckets +import org.jxmpp.jid.EntityBareJid +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.logging.Level +import org.jitsi.jicofo.metrics.JicofoMetricsContainer.Companion.instance as metricsContainer + +/** + * Manages the set of [JitsiMeetConference]s in this instance. + * + * @author Pawel Domas + * @author Boris Grozev + */ +class FocusManager( + private val jicofoServices: JicofoServices, + /** Clock to use for pin timeouts. */ + private val clock: Clock = Clock.systemUTC(), +) : ConferenceListener, ConferenceStore, XmppProvider.Listener { + + val logger = createLogger() + + /** + * Jitsi Meet conferences mapped by MUC room names. + * + * Note that access to this field is almost always protected by a lock on `this`. However, `#getConferenceCount()` + * executes [Map.size] on it, which wouldn't be safe with a [HashMap] (as opposed to a [ConcurrentHashMap]. + * I've chosen this solution, because I don't know whether the cleaner solution of synchronizing on + * [.conferencesSyncRoot] in `#getConferenceCount()` is safe. + */ + private val conferences: MutableMap = ConcurrentHashMap() + private val conferencesCache: MutableList = CopyOnWriteArrayList() + private val conferencesByMeetingId: MutableMap = ConcurrentHashMap() + + /** The object used to synchronize access to [.conferences]. */ + private val conferencesSyncRoot = Any() + private val listeners: MutableList = ArrayList() + + /** Holds the conferences that are currently pinned to a specific bridge version. */ + private val pinnedConferences: MutableMap = HashMap() + + fun start() { + metricsContainer.metricsUpdater.addUpdateTask { updateMetrics() } + } + + /** + * Whether there are any breakout conferences whose main room is [jid]. + */ + fun hasBreakoutRooms(jid: EntityBareJid) = conferences.values.any { it.mainRoomJid == jid } + + /** + * @return true if conference focus is in the room and ready to handle session participants. + * @throws Exception if for any reason we have failed to create the conference. + */ + @JvmOverloads + @Throws(Exception::class) + fun conferenceRequest( + /** The name of MUC room for which new conference has to be allocated. */ + room: EntityBareJid, + /** Configuration properties map included in the request. */ + properties: Map, + /** The logging level which should be used by the new conference. */ + loggingLevel: Level = Level.ALL, + /** Whether this conference should be included in statistics. */ + includeInStatistics: Boolean = true + ): JitsiMeetConference { + var conference: JitsiMeetConferenceImpl + var isConferenceCreator: Boolean + synchronized(conferencesSyncRoot) { + val existingConference = conferences[room] + conference = existingConference ?: createConference(room, properties, loggingLevel, includeInStatistics) + isConferenceCreator = existingConference == null + if (!isConferenceCreator) conference.rescheduleConferenceStartTimeout() + } + try { + if (isConferenceCreator) { + conference.start() + } + } catch (e: Exception) { + logger.warn("Exception while trying to start the conference", e) + throw e + } + return conference + } + + /** Creates a new conference and registers it in [conferences]. */ + private fun createConference( + room: EntityBareJid, + properties: Map, + logLevel: Level, + includeInStatistics: Boolean + ): JitsiMeetConferenceImpl { + var conference: JitsiMeetConferenceImpl + synchronized(conferencesSyncRoot) { + val jvbVersion = getBridgeVersionForConference(room) + conference = JitsiMeetConferenceImpl( + room, + this, + properties, + logLevel, + jvbVersion, + includeInStatistics, + jicofoServices.xmppServices, + jicofoServices.bridgeSelector, + jicofoServices.jibriDetector, + jicofoServices.sipJibriDetector, + jicofoServices.authenticationAuthority, + this + ) + conferences[room] = conference + conferencesCache.add(conference) + } + if (includeInStatistics) { + ConferenceMetrics.conferenceCount.inc() + ConferenceMetrics.conferencesCreated.inc() + } + return conference + } + + /** {@inheritDoc} */ + override fun conferenceEnded(conference: JitsiMeetConferenceImpl) { + val roomName = conference.roomName + synchronized(conferencesSyncRoot) { + conferences.remove(roomName) + conferencesCache.remove(conference) + conferencesByMeetingId.remove(conference.meetingId) + if (conference.includeInStatistics()) { + ConferenceMetrics.conferenceCount.dec() + } + + // If this was a breakout room, tell the main conference that it ended. + conference.mainRoomJid?.let { mainRoomJid -> + conferences[mainRoomJid]?.let { + TaskPools.ioPool.submit { it.breakoutConferenceEnded() } + } + } + + // It is not clear whether the code below necessarily needs to hold the lock or not. + var listeners: Iterable + synchronized(this.listeners) { + listeners = ArrayList(this.listeners) + } + for (listener in listeners) { + listener.conferenceEnded(roomName) + } + } + } + + override fun meetingIdSet(conference: JitsiMeetConferenceImpl, meetingId: String): Boolean = + synchronized(conferencesSyncRoot) { + conferencesByMeetingId[meetingId]?.let { + // If there is already a conference with this meeting ID, we don't allow setting it. + logger.warn("Meeting ID $meetingId already exists for ${it.roomName}.") + false + } ?: run { + // Otherwise, we set the meeting ID and register the conference. + conferencesByMeetingId[meetingId] = conference + true + } + } + + /** {@inheritDoc} */ + override fun getConference(jid: EntityBareJid): JitsiMeetConference? = synchronized(conferencesSyncRoot) { + return conferences[jid] + } + + /** {@inheritDoc} */ + override fun getAllConferences() = getConferences() + + /** {@inheritDoc} */ + fun getConferences() = conferencesCache + + /** {@inheritDoc} */ + override fun addListener(listener: ConferenceStore.Listener) { + synchronized(listeners) { + listeners.add(listener) + } + } + + /** {@inheritDoc} */ + override fun removeListener(listener: ConferenceStore.Listener) { + synchronized(listeners) { + listeners.remove(listener) + } + } + + private fun updateMetrics() { + // Calculate the number of participants and conference size distribution + var numParticipants = 0 + var largestConferenceSize = 0 + val conferenceSizes = ConferenceSizeBuckets() + // The sum of squares of conference sizes. + var endpointPairs = 0 + val jibriSessions: MutableSet = HashSet() + var conferencesWithVisitors = 0 + var visitors = 0L + + for (conference in getConferences()) { + if (!conference.includeInStatistics()) { + continue + } + var confSize = conference.participantCount + val conferenceVisitors = conference.visitorCount + visitors += conferenceVisitors + if (conferenceVisitors > 0) { + conferencesWithVisitors++ + } + // getParticipantCount only includes endpoints with allocated media + // channels, so if a single participant is waiting in a meeting + // they wouldn't be counted. In stats, calling this a conference + // with size 0 would be misleading, so we add 1 in this case to + // properly show it as a conference of size 1. (If there really + // weren't any participants in there at all, the conference + // wouldn't have existed in the first place). + if (confSize == 0) { + confSize = 1 + } + numParticipants += confSize + endpointPairs += confSize * confSize + largestConferenceSize = largestConferenceSize.coerceAtLeast(confSize) + conferenceSizes.addValue(confSize.toLong()) + conference.jibriRecorder?.let { jibriSessions.addAll(it.jibriSessions) } + conference.jibriSipGateway?.let { jibriSessions.addAll(it.jibriSessions) } + } + + ConferenceMetrics.largestConference.set(largestConferenceSize.toLong()) + ConferenceMetrics.currentParticipants.set(numParticipants.toLong()) + ConferenceMetrics.conferenceSizes = conferenceSizes + ConferenceMetrics.participantPairs.set(endpointPairs.toLong()) + ConferenceMetrics.conferencesWithVisitors.set(conferencesWithVisitors.toLong()) + ConferenceMetrics.currentVisitors.set(visitors.toLong()) + + JibriStats.liveStreamingActive.set( + jibriSessions.count { it.jibriType == JibriSession.Type.LIVE_STREAMING && it.isActive }.toLong() + ) + JibriStats.recordingActive.set( + jibriSessions.count { it.jibriType == JibriSession.Type.RECORDING && it.isActive }.toLong() + ) + JibriStats.sipActive.set( + jibriSessions.count { it.jibriType == JibriSession.Type.SIP_CALL && it.isActive }.toLong() + ) + } + + // We want to avoid exposing unnecessary hierarchy levels in the stats, + // so we'll merge stats from different "child" objects here. + val stats: ObjectNode + get() { + // We want to avoid exposing unnecessary hierarchy levels in the stats, + // so we'll merge stats from different "child" objects here. + val stats = JsonNodeFactory.instance.objectNode() + stats.put("total_participants", ConferenceMetrics.participants.get()) + stats.put("total_conferences_created", ConferenceMetrics.conferencesCreated.get()) + stats.put("conferences", ConferenceMetrics.conferenceCount.get()) + stats.put("conferences_with_visitors", ConferenceMetrics.conferencesWithVisitors.get()) + val bridgeFailures = JsonNodeFactory.instance.objectNode() + bridgeFailures.put("participants_moved", ConferenceMetrics.participantsMoved.get()) + bridgeFailures.put("bridges_removed", ConferenceMetrics.bridgesRemoved.get()) + stats.set("bridge_failures", bridgeFailures) + val participantNotifications = JsonNodeFactory.instance.objectNode() + participantNotifications.put("ice_failed", ConferenceMetrics.participantsIceFailed.get()) + participantNotifications.put("request_restart", ConferenceMetrics.participantsRequestedRestart.get()) + stats.set("participant_notifications", participantNotifications) + stats.put("largest_conference", ConferenceMetrics.largestConference.get()) + stats.put("participants", ConferenceMetrics.currentParticipants.get()) + stats.put("visitors", ConferenceMetrics.currentVisitors.get()) + stats.set("conference_sizes", ConferenceMetrics.conferenceSizes.toJson()) + stats.put("endpoint_pairs", ConferenceMetrics.participantPairs.get()) + stats.set( + "jibri", + JsonNodeFactory.instance.objectNode().apply { + put("total_sip_call_failures", JibriStats.sipFailures.get()) + put("total_live_streaming_failures", JibriStats.liveStreamingFailures.get()) + put("total_recording_failures", JibriStats.recordingFailures.get()) + put("live_streaming_active", JibriStats.liveStreamingActive.get()) + put("recording_active", JibriStats.recordingActive.get()) + put("sip_call_active", JibriStats.sipActive.get()) + } + ) + stats.set("queues", getStatistics()) + return stats + } + + fun getDebugState(full: Boolean): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + for (conference in getConferences()) { + if (full) { + set(conference.roomName.toString(), conference.debugState) + } else { + put(conference.roomName.toString(), conference.participantCount) + } + } + } + + /** Create or update the pinning for the specified conference. */ + override fun pinConference(roomName: EntityBareJid, jvbVersion: String, duration: Duration) { + val pc = PinnedConferenceState(jvbVersion, duration) + synchronized(conferencesSyncRoot) { + val prev = pinnedConferences.remove(roomName) + if (prev != null) { + logger.info("Modifying pin for $roomName") + } + pinnedConferences.put(roomName, pc) + } + logger.info("Pinning $roomName to version \"$jvbVersion\" for ${duration.toMinutes()} minute(s).") + } + + /** + * Remove any existing pinning for the specified conference. + */ + override fun unpinConference(roomName: EntityBareJid) = synchronized(conferencesSyncRoot) { + val prev = pinnedConferences.remove(roomName) + logger.info(if (prev != null) "Removing pin for $roomName" else "Unpin failed: $roomName") + } + + private fun expirePins(curTime: Instant) { + if (pinnedConferences.values.removeIf { it.expiresAt.isBefore(curTime) }) { + logger.info("Some pins have expired.") + } + } + + /** + * Return the requested bridge version of a pinned conference. + * Returns null if the conference is not currently pinned. + */ + fun getBridgeVersionForConference(roomName: EntityBareJid): String? { + synchronized(conferencesSyncRoot) { + expirePins(clock.instant()) + return pinnedConferences[roomName]?.jvbVersion + } + } + + /** Get the set of current pinned conferences. */ + override fun getPinnedConferences(): List = buildList { + synchronized(conferencesSyncRoot) { + expirePins(clock.instant()) + pinnedConferences.forEach { (conferenceId, p) -> + add(PinnedConference(conferenceId.toString(), p.jvbVersion, p.expiresAt.toString())) + } + } + } + + /** Holds pinning information for one conference. */ + private inner class PinnedConferenceState( + /** The version of the bridge that this conference must use. */ + val jvbVersion: String, + duration: Duration + ) { + /** When this pinning expires. */ + val expiresAt: Instant = clock.instant().plus(duration).truncatedTo(ChronoUnit.SECONDS) + } + + override fun registrationChanged(registered: Boolean) { + conferencesCache.forEach { it.registrationChanged(registered) } + } + + /** + * Notify the conferences that the XMPP stream to the visitor node [node] was re-established without being + * resumed. The MUCs that jicofo joined on that node are not joined anymore. + */ + fun visitorConnectionReset(node: String) = conferencesCache.forEach { + try { + it.visitorConnectionReset(node) + } catch (e: Exception) { + logger.error("Failed to reset visitor node $node for conference ${it.roomName}", e) + } + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/JicofoServices.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/JicofoServices.kt new file mode 100644 index 0000000..1237aae --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/JicofoServices.kt @@ -0,0 +1,267 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import org.jitsi.jicofo.auth.AbstractAuthAuthority +import org.jitsi.jicofo.auth.AuthConfig +import org.jitsi.jicofo.auth.ExternalJWTAuthority +import org.jitsi.jicofo.auth.XMPPDomainAuthAuthority +import org.jitsi.jicofo.bridge.BridgeConfig +import org.jitsi.jicofo.bridge.BridgeMucDetector +import org.jitsi.jicofo.bridge.BridgeSelector +import org.jitsi.jicofo.bridge.JvbDoctor +import org.jitsi.jicofo.bridgeload.LoadRedistributor +import org.jitsi.jicofo.health.HealthConfig +import org.jitsi.jicofo.health.JicofoHealthChecker +import org.jitsi.jicofo.jibri.JibriConfig +import org.jitsi.jicofo.jibri.JibriDetector +import org.jitsi.jicofo.jibri.JibriDetectorMetrics +import org.jitsi.jicofo.ktor.RestConfig +import org.jitsi.jicofo.metrics.GlobalMetrics +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.jicofo.version.CurrentVersionImpl +import org.jitsi.jicofo.xmpp.XmppServices +import org.jitsi.jicofo.xmpp.initializeSmack +import org.jitsi.jicofo.xmpp.jingle.JingleStats +import org.jitsi.utils.logging2.createLogger +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import org.jitsi.jicofo.auth.AuthConfig.Companion.config as authConfig + +private val jsonMapper = jacksonObjectMapper() + +/** + * Start/stop jicofo-specific services. + */ +@SuppressFBWarnings("MS_CANNOT_BE_FINAL") +class JicofoServices { + private val logger = createLogger() + + init { + // Init smack shit + initializeSmack() + } + + val focusManager: FocusManager = FocusManager(this).apply { start() } + val authenticationAuthority: AbstractAuthAuthority? = createAuthenticationAuthority()?.apply { + start() + focusManager.addListener(this) + } + + val xmppServices: XmppServices = XmppServices( + conferenceStore = focusManager, + // TODO do not use FocusManager directly + focusManager = focusManager, + authenticationAuthority = authenticationAuthority, + hasJibriDetector = { jibriDetector != null || sipJibriDetector != null } + ).also { + it.clientConnection.addListener(focusManager) + } + + val bridgeSelector = BridgeSelector() + private val jvbDoctor = if (BridgeConfig.config.healthChecksEnabled) { + JvbDoctor(bridgeSelector, xmppServices.serviceConnection).apply { + bridgeSelector.addHandler(this) + } + } else { + logger.warn("JVB health-checks disabled") + null + } + + private val loadRedistributor = LoadRedistributor(focusManager, bridgeSelector) + + private val bridgeDetector: BridgeMucDetector? = BridgeConfig.config.breweryJid?.let { breweryJid -> + BridgeMucDetector( + xmppServices.getXmppConnectionByName(BridgeConfig.config.xmppConnectionName), + bridgeSelector, + breweryJid + ).apply { init() } + } ?: run { + logger.error("No bridge detector configured.") + null + } + val jibriDetector: JibriDetector? = JibriConfig.config.breweryJid?.let { breweryJid -> + JibriDetector( + xmppServices.getXmppConnectionByName(JibriConfig.config.xmppConnectionName), + breweryJid, + false + ).apply { + init() + } + } ?: run { + logger.info("No Jibri detector configured.") + null + } + val sipJibriDetector: JibriDetector? = JibriConfig.config.sipBreweryJid?.let { breweryJid -> + JibriDetector(xmppServices.clientConnection, breweryJid, true).apply { init() } + } ?: run { + logger.info("No SIP Jibri detector configured.") + null + } + + init { + if (jibriDetector != null || sipJibriDetector != null) { + JicofoMetricsContainer.instance.metricsUpdater.addUpdateTask { + JibriDetectorMetrics.updateMetrics(jibriDetector = jibriDetector, sipJibriDetector = sipJibriDetector) + } + } + } + + private val healthChecker: JicofoHealthChecker? = if (HealthConfig.config.enabled) { + JicofoHealthChecker( + HealthConfig.config, + focusManager, + bridgeSelector, + setOf(xmppServices.clientConnection) + ).apply { + start() + } + } else { + null + } + + private val ktor = if (RestConfig.config.enabled) { + org.jitsi.jicofo.ktor.Application( + healthChecker, + xmppServices.conferenceIqHandler, + focusManager, + loadRedistributor, + { getStats() } + ) { full, confId -> + if (confId == null) { + getDebugState(full) + } else { + getConferenceDebugState(confId) + } + } + } else { + logger.info("Rest interface disabled.") + null + } + + init { + logger.info("Registering GlobalMetrics periodic updates.") + JicofoMetricsContainer.instance.metricsUpdater.addUpdateTask { GlobalMetrics.update() } + } + + fun shutdown() { + authenticationAuthority?.let { + focusManager.removeListener(it) + it.shutdown() + } + healthChecker?.shutdown() + JicofoMetricsContainer.instance.metricsUpdater.stop() + ktor?.stop() + jvbDoctor?.let { + bridgeSelector.removeHandler(it) + it.shutdown() + } + loadRedistributor.shutdown() + bridgeDetector?.shutdown() + jibriDetector?.shutdown() + sipJibriDetector?.shutdown() + xmppServices.clientConnection.removeListener(focusManager) + xmppServices.shutdown() + } + + private fun createAuthenticationAuthority(): AbstractAuthAuthority? = + if (AuthConfig.config.type != AuthConfig.Type.NONE) { + logger.info("Starting authentication service with config=$authConfig.") + val authAuthority = when (authConfig.type) { + AuthConfig.Type.XMPP -> XMPPDomainAuthAuthority( + authConfig.enableAutoLogin, + authConfig.authenticationLifetime, + JidCreate.domainBareFrom(authConfig.loginUrl) + ) + + AuthConfig.Type.JWT -> ExternalJWTAuthority( + JidCreate.domainBareFrom(authConfig.loginUrl) + ) + + AuthConfig.Type.NONE -> null + } + authAuthority + } else { + logger.info("Authentication service disabled.") + null + } + + /** Gets statistics for the /stats HTTP interface. */ + private fun getStats(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + // Update the metrics that are usually updated periodically so we read the current values. + JicofoMetricsContainer.instance.metricsUpdater.updateMetrics() + // We want to avoid exposing unnecessary hierarchy levels in the stats, + // so we merge the FocusManager and ColibriConference stats in the root object. + setAll(focusManager.stats) + + set("bridge_selector", bridgeSelector.stats) + JibriDetectorMetrics.appendStats(this) + xmppServices.jigasiDetector?.let { set("jigasi_detector", it.stats) } + set("jigasi", xmppServices.jigasiStats) + put("threads", GlobalMetrics.threadCount.get()) + set("jingle", JingleStats.toJson()) + put("version", CurrentVersionImpl.VERSION.toString()) + healthChecker?.let { + val result = it.result + put("slow_health_check", it.totalSlowHealthChecks) + put("healthy", result.success) + set( + "health", + jsonMapper.valueToTree( + mapOf( + "success" to result.success, + "hardFailure" to result.hardFailure, + "responseCode" to result.responseCode, + "sticky" to result.sticky, + "message" to result.message + ) + ) + ) + } + } + + private fun getDebugState(full: Boolean): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + set("focus_manager", focusManager.getDebugState(full)) + set("bridge_selector", bridgeSelector.debugState) + jibriDetector?.let { set("jibri_detector", it.debugState) } ?: putNull("jibri_detector") + sipJibriDetector?.let { set("sip_jibri_detector", it.debugState) } ?: putNull("sip_jibri_detector") + xmppServices.jigasiDetector?.let { set("jigasi_detector", it.debugState) } + ?: putNull("jigasi_detector") + set("av_moderation", xmppServices.avModerationHandler.debugState) + set("conference_iq_handler", xmppServices.conferenceIqHandler.debugState) + } + + private fun getConferenceDebugState(conferenceId: EntityBareJid): JsonNode { + val conference = focusManager.getConference(JidCreate.entityBareFrom(conferenceId)) + return conference?.debugState ?: JsonNodeFactory.instance.objectNode() + } + + companion object { + @JvmField + val versionMetric = JicofoMetricsContainer.instance.registerInfo( + "version", + "Application version", + CurrentVersionImpl.VERSION.toString() + ) + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/Offer.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/Offer.kt new file mode 100644 index 0000000..20eb823 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/Offer.kt @@ -0,0 +1,27 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import org.jitsi.jicofo.conference.source.ConferenceSourceMap +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension + +/** + * Represent a Jingle offer consisting of a set of "content" extensions (which internally contain RTP payload + * information, transport information, etc) and a set of "sources". + */ +data class Offer(val sources: ConferenceSourceMap, val contents: List) diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/ReinviteMethod.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/ReinviteMethod.kt new file mode 100644 index 0000000..28575fd --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/ReinviteMethod.kt @@ -0,0 +1,20 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +enum class ReinviteMethod { RestartJingle, ReplaceTransport } diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/auth/AuthConfig.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/auth/AuthConfig.kt new file mode 100644 index 0000000..5ddca3f --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/auth/AuthConfig.kt @@ -0,0 +1,92 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jicofo.auth + +import org.jitsi.config.JitsiConfig.Companion.legacyConfig +import org.jitsi.config.JitsiConfig.Companion.newConfig +import org.jitsi.metaconfig.config +import java.time.Duration + +class AuthConfig private constructor() { + private val enabled: Boolean by config { + // Enabled if the URL is set to anything + LEGACY_LOGIN_URL_PROPERTY_NAME.from(legacyConfig).convertFrom { true } + // Read the legacy key from newConfig in case it was set as a System property. + LEGACY_LOGIN_URL_PROPERTY_NAME.from(newConfig).convertFrom { true } + "jicofo.authentication.enabled".from(newConfig) + } + + val loginUrl: String by config { + LEGACY_LOGIN_URL_PROPERTY_NAME.from(legacyConfig).convertFrom { it.stripType() } + // Read the legacy key from newConfig in case it was set as a System property. + LEGACY_LOGIN_URL_PROPERTY_NAME.from(newConfig).convertFrom { it.stripType() } + "jicofo.authentication.login-url".from(newConfig) + } + + val authenticationLifetime: Duration by config { + "org.jitsi.jicofo.auth.AUTH_LIFETIME".from(legacyConfig).convertFrom { + Duration.ofMillis(it) + } + "jicofo.authentication.authentication-lifetime".from(newConfig) + } + + val enableAutoLogin: Boolean by config { + "org.jitsi.jicofo.auth.DISABLE_AUTOLOGIN".from(legacyConfig).transformedBy { !it } + "jicofo.authentication.enable-auto-login".from(newConfig) + } + + val type: Type + get() = if (enabled) typeProperty else Type.NONE + + private val typeProperty: Type by config { + "org.jitsi.jicofo.auth.URL".from(legacyConfig).convertFrom { + when { + it.startsWith("XMPP:") -> Type.XMPP + it.startsWith("EXT_JWT:") -> Type.JWT + else -> Type.NONE + } + } + "jicofo.authentication.type".from(newConfig) + } + + override fun toString(): String = "AuthConfig[enabled=$enabled, type=$type, loginUrl=$loginUrl, " + + "authenticationLifetime=$authenticationLifetime, enableAutoLogin=$enableAutoLogin]" + + companion object { + @JvmField + val config = AuthConfig() + + const val LEGACY_LOGIN_URL_PROPERTY_NAME = "org.jitsi.jicofo.auth.URL" + } + + enum class Type { + XMPP, + JWT, + NONE + } +} + +/** + * Strip the type encoded in the login URL in legacy properties. + */ +private fun String.stripType(): String = when { + this.startsWith("XMPP:") -> this.substring("XMPP:".length) + this.startsWith("EXT_JWT:") -> this.substring("EXT_JWT:".length) + else -> this +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/bridgeload/LoadRedistributor.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/bridgeload/LoadRedistributor.kt new file mode 100644 index 0000000..a15eba5 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/bridgeload/LoadRedistributor.kt @@ -0,0 +1,273 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridgeload + +import org.jitsi.jicofo.ConferenceStore +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.bridge.Bridge +import org.jitsi.jicofo.bridge.BridgeSelector +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.utils.logging2.createLogger +import org.jxmpp.jid.impl.JidCreate +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlin.jvm.Throws +import kotlin.math.min +import kotlin.math.roundToInt +import org.jitsi.jicofo.bridge.BridgeConfig.Companion.config as config + +/** + * This class serves two purposes: + * 1. Provide an API that can be used externally (exposed via HTTP) to move endpoints away from a bridge: + * - [moveEndpoint] + * - [moveEndpoints] + * - [moveFraction] + * 2. Optionally, automatically redistribute load from overloaded bridges to non-overloaded ones. This is controlled by + * the [config.loadRedistribution] configuration. + */ +class LoadRedistributor(private val conferenceStore: ConferenceStore, private val bridgeSelector: BridgeSelector) { + val logger = createLogger() + + private var task = if (config.loadRedistribution.enabled) { + logger.info("Enabling automatic load redistribution: ${config.loadRedistribution}") + TaskPools.scheduledPool.scheduleAtFixedRate( + { run() }, + config.loadRedistribution.interval.toMillis(), + config.loadRedistribution.interval.toMillis(), + TimeUnit.MILLISECONDS + ) + } else { + null + } + + fun shutdown() = task?.let { + logger.info("Stopping load redistribution") + it.cancel(true) + bridgesInTimeout.clear() + } + + private val bridgesInTimeout: MutableMap = mutableMapOf() + + private fun run() { + try { + logger.trace("Running load redistribution") + if (!bridgeSelector.hasNonOverloadedBridge()) { + logger.warn("No non-overloaded bridges, skipping load redistribution") + return + } + + cleanupTimeouts() + bridgeSelector.getAll().filter { !bridgesInTimeout.containsKey(it) }.forEach(::runSingleBridge) + } catch (e: Exception) { + logger.error("Error running load redistribution", e) + } + } + + private fun runSingleBridge(bridge: Bridge) { + if (bridge.correctedStress >= config.loadRedistribution.stressThreshold) { + bridgesInTimeout[bridge] = Instant.now() + val result = moveEndpoints(bridge.jid.toString(), null, config.loadRedistribution.endpoints) + logger.info("Moved $result away from ${bridge.jid.resourceOrEmpty}") + bridge.endpointsMoved(result.movedEndpoints.toLong()) + totalEndpointsMoved.add(result.movedEndpoints.toLong()) + } + } + + private fun cleanupTimeouts() { + val limit = Instant.now() - config.loadRedistribution.timeout + bridgesInTimeout.entries.removeIf { it.value.isBefore(limit) } + } + + /** + * Move a specific endpoint in a specific conference. + */ + @Throws(MoveFailedException::class) + fun moveEndpoint( + /** Conference JID, e.g room@conference.example.com. */ + conferenceId: String?, + /** Endpoint ID, e.g. abcdefgh. */ + endpointId: String?, + /** + * Optional bridge JID. If specified, the endpoint will only be moved it if is indeed connected to this bridge. + */ + bridgeId: String? + ): MoveResult { + if (conferenceId.isNullOrBlank()) throw MissingParameterException("conference") + if (endpointId.isNullOrBlank()) throw MissingParameterException("endpoint") + val bridge = if (bridgeId.isNullOrBlank()) null else getBridge(bridgeId) + val conference = getConference(conferenceId) + + logger.info("Moving conference=$conferenceId endpoint=$endpointId bridge=$bridgeId") + return if (conference.moveEndpoint(endpointId, bridge)) { + logger.info("Moved successfully") + MoveResult(1, 1) + } else { + // Should we throw? + logger.info("Failed to move") + MoveResult(0, 0) + } + } + + /** + * Moves a specific number E of endpoints from a specific bridge B. If a conference is specified, only endpoints in + * that conference are moved. Otherwise, all conferences are ordered by the number of endpoints on B, and endpoints + * from large conferences are removed until E is reached. + * + * If a conference is specified, the endpoints are selected randomly from it. Otherwise, the endpoints are selected + * by ordering the list of conferences that use the bridge by the number of endpoints on this bridge. Then we select + * greedily from the list until we've selected the desired count. Note that this may need to be adjusted if it leads + * to thundering horde issues (though the recentlyAddedEndpointCount correction should prevent them). + */ + @Throws(MoveFailedException::class) + fun moveEndpoints( + /** Bridge JID, e.g. jvbbrewery@muc.jvb.example.com/jvb1. */ + bridgeId: String?, + /** + * Optional conference JID, e.g room@conference.example.com. If specified only endpoints from this conference + * will be moved. + */ + conferenceId: String?, + /** Number of endpoints to move. */ + numEndpoints: Int + ): MoveResult { + if (bridgeId.isNullOrBlank()) throw MissingParameterException("bridge") + val bridge = getBridge(bridgeId) + val conference = if (conferenceId.isNullOrBlank()) null else getConference(conferenceId) + val bridgeConferences = if (conference == null) { + bridge.getConferences() + } else { + bridge.getConferences().filter { it.first == conference } + } + logger.info("Moving $numEndpoints endpoints from bridge=${bridge.jid} (conference=$conference)") + val endpointsToMove = bridgeConferences.select(numEndpoints) + return doMove(bridge, endpointsToMove) + } + + /** + * Move a specific fraction of the endpoints from a specific bridge. + * + * The endpoints to move are selected by ordering the list of conferences that use the bridge by the number of + * endpoints on this bridge. Then we select greedily from the list until we've selected the desired count. Note + * that this may need to be adjusted if it leads to thundering horde issues (though the recentlyAddedEndpointCount + * correction should prevent them). + */ + @Throws(MoveFailedException::class) + fun moveFraction( + /** Bridge JID, e.g. jvbbrewery@muc.jvb.example.com/jvb1. */ + bridgeId: String?, + /** The fraction of endpoints to move. */ + fraction: Double + ): MoveResult { + if (bridgeId.isNullOrBlank()) throw MissingParameterException("bridge") + val bridge = getBridge(bridgeId) + val bridgeConferences = bridge.getConferences() + val totalEndpoints = bridgeConferences.sumOf { it.second } + val numEndpoints = (fraction * totalEndpoints).roundToInt() + logger.info("Moving $fraction of endpoints from bridge=$bridge ($numEndpoints out of $totalEndpoints)") + val endpointsToMove = bridgeConferences.select(numEndpoints) + return doMove(bridge, endpointsToMove) + } + + private fun getConference(conferenceId: String): JitsiMeetConference { + val conferenceJid = try { + JidCreate.entityBareFrom(conferenceId) + } catch (e: Exception) { + throw InvalidParameterException("conference ID") + } + return conferenceStore.getConference(conferenceJid) ?: throw ConferenceNotFoundException(conferenceId) + } + + private fun getBridge(bridge: String): Bridge { + val bridgeJid = try { + JidCreate.from(bridge) + } catch (e: Exception) { + throw InvalidParameterException("bridge ID") + } + + bridgeSelector.get(bridgeJid)?.let { return it } + + val bridgeFullJid = try { + JidCreate.from("${config.breweryJid}/$bridge") + } catch (e: Exception) { + throw InvalidParameterException("bridge ID") + } + return bridgeSelector.get(bridgeFullJid) ?: throw BridgeNotFoundException(bridge) + } + + private fun Bridge.getConferences() = conferenceStore.getAllConferences().mapNotNull { conference -> + conference.bridges[this]?.participantCount?.let { Pair(conference, it) } + }.sortedByDescending { it.second } + + private fun doMove(bridge: Bridge, endpointsToMove: Map): MoveResult { + logger.info("Moving endpoints from bridge ${bridge.jid}: $endpointsToMove") + var movedEndpoints = 0 + var conferences = 0 + endpointsToMove.forEach { (conference, numEps) -> + val moved = conference.moveEndpoints(bridge, numEps) + movedEndpoints += moved + if (moved > 0) conferences++ + } + logger.info("Moved $movedEndpoints endpoints from $conferences conferences.") + return MoveResult(movedEndpoints, conferences) + } + + companion object { + val totalEndpointsMoved = JicofoMetricsContainer.instance.registerCounter( + "load_redistributor_endpoints_moved", + "Total number of endpoints moved away from any bridge for automatic load redistribution" + ) + } +} + +data class MoveResult( + val movedEndpoints: Int, + val conferences: Int +) { + override fun toString() = "$movedEndpoints endpoints from $conferences conferences" +} + +/** + * Select endpoints to move, e.g. with a map m={a: 1, b: 3, c: 3}: + * select(m, 1) should return {a: 1} + * select(m, 2) should return {a: 1, b: 1} + * select(m, 3) should return {a: 1, b: 2} + * select(m, 6) should return {a: 1, b: 3, c: 2} + * select(m, 100) should return {a: 1, b: 3, c: 3} + * + * That is, it selects greedily in the order of the list. + */ +private fun List>.select(n: Int): Map { + var moved = 0 + return buildMap { + this@select.forEach { + if (moved >= n) { + return@forEach + } + val m = min(it.second, n - moved) + moved += m + put(it.first, m) + } + } +} + +sealed class MoveFailedException(msg: String) : Exception(msg) +class MissingParameterException(name: String) : MoveFailedException("Missing parameter: $name") +class InvalidParameterException(name: String) : MoveFailedException("Invalid parameter: $name") +class BridgeNotFoundException(bridge: String) : MoveFailedException("Bridge not found: $bridge") +class ConferenceNotFoundException(conference: String) : MoveFailedException("Conference not found: $conference") diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/ClientRequirementsHandler.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/ClientRequirementsHandler.kt new file mode 100644 index 0000000..15d6e76 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/ClientRequirementsHandler.kt @@ -0,0 +1,226 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.ClientRequirement +import org.jitsi.jicofo.ClientRequirementsConfig +import org.jitsi.jicofo.xmpp.Features +import org.jitsi.jicofo.xmpp.muc.ChatRoomMember +import org.jitsi.jicofo.xmpp.tryToSendStanza +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import org.jitsi.xmpp.extensions.clientrequirements.ClientRequirementsIq +import org.jitsi.xmpp.extensions.clientrequirements.MissingFeatureExtension +import org.jitsi.xmpp.extensions.clientrequirements.RequirementLevel +import org.jitsi.xmpp.extensions.clientrequirements.RequirementsAction +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.Message +import org.jivesoftware.smack.packet.MessageBuilder +import org.jivesoftware.smack.packet.Stanza +import org.jxmpp.jid.EntityFullJid +import java.util.UUID + +/** + * Checks that the endpoints in a conference advertise the capabilities that the deployment requires (see + * [ClientRequirementsConfig]), notifies the ones that do not, and keeps the state necessary to do this only once per + * endpoint. + * + * An endpoint which misses a requirement with a level of [RequirementLevel.HARD] is not invited to the conference. It + * stays in the MUC, so it can still use the features which do not require a media session (e.g. chat). + */ +class ClientRequirementsHandler @JvmOverloads constructor( + parentLogger: Logger, + private val config: ClientRequirementsConfig = ClientRequirementsConfig.config, + /** Sends a stanza to an endpoint. Overridable for testing. */ + private val sender: (ChatRoomMember, Stanza) -> Unit = { member, stanza -> + member.chatRoom.xmppProvider.xmppConnection.tryToSendStanza(stanza) + } +) { + private val logger = createChildLogger(parentLogger) + + /** The endpoints that have already been checked and found to miss a requirement, keyed by occupant JID. */ + private val checked = mutableMapOf() + + /** + * Check whether [member] advertises the required capabilities. Notify it, log and update metrics if it does not. + * + * Only the first call for a given member has any effect. Subsequent calls (e.g. when something triggers a + * re-invite) return the previous result without notifying the endpoint again. + * + * @return true if the member should be invited to the conference. + */ + fun checkAndNotify(member: ChatRoomMember): Boolean { + if (!config.enabled) { + return true + } + + synchronized(checked) { + checked[member.occupantJid]?.let { + logger.info("Already checked ${member.name}: $it") + return !it.reject + } + } + + // Jibri, jigasi and the transcriber advertise a different set of features, and the requirements do not apply. + if (member.isJibri || member.isJigasi || member.isTranscriber) { + return true + } + + val requirements = config.requirements + if (requirements.isEmpty()) { + return true + } + + val features = member.features + if (!member.featuresDiscovered) { + // The features were not discovered, so we can not conclude that any of them is missing. + logger.info("Not checking requirements for ${member.name}, features were not discovered.") + ConferenceMetrics.participantsFeaturesNotDiscovered.inc() + return true + } + + val missing = requirements.filter { !features.contains(it.feature) } + if (missing.isEmpty()) { + return true + } + + val hasHard = missing.any { it.level == RequirementLevel.HARD } + val verdict = Verdict(missing, reject = hasHard && config.enforce) + synchronized(checked) { + checked[member.occupantJid] = verdict + } + + missing.forEach { + ConferenceMetrics.participantsMissingFeatures.inc(listOf(it.feature.name, it.level.value)) + } + if (verdict.reject) { + ConferenceMetrics.participantsRejectedMissingFeatures.inc() + } + + logger.warn( + "Endpoint ${member.name} is missing required capabilities: " + + "missing=${missing.map { "${it.feature.name}/${it.level.value}" }}, reject=${verdict.reject}" + + (if (hasHard && !config.enforce) " (enforce is disabled)" else "") + + ", statsId=${member.statsId}, region=${member.region}" + + ", features=${features.map { it.name }}" + ) + + notify(member, verdict) + return !verdict.reject + } + + /** + * Clean up the state associated with a member which left the conference. + * @return true if the member had been rejected (i.e. was never invited). + */ + fun memberLeft(member: ChatRoomMember): Boolean = synchronized(checked) { + checked.remove(member.occupantJid)?.reject == true + } + + /** The number of members which are in the MUC, but were not invited because they miss a requirement. */ + val rejectedCount: Int + get() = synchronized(checked) { checked.values.count { it.reject } } + + private fun notify(member: ChatRoomMember, verdict: Verdict) { + if (member.features.contains(Features.CLIENT_REQUIREMENTS_1)) { + sender(member, createIq(member.occupantJid, verdict)) + ConferenceMetrics.clientRequirementsNotifications.inc(listOf("iq")) + } else { + // The endpoint does not understand the IQ. Send it plain chat messages instead, which any client renders. + verdict.missing.forEach { + sender(member, createMessage(member.occupantJid, it)) + ConferenceMetrics.clientRequirementsNotifications.inc(listOf("message")) + } + } + } + + private fun createIq(to: EntityFullJid, verdict: Verdict): IQ = ClientRequirementsIq.Builder( + UUID.randomUUID().toString() + ).apply { + action = if (verdict.reject) RequirementsAction.REJECT else RequirementsAction.WARN + verdict.missing.forEach { requirement -> + addExtension( + MissingFeatureExtension( + feature = requirement.feature.value, + name = requirement.feature.name, + // In a dry run (enforce disabled) we do not reject the endpoint, so we do not tell it that we did. + level = if (verdict.reject) requirement.level else RequirementLevel.SOFT, + details = requirement.details, + url = requirement.url + ) + ) + } + to(to) + ofType(IQ.Type.set) + }.build() + + private fun createMessage(to: EntityFullJid, requirement: ClientRequirement) = MessageBuilder + .buildMessage() + .to(to) + .ofType(Message.Type.chat) + .setBody(getMessageBody(requirement)) + .build() + + private fun getMessageBody(requirement: ClientRequirement) = buildString { + if (requirement.level == RequirementLevel.HARD && config.enforce) { + append( + "This app version does not support a capability that is required in this meeting " + + "(${requirement.feature.name}), and can not send or receive audio or video. Please update." + ) + } else { + append( + "This app version does not support a capability that is expected in this meeting " + + "(${requirement.feature.name}). Please update." + ) + } + requirement.details?.let { append(" $it") } + requirement.url?.let { append(" $it") } + } + + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("enabled", config.enabled) + put("enforce", config.enforce) + set( + "requirements", + JsonNodeFactory.instance.arrayNode().apply { + config.requirements.forEach { add("${it.feature.name}/${it.level.value}") } + } + ) + set( + "checked", + JsonNodeFactory.instance.objectNode().apply { + synchronized(checked) { + checked.forEach { (jid, verdict) -> put(jid.resourceOrEmpty.toString(), verdict.toString()) } + } + } + ) + } + + /** The result of checking an endpoint against the configured requirements. */ + data class Verdict( + val missing: List, + /** Whether the endpoint is not to be invited to the conference. */ + val reject: Boolean + ) { + override fun toString() = "[missing=${missing.map { "${it.feature.name}/${it.level.value}" }}, " + + "reject=$reject]" + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/ConferenceMetrics.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/ConferenceMetrics.kt new file mode 100644 index 0000000..6658277 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/ConferenceMetrics.kt @@ -0,0 +1,149 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import org.jitsi.utils.stats.ConferenceSizeBuckets +import org.jitsi.jicofo.metrics.JicofoMetricsContainer.Companion.instance as metricsContainer + +@SuppressFBWarnings("MS_CANNOT_BE_FINAL") +class ConferenceMetrics { + companion object { + @JvmField + val conferencesCreated = metricsContainer.registerCounter( + "conferences_created", + "The number of conferences created on this Jicofo since it was started" + ) + + @JvmField + val conferenceSeconds = metricsContainer.registerDoubleGauge( + "conferences_seconds", + "The duration of completed conferences in seconds." + ) + + @JvmField + val endpointSeconds = metricsContainer.registerDoubleGauge( + "endpoint_seconds", + "The duration of endpoints in a conferences in seconds." + ) + + @JvmField + val participants = metricsContainer.registerCounter( + "participants", + "The total number of participants that have connected to this Jicofo since it was started." + ) + + @JvmField + val participantsNoStartMutedRmd = metricsContainer.registerCounter( + "participants_no_start_muted_rmd", + "The number of participants with no support for start muted via room-metadata." + ) + + @JvmField + val participantsMissingFeatures = metricsContainer.registerCounter( + "participants_missing_features", + "The number of participants that were missing a feature required by the deployment.", + labelNames = listOf("feature", "level") + ) + + @JvmField + val participantsRejectedMissingFeatures = metricsContainer.registerCounter( + "participants_rejected_missing_features", + "The number of participants that were not invited because they were missing a required feature." + ) + + @JvmField + val participantsFeaturesNotDiscovered = metricsContainer.registerCounter( + "participants_features_not_discovered", + "The number of participants whose features could not be discovered." + ) + + @JvmField + val clientRequirementsNotifications = metricsContainer.registerCounter( + "client_requirements_notifications", + "The number of notifications sent to endpoints about missing capabilities.", + labelNames = listOf("transport") + ) + + @JvmField + val participantsMoved = metricsContainer.registerCounter( + "participants_moved", + "Number of participants moved away from a failed bridge" + ) + + @JvmField + val bridgesRemoved = metricsContainer.registerCounter( + "bridges_removed", + "Number of times a bridge was removed from a conference due to a failure" + ) + + @JvmField + val participantsIceFailed = metricsContainer.registerCounter( + "participants_ice_failed", + "Number of participants that reported an ICE failure" + ) + + @JvmField + val participantsRequestedRestart = metricsContainer.registerCounter( + "participants_restart_requested", + "Number of times a participant requested a restart via session-terminate" + ) + + @JvmField + val largestConference = metricsContainer.registerLongGauge( + "largest_conference", + "The current largest conference." + ) + + @JvmField + val currentParticipants = metricsContainer.registerLongGauge( + "participants_current", + "The current number of participants. This includes visitors." + ) + + @JvmField + val currentVisitors = metricsContainer.registerLongGauge( + "visitors_current", + "The current number of visitors." + ) + + @JvmField + val conferenceCount = metricsContainer.registerLongGauge( + "conferences", + "Running count of conferences (excluding internal conferences created for health checks)." + ) + + @JvmField + val conferencesWithVisitors = metricsContainer.registerLongGauge( + "conferences_with_visitors", + "Running count of conferences which have at least 1 visitor." + ) + + /** + * TODO: convert to a [Metric] + */ + @JvmField + var conferenceSizes = ConferenceSizeBuckets() + + @JvmField + val participantPairs = metricsContainer.registerLongGauge( + "participants_pairs", + "The number of pairs of participants (the sum of n*(n-1) for each conference)" + ) + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/ConferenceUtil.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/ConferenceUtil.kt new file mode 100644 index 0000000..f9586f9 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/ConferenceUtil.kt @@ -0,0 +1,97 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import org.jitsi.jicofo.visitors.VisitorsConfig +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jitsi.jicofo.xmpp.muc.ChatRoom +import org.jitsi.utils.MediaType +import org.jitsi.xmpp.extensions.colibri2.Media +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension +import org.jitsi.xmpp.extensions.jingle.IceRtcpmuxPacketExtension +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension +import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import kotlin.IllegalStateException + +internal fun ContentPacketExtension.toMedia(): Media? { + val media = when (name.lowercase()) { + "audio" -> Media.getBuilder().setType(MediaType.AUDIO) + "video" -> Media.getBuilder().setType(MediaType.VIDEO) + else -> return null + } + + getFirstChildOfType(RtpDescriptionPacketExtension::class.java)?.let { + it.payloadTypes.forEach { payloadTypePacketExtension -> + media.addPayloadType(payloadTypePacketExtension) + } + it.extmapList.forEach { rtpHdrExtPacketExtension -> + media.addRtpHdrExt(rtpHdrExtPacketExtension) + } + it.extmapAllowMixed?.let { allowMixed -> media.setExtmapAllowMixed(allowMixed) } + } + return media.build() +} + +internal fun List.getTransport(): IceUdpTransportPacketExtension? { + val transport = firstNotNullOfOrNull { it.getFirstChildOfType(IceUdpTransportPacketExtension::class.java) } + ?: return null + // Insert rtcp-mux if missing. Is this still needed? + if (!transport.isRtcpMux) { + transport.addChildExtension(IceRtcpmuxPacketExtension()) + } + return transport +} + +internal fun selectVisitorNode(existingNodes: Map, allNodes: List): String? { + val registeredNodeNames = allNodes.filter { it.registered }.map { it.config.name }.toSet() + + // Re-use a node that we already have a room on, if it has capacity. Skip a node whose XMPP connection is down, + // because we can not signal to the visitors that we send there. + val min = existingNodes.filterKeys { registeredNodeNames.contains(it) } + .minByOrNull { it.value.visitorCount } + if (min != null && min.value.visitorCount < VisitorsConfig.config.maxVisitorsPerNode) { + return min.key + } + + return allNodes + .filterNot { existingNodes.keys.contains(it.config.name) } + .filter { it.registered } + .randomOrNull()?.config?.name + ?: allNodes.randomOrNull()?.config?.name +} + +/** + * Get the JID of the visitor MUC for a given [mainRoom]. Handles "tenants", i.e. the jitsi-meet URL + * https://example.com/tenant/room would use a main room JID of room@conference.tenant.example.com and + * a visitor MUC ID room@conference.tenant.v1.meet.jitsi (where v1.meet.jitsi is the xmpp-domain configured + * for the [visitorXmppProvider]). + */ +internal fun getVisitorMucJid( + mainRoom: EntityBareJid, + mainXmppProvider: XmppProvider, + visitorXmppProvider: XmppProvider +): EntityBareJid { + val mainDomain = mainXmppProvider.config.xmppDomain?.toString() + ?: throw IllegalStateException("main domain not configured") + val visitorDomain = visitorXmppProvider.config.xmppDomain?.toString() + ?: throw IllegalStateException("visitor domain not configured for ${visitorXmppProvider.config.name}") + + return JidCreate.entityBareFrom(mainRoom.toString().replace(mainDomain, visitorDomain)) +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/JitsiMeetConfig.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/JitsiMeetConfig.kt new file mode 100644 index 0000000..7b2ccb6 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/JitsiMeetConfig.kt @@ -0,0 +1,64 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.utils.logging2.createLogger +import java.lang.Boolean.parseBoolean + +/** Configuration for a conference included in a conference request */ +class JitsiMeetConfig(properties: Map) { + private val logger = createLogger() + + val rtcStatsEnabled = properties.getBoolean(RTCSTATS_ENABLED, default = true) + val startAudioMuted: Int? = properties.getInt(START_AUDIO_MUTED) + val startVideoMuted: Int? = properties.getInt(START_VIDEO_MUTED) + + private fun Map.getInt(key: String): Int? = get(key).let { + try { + return if (it.isNullOrBlank()) null else Integer.parseInt(it) + } catch (e: NumberFormatException) { + logger.warn("Failed to parse the value of $key as an integer.") + return null + } + } + + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("rtcstatsEnabled", rtcStatsEnabled) + if (startAudioMuted != null) put("startAudioMuted", startAudioMuted) else putNull("startAudioMuted") + if (startVideoMuted != null) put("startVideoMuted", startVideoMuted) else putNull("startVideoMuted") + } + + // Needed for createLogger() to work. + companion object +} + +private fun Map.getBoolean(key: String, default: Boolean): Boolean = get(key).let { + return if (it.isNullOrBlank()) default else parseBoolean(it) +} + +/** The name of the start muted property for audio. */ +const val START_AUDIO_MUTED = "startAudioMuted" + +/** The name of the start muted property for video. */ +const val START_VIDEO_MUTED = "startVideoMuted" + +/** The name of the rtcstats enabled property. */ +const val RTCSTATS_ENABLED = "rtcstatsEnabled" diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/Participant.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/Participant.kt new file mode 100644 index 0000000..26e7ef4 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/Participant.kt @@ -0,0 +1,589 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.ConferenceConfig +import org.jitsi.jicofo.TaskPools.Companion.scheduledPool +import org.jitsi.jicofo.codec.Config +import org.jitsi.jicofo.conference.JitsiMeetConferenceImpl.InvalidBridgeSessionIdException +import org.jitsi.jicofo.conference.JitsiMeetConferenceImpl.SenderCountExceededException +import org.jitsi.jicofo.conference.source.ConferenceSourceMap +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.conference.source.EndpointSourceSet.Companion.fromJingle +import org.jitsi.jicofo.conference.source.ValidationFailedException +import org.jitsi.jicofo.metrics.IceRestartMetrics +import org.jitsi.jicofo.util.Cancelable +import org.jitsi.jicofo.xmpp.Features +import org.jitsi.jicofo.xmpp.jingle.JingleIqRequestHandler +import org.jitsi.jicofo.xmpp.jingle.JingleRequestHandler +import org.jitsi.jicofo.xmpp.jingle.JingleSession +import org.jitsi.jicofo.xmpp.muc.ChatRoomMember +import org.jitsi.jicofo.xmpp.muc.MemberRole +import org.jitsi.jicofo.xmpp.muc.hasModeratorRights +import org.jitsi.utils.RateLimit +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.LoggerImpl +import org.jitsi.xmpp.extensions.colibri2.InitialLastN +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension +import org.jitsi.xmpp.extensions.jingle.JingleAction +import org.jitsi.xmpp.extensions.jingle.JingleIQ +import org.jitsi.xmpp.extensions.jingle.Reason +import org.jitsi.xmpp.extensions.jitsimeet.BridgeSessionPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.IceStatePacketExtension +import org.jivesoftware.smack.packet.StanzaError +import org.jxmpp.jid.EntityFullJid +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +/** + * Class represent Jitsi Meet conference participant. Stores information about + * Colibri channels allocated, Jingle session and media sources. + * + * @author Pawel Domas + * @author Boris Grozev + */ +open class Participant @JvmOverloads constructor( + /** The associated chat member. */ + val chatMember: ChatRoomMember, + /** The conference in which this participant participates. */ + private val conference: JitsiMeetConferenceImpl, + /** The [JingleIqRequestHandler] with which to register the Jingle session. */ + private val jingleIqRequestHandler: JingleIqRequestHandler, + parentLogger: Logger? = null, + /** The list of XMPP features supported by this participant. */ + val supportedFeatures: Set = Features.defaultFeatures, + /** The [Clock] used by this participant. */ + private val clock: Clock = Clock.systemUTC() +) { + private val createdInstant: Instant = clock.instant() + + fun durationSeconds(): Double = Duration.between(createdInstant, clock.instant()).toMillis() / 1000.0 + + /** The endpoint ID for this participant in the videobridge (Colibri) context. */ + val endpointId: String = chatMember.name + + private val logger: Logger = ( + parentLogger?.createChildLogger(Participant::javaClass.name) + ?: LoggerImpl(Participant::javaClass.name) + ).apply { + addContext("participant", endpointId) + } + + fun terminateJingleSession(reason: Reason, message: String?, sendIq: Boolean) { + jingleSession?.terminate(reason, message, sendIq) + jingleSession = null + } + + /** + * The layer which keeps track of which sources have been signaled to this participant. + */ + private val sourceSignaling = SourceSignaling( + audio = hasAudioSupport(), + video = hasVideoSupport(), + ConferenceConfig.config.stripSimulcast() + ) + + /** + * Used to synchronize access to [.inviteRunnable]. + */ + private val inviteRunnableSyncRoot = Any() + + /** + * The cancelable thread, if any, which is currently allocating channels for this participant. + */ + private var inviteRunnable: Cancelable? = null + + private val restartRequestsRateLimit = RateLimit( + defaultMinInterval = ConferenceConfig.config.restartRequestMinInterval, + maxRequests = ConferenceConfig.config.restartRequestMaxRequests, + interval = ConferenceConfig.config.restartRequestInterval, + clock = clock + ) + + /** + * A separate budget for in-place ICE restart requests, so that a burst of them can not exhaust the budget for full + * session restarts (and vice versa). It uses the same configured limits. + */ + private val iceRestartRequestsRateLimit = RateLimit( + defaultMinInterval = ConferenceConfig.config.restartRequestMinInterval, + maxRequests = ConferenceConfig.config.restartRequestMaxRequests, + interval = ConferenceConfig.config.restartRequestInterval, + clock = clock + ) + + /** + * The Jingle session (if any) established with this peer. + */ + var jingleSession: JingleSession? = null + private set + + /** + * The task, if any, currently scheduled to signal queued remote sources. + */ + private var signalQueuedSourcesTask: ScheduledFuture<*>? = null + + /** + * The lock used when queueing remote sources to be signaled with a delay, i.e. when setting + * [.signalQueuedSourcesTask]. + */ + private val signalQueuedSourcesTaskSyncRoot = Any() + + /** + * Replaces the channel allocator thread, which is currently allocating channels for this participant (if any) + * with the specified channel allocator (if any). + * @param inviteRunnable the channel allocator to set, or `null` to clear it. + */ + fun setInviteRunnable(inviteRunnable: Cancelable?) { + synchronized(inviteRunnableSyncRoot) { + this.inviteRunnable?.let { + // There is an ongoing thread allocating channels and sending an invite for this participant. Tell it to + // stop. + logger.warn("Canceling $it") + it.cancel() + } + this.inviteRunnable = inviteRunnable + } + } + + /** + * Signals to this [Participant] that a specific channel allocator has completed its task and its thread + * is about to terminate. + * @param channelAllocator the channel allocator which has completed its task and its thread is about to terminate. + */ + fun inviteRunnableCompleted(channelAllocator: Cancelable) { + synchronized(inviteRunnableSyncRoot) { + if (inviteRunnable === channelAllocator) { + inviteRunnable = null + } + } + } + + /** Return `true` if this participant supports SSRC rewriting functionality. */ + fun hasSsrcRewritingSupport() = supportedFeatures.contains(Features.SSRC_REWRITING_V1) + + /** Return `true` if SSRC rewriting should be used for this participant. */ + fun useSsrcRewriting() = ConferenceConfig.config.useSsrcRewriting && hasSsrcRewritingSupport() + + /** Return `true` if this participant supports demuxing forwarded media by the RTP sdes:mid header extension. */ + fun hasRtpMidDemuxSupport() = supportedFeatures.contains(Features.RTP_MID_DEMUX) + + /** + * Return `true` if the bridge should stamp mids on media forwarded to this participant. Requires SSRC rewriting and + * that the sdes:mid header extension is being offered. + */ + fun useRtpMidDemux() = useSsrcRewriting() && + ConferenceConfig.config.useRtpMidDemux && + hasRtpMidDemuxSupport() && + Config.config.mid.enabled() + + /** Return `true` if this participant supports receiving Jingle sources encoded in JSON. */ + fun supportsJsonEncodedSources() = supportedFeatures.contains(Features.JSON_SOURCES) + + /** Returns `true` iff this participant supports REMB. */ + fun hasRembSupport() = supportedFeatures.contains(Features.REMB) + + /** Returns `true` iff this participant supports TCC. */ + fun hasTccSupport() = supportedFeatures.contains(Features.TCC) + + /** Returns `true` iff this participant supports RTX. */ + fun hasRtxSupport() = supportedFeatures.contains(Features.RTX) + + /** Returns `true` iff this participant supports RED for opus. */ + fun hasOpusRedSupport() = supportedFeatures.contains(Features.OPUS_RED) + + /** Returns true if RTP audio is supported by this peer. */ + fun hasAudioSupport() = supportedFeatures.contains(Features.AUDIO) + + /** Returns true if RTP video is supported by this peer. */ + fun hasVideoSupport() = supportedFeatures.contains(Features.VIDEO) + + /** Returns true if RTP audio can be muted for this peer. */ + fun hasAudioMuteSupport() = supportedFeatures.contains(Features.AUDIO_MUTE) + + /** Returns true if this peer supports DTLS/SCTP. */ + fun hasSctpSupport() = supportedFeatures.contains(Features.SCTP) + + /** Return true if a restart request should be accepted and false otherwise. */ + fun acceptRestartRequest(): Boolean = restartRequestsRateLimit.accept() + + /** Return true if an in-place ICE restart request should be accepted and false otherwise. */ + fun acceptIceRestartRequest(): Boolean = iceRestartRequestsRateLimit.accept() + + /** The stats ID of the participant. */ + val statId: String? + get() = chatMember.statsId + + /** The MUC JID of this Participant. */ + val mucJid: EntityFullJid = chatMember.occupantJid + + /** The sources advertised by this participant. They are stored in a common map by the conference. */ + val sources: EndpointSourceSet + get() = conference.getSourcesForParticipant(this) + + /** + * Add a set of remote sources, which are to be signaled to the remote side. The sources may be signaled + * immediately, or queued to be signaled later. + */ + fun addRemoteSources(sources: ConferenceSourceMap) { + if (useSsrcRewriting()) { + // Bridge will signal sources in this case. + return + } + synchronized(sourceSignaling) { sourceSignaling.addSources(sources) } + if (jingleSession?.isActive() == true) { + synchronized(signalQueuedSourcesTaskSyncRoot) { scheduleSignalingOfQueuedSources() } + } + // No need to schedule, the queued sources will be signaled when the session becomes active. + } + + /** + * Reset the set of sources that have been signaled to the participant. + * @param sources set of remote sources to be signaled to the participant (pre-filtering!) + * @return the set of sources that should be signaled in the initial offer (after filtering is applied!) + */ + fun resetSignaledSources(sources: ConferenceSourceMap): ConferenceSourceMap { + synchronized(sourceSignaling) { return sourceSignaling.reset(sources) } + } + + /** + * Schedule a task to signal all queued remote sources to the remote side. If a task is already scheduled, does + * not schedule a new one (the existing task will send all latest queued sources). + */ + private fun scheduleSignalingOfQueuedSources() { + val delayMs = ConferenceConfig.config.getSourceSignalingDelayMs(conference.participantCount) + synchronized(signalQueuedSourcesTaskSyncRoot) { + if (signalQueuedSourcesTask == null) { + logger.debug("Scheduling a task to signal queued remote sources after $delayMs ms.") + val timeOfSchedule = Instant.now() + signalQueuedSourcesTask = scheduledPool.schedule( + { + val actualDelayMs = Duration.between(timeOfSchedule, Instant.now()).toMillis() + if (actualDelayMs > delayMs + 3000) { + logger.warn("Scheduling of sources was delayed by $actualDelayMs ms (expected $delayMs)") + } + synchronized(signalQueuedSourcesTaskSyncRoot) { + sendQueuedRemoteSources() + signalQueuedSourcesTask = null + } + }, + delayMs.toLong(), + TimeUnit.MILLISECONDS + ) + if (signalQueuedSourcesTask?.isDone == true) { + // In case the executor ran immediately in the same thread (i.e. in tests). + signalQueuedSourcesTask = null + } + } + } + } + + /** + * Remove a set of remote sources, which are to be signaled as removed to the remote side. The sources may be + * signaled immediately, or queued to be signaled later. + */ + fun removeRemoteSources(sources: ConferenceSourceMap) { + if (useSsrcRewriting()) { + // Bridge will signal sources in this case. + return + } + synchronized(sourceSignaling) { sourceSignaling.removeSources(sources) } + if (jingleSession?.isActive() == true) { + synchronized(signalQueuedSourcesTaskSyncRoot) { scheduleSignalingOfQueuedSources() } + } + // No need to schedule, the queued sources will be signaled when the session becomes active. + } + + /** + * Signal any queued remote source modifications (either addition or removal) to the remote side. + */ + fun sendQueuedRemoteSources() { + val jingleSession = jingleSession + if (jingleSession?.isActive() != true) { + logger.warn("Can not signal remote sources, Jingle session not established.") + return + } + var modifiedSources: List + synchronized(sourceSignaling) { + modifiedSources = sourceSignaling.update() + } + for ((action, sources) in modifiedSources) { + logger.info("Sending a queued source-${action.toString().lowercase()}, sources=$sources") + if (action === AddOrRemove.Add) { + jingleSession.addSource(sources) + } else if (action === AddOrRemove.Remove) { + jingleSession.removeSource(sources) + } + } + } + + /** + * Whether force-muting should be suppressed for this participant (it is a trusted participant and doesn't + * support unmuting, or is a visitor and muting is redundant). + */ + fun shouldSuppressForceMute() = (chatMember.isJigasi && !hasAudioMuteSupport()) || chatMember.isJibri || + chatMember.role == MemberRole.VISITOR + + /** Checks whether this [Participant]'s role has moderator rights. */ + fun hasModeratorRights() = chatMember.role.hasModeratorRights() + override fun toString() = "Participant[$mucJid]" + + fun getDebugState(full: Boolean): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + put("id", endpointId) + if (full) { + set("source_signaling", sourceSignaling.debugState) + } + statId?.let { put("stats_id", it) } + put("invite_runnable", if (inviteRunnable != null) "Running" else "Not running") + jingleSession?.let { set("jingle_session", it.debugState()) } ?: putNull("jingle_session") + } + + /** + * Create a new [JingleSession] instance for this participant. Defined here and left open for easier testing. + */ + open fun createNewJingleSession(): JingleSession { + jingleSession?.let { + logger.info("Terminating existing jingle session") + it.terminate(Reason.UNDEFINED, null, false) + } + + return JingleSession( + JingleIQ.generateSID(), + mucJid, + jingleIqRequestHandler, + chatMember.chatRoom.xmppProvider.xmppConnection, + JingleRequestHandlerImpl(), + ConferenceConfig.config.useJsonEncodedSources && supportsJsonEncodedSources() + ).also { + jingleSession = it + } + } + + private inner class JingleRequestHandlerImpl : JingleRequestHandler { + private fun checkJingleSession(jingleSession: JingleSession): StanzaError? = + if (this@Participant.jingleSession != jingleSession) { + StanzaError.from(StanzaError.Condition.item_not_found, "jingle session no longer active").build() + } else { + null + } + + override fun onAddSource(jingleSession: JingleSession, contents: List): StanzaError? { + checkJingleSession(jingleSession)?.let { return it } + + if (chatMember.role === MemberRole.VISITOR) { + return StanzaError.from(StanzaError.Condition.forbidden, "add-source not allowed for visitors").build() + } + + val sourcesAdvertised = fromJingle(contents) + logger.debug { "Received source-add: $sourcesAdvertised" } + if (sourcesAdvertised.isEmpty()) { + logger.warn("Received source-add with empty sources, ignoring") + return null + } + + try { + conference.addSource(this@Participant, sourcesAdvertised) + } catch (e: SenderCountExceededException) { + logger.warn("Rejecting source-add: ${e.message}") + return StanzaError.from(StanzaError.Condition.resource_constraint, e.message).build() + } catch (e: ValidationFailedException) { + logger.warn("Rejecting source-add: ${e.message}") + return StanzaError.from(StanzaError.Condition.bad_request, e.message).build() + } + + return null + } + + override fun onRemoveSource( + jingleSession: JingleSession, + contents: List + ): StanzaError? { + checkJingleSession(jingleSession)?.let { return it } + + val sources = fromJingle(contents) + if (sources.isEmpty()) { + logger.info("Ignoring source-remove with no sources specified.") + return null + } + + try { + conference.removeSources(this@Participant, sources) + } catch (e: ValidationFailedException) { + return StanzaError.from(StanzaError.Condition.bad_request, e.message).build() + } + + return null + } + + override fun onSessionAccept(jingleSession: JingleSession, contents: List) = + onSessionOrTransportAccept(jingleSession, contents, JingleAction.SESSION_ACCEPT) + + private fun onSessionOrTransportAccept( + jingleSession: JingleSession, + contents: List, + action: JingleAction + ): StanzaError? { + if (this@Participant.jingleSession != null && this@Participant.jingleSession != jingleSession) { + logger.error("Rejecting $action for a session that has been replaced.") + return StanzaError.from(StanzaError.Condition.gone, "session has been replaced").build() + } + + logger.info("Received $action") + val sourcesAdvertised = fromJingle(contents) + if (!sourcesAdvertised.isEmpty() && this@Participant.chatMember.role == MemberRole.VISITOR) { + return StanzaError.from(StanzaError.Condition.forbidden, "sources not allowed for visitors").build() + } + val initialLastN: InitialLastN? = + contents.find { it.name == "video" }?.getChildExtension(InitialLastN::class.java) + + try { + conference.acceptSession(this@Participant, sourcesAdvertised, contents.getTransport(), initialLastN) + } catch (e: ValidationFailedException) { + return StanzaError.from(StanzaError.Condition.bad_request, e.message).build() + } + + return null + } + + override fun onSessionInfo(jingleSession: JingleSession, iq: JingleIQ): StanzaError? { + checkJingleSession(jingleSession)?.let { return it } + + val bridgeSession = iq.getExtension(BridgeSessionPacketExtension::class.java) + if (bridgeSession?.isIceRestart == true) { + return onIceRestartRequest(bridgeSession.id) + } + + val iceState = iq.getExtension(IceStatePacketExtension::class.java)?.text + if (!iceState.equals("failed", ignoreCase = true)) { + logger.info("Ignored unknown ice-state: $iceState") + return null + } + ConferenceMetrics.participantsIceFailed.inc() + + conference.iceFailed(this@Participant, bridgeSession?.id) + return null + } + + /** + * Handle a request for an in-place ICE restart, i.e. a `session-info` with + * ``. Unlike the full session restart requested via `session-terminate` + * this does not tear the session down: the bridge rotates its ICE credentials in place, and the resulting + * transport is signaled back to the participant in a `transport-info`. + * + * @return a [StanzaError] to respond with if the request was rejected, or null if it was accepted. + */ + private fun onIceRestartRequest(bridgeSessionId: String?): StanzaError? { + logger.info("ICE restart: received a request, bsId=$bridgeSessionId") + IceRestartMetrics.requested.inc() + + if (!ConferenceConfig.config.enableIceRestart) { + logger.info("ICE restart: rejecting the request, disabled in configuration.") + IceRestartMetrics.failed.inc() + return StanzaError.from( + StanzaError.Condition.feature_not_implemented, + "in-place ICE restart is disabled" + ).build() + } + + if (!acceptIceRestartRequest()) { + logger.warn("ICE restart: rejecting the request, rate-limited.") + IceRestartMetrics.failed.inc() + return StanzaError.from(StanzaError.Condition.resource_constraint, "rate-limited").build() + } + + try { + conference.iceRestart(this@Participant, bridgeSessionId) + } catch (e: InvalidBridgeSessionIdException) { + logger.warn("ICE restart: rejecting the request, ${e.message}") + IceRestartMetrics.failed.inc() + return StanzaError.from(StanzaError.Condition.item_not_found, e.message).build() + } + + return null + } + + override fun onSessionTerminate(jingleSession: JingleSession, iq: JingleIQ): StanzaError? { + checkJingleSession(jingleSession)?.let { return it } + + val bridgeSessionPacketExtension = iq.getExtension(BridgeSessionPacketExtension::class.java) + val restartRequested = bridgeSessionPacketExtension?.isRestart ?: false + val bridgeSessionId = bridgeSessionPacketExtension?.id + if (restartRequested) { + ConferenceMetrics.participantsRequestedRestart.inc() + } + val reinvite = restartRequested && acceptRestartRequest() + logger.info("Received session-terminate, bsId=$bridgeSessionId, restartRequested=$restartRequested") + + try { + conference.terminateSession(this@Participant, bridgeSessionPacketExtension?.id, reinvite) + } catch (e: InvalidBridgeSessionIdException) { + return StanzaError.from(StanzaError.Condition.item_not_found, e.message).build() + } + + // Note we still handle the termination above, we just don't start a new session. + if (restartRequested && !reinvite) { + logger.warn("Rate limiting restart request.") + return StanzaError.from(StanzaError.Condition.resource_constraint, "rate-limited").build() + } + + return null + } + + override fun onTransportInfo( + jingleSession: JingleSession, + contents: List + ): StanzaError? { + logger.info("Received transport-info") + if (chatMember.isJigasi) { + // Jigasi insists on using trickle and only includes ICE credentials in a transport-info sent before + // session-accept. So we need to always accept transport-info. + if (this@Participant.jingleSession != null && this@Participant.jingleSession != jingleSession) { + // If we somehow end up with more than one session we can't use the normal check to ignore requests + // for the old one. Ideally the flow that sets Participant.jingleSession should be cleaned up + // to simply contain the latest session, but until then just log a warning. + logger.warn("Accepting transport-info for a different (new?) jingle session") + } + } else { + checkJingleSession(jingleSession)?.let { + // It's technically allowed to send transport-info before the session is active (XEPs 166, 176), but + // we prefer to avoid trickle at all and don't expect to see it. + logger.warn("Received an early or stale transport-info from non-jigasi.") + } + } + + val transport = contents.getTransport() + ?: return StanzaError.from(StanzaError.Condition.bad_request, "missing transport").build() + conference.updateTransport(this@Participant, transport) + + return null + } + + override fun onTransportAccept(jingleSession: JingleSession, contents: List) = + onSessionOrTransportAccept(jingleSession, contents, JingleAction.TRANSPORT_ACCEPT) + + override fun onTransportReject(jingleSession: JingleSession, iq: JingleIQ) { + checkJingleSession(jingleSession)?.let { return } + + logger.warn("Received transport-reject: ${iq.toXML()}") + } + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/SourceSignaling.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/SourceSignaling.kt new file mode 100644 index 0000000..802cbfb --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/SourceSignaling.kt @@ -0,0 +1,103 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.conference.AddOrRemove.Add +import org.jitsi.jicofo.conference.AddOrRemove.Remove +import org.jitsi.jicofo.conference.source.ConferenceSourceMap +import org.jitsi.utils.MediaType + +class SourceSignaling( + audio: Boolean = true, + video: Boolean = true, + private val stripSimulcast: Boolean = true +) { + /** The set of media types supported by the endpoint. */ + private val supportedMediaTypes: Set = buildSet { + if (audio) add(MediaType.AUDIO) + if (video) add(MediaType.VIDEO) + } + + /** + * The pre-filtered set of sources that have been signaled to the endpoint. + * The actual set of sources that have been signaled are the result of [filter] applied to [signaledSources]. + */ + private var signaledSources = ConferenceSourceMap() + + /** + * The pre-filtered set of updated sources, i.e. [signaledSources] with any requested changes made via [addSources] + * or [removeSources]. + */ + private var updatedSources = ConferenceSourceMap() + + fun addSources(sourcesToAdd: ConferenceSourceMap) { + updatedSources.add(sourcesToAdd) + } + + fun removeSources(sourcesToRemove: ConferenceSourceMap) { + updatedSources.remove(sourcesToRemove) + } + + /** + * Update [signaledSources] to [updatedSources]. Return the set of operations ([Add] or [Remove]) needed to be + * signaled to the endpoint to accomplish the update. + */ + fun update(): List { + val ss = signaledSources.filter() + val us = updatedSources.filter() + val sourcesToAdd = us - ss + val sourcesToRemove = ss - us + reset(updatedSources) + return buildList { + if (sourcesToRemove.isNotEmpty()) { + add(SourcesToAddOrRemove(Remove, sourcesToRemove)) + } + if (sourcesToAdd.isNotEmpty()) { + add(SourcesToAddOrRemove(Add, sourcesToAdd)) + } + } + } + + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + set("signaled_sources", signaledSources.toJson()) + set("sources", updatedSources.toJson()) + set( + "supported_media_types", + JsonNodeFactory.instance.arrayNode().apply { supportedMediaTypes.forEach { add(it.toString()) } } + ) + } + + fun reset(s: ConferenceSourceMap): ConferenceSourceMap { + signaledSources = s.copy() + updatedSources = signaledSources.copy() + return s.filter() + } + + /** + * Filter out certain sources which should not be signaled to this endpoint. E.g. filter out video for endpoints + * which don't support video. + */ + private fun ConferenceSourceMap.filter(): ConferenceSourceMap = copy().apply { + stripByMediaType(supportedMediaTypes) + if (stripSimulcast) stripSimulcast() + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/SourcesToAddOrRemove.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/SourcesToAddOrRemove.kt new file mode 100644 index 0000000..59a7df5 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/SourcesToAddOrRemove.kt @@ -0,0 +1,40 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.conference.source.ConferenceSourceMap + +/** An action -- add or remove. */ +enum class AddOrRemove { + Add, + Remove +} + +/** Holds a [ConferenceSourceMap] together with an action specifying if the sources are to be added or removed. */ +data class SourcesToAddOrRemove( + val action: AddOrRemove, + val sources: ConferenceSourceMap +) { + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("action", action.toString()) + set("sources", sources.toJson()) + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/translation/ConferenceTranslationManager.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/translation/ConferenceTranslationManager.kt new file mode 100644 index 0000000..44f78fb --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/translation/ConferenceTranslationManager.kt @@ -0,0 +1,133 @@ +/* + * Copyright @ 2024 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.translation + +import org.jitsi.jicofo.TranslationConfig +import org.jitsi.jicofo.bridge.colibri.ColibriSessionManager +import org.jitsi.jicofo.bridge.colibri.TranslationRequest +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.conference.source.Source +import org.jitsi.jicofo.conference.source.ValidatingConferenceSourceMap +import org.jitsi.utils.MediaType +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger + +/** + * Ties the aggregated live-translation request map (from RoomMetadata) to the conference: it maintains the synthetic + * audio [Source]s in the conference source map, signals them to the bridges, and drives the translator ``. + * + * Synthetic sources are owned by the sender endpoint and survive the sender's unrelated source add/remove (they are + * only removed when the request is dropped, the sender's audio source disappears, or the sender leaves — reflected via + * [reapply], which the conference calls on the relevant events). + * + * All side effects on the conference source map and colibri session manager happen here; the pure naming/SSRC logic + * lives in [TranslationSourceManager]. + */ +class ConferenceTranslationManager( + private val conferenceSources: ValidatingConferenceSourceMap, + parentLogger: Logger +) { + private val logger = createChildLogger(parentLogger) + private val sourceManager = TranslationSourceManager() + private var requests: Map> = emptyMap() + + /** + * Per-room translator connect headers (config headers merged with room metadata, e.g. a per-customer usage + * token), or null to use the static config headers. Stored so [reapply] reuses the last value. + */ + private var customHeaders: Map? = null + + /** Update the aggregated request map (and per-room connect headers) and re-apply. */ + @Synchronized + fun setRequests( + requests: Map>, + customHeaders: Map?, + colibriSessionManager: ColibriSessionManager?, + meetingId: String? + ) { + this.requests = requests + this.customHeaders = customHeaders + apply(colibriSessionManager, meetingId) + } + + /** Re-apply the last request map (e.g. after a participant left or removed a source, or once colibri is ready). */ + @Synchronized + fun reapply(colibriSessionManager: ColibriSessionManager?, meetingId: String?) = + apply(colibriSessionManager, meetingId) + + private fun apply(colibriSessionManager: ColibriSessionManager?, meetingId: String?) { + if (colibriSessionManager == null || meetingId == null) { + // Not ready yet; will be applied from JitsiMeetConferenceImpl once colibri is initialized. + return + } + + val result = sourceManager.update(requests, ::baseName, ::ssrcInUse) + + // Diff the desired synthetic sources against what is currently in the conference source map and apply the delta. + val affected = mutableSetOf() + for (sender in conferenceSources.unmodifiable().keys.toList()) { + val existingSynthetic = conferenceSources[sender]?.sources?.filter { it.synthetic }?.toSet() ?: emptySet() + val desired = result.sourcesBySender[sender] ?: emptySet() + val toRemove = existingSynthetic - desired + val toAdd = desired - existingSynthetic + + try { + if (toRemove.isNotEmpty()) { + conferenceSources.tryToRemove(sender, EndpointSourceSet(toRemove)) + affected += sender + } + if (toAdd.isNotEmpty()) { + conferenceSources.tryToAdd(sender, EndpointSourceSet(toAdd)) + affected += sender + } + } catch (e: Exception) { + logger.error("Failed to update synthetic translation sources for $sender", e) + } + } + + // Signal the updated source sets to the bridges. + affected.forEach { sender -> + colibriSessionManager.updateParticipant(sender, sources = conferenceSources[sender]) + } + + // Enable/update/disable the translator connect(s). The colibri layer decides placement (per-source or + // single-bridge) based on configuration. + val url = if (result.isEmpty) null else TranslationConfig.config.getUrl(meetingId) + val translationRequests = result.sourcesBySender.mapNotNull { (sender, sources) -> + val baseName = baseName(sender) ?: return@mapNotNull null + TranslationRequest(sender, baseName, sources.map { it.name!! }.sorted()) + } + colibriSessionManager.setTranslator(url, translationRequests, customHeaders) + + logger.info( + "Applied audio translation: requests=$requests, translationRequests=$translationRequests, " + + "signaledBridgesFor=$affected, translator=${if (url != null) "enabled" else "disabled"}" + ) + } + + /** The base (first non-synthetic audio) source name for a sender, or null if it has none / is not present. */ + private fun baseName(senderId: String): String? { + val audio = conferenceSources[senderId]?.sources?.firstOrNull { + it.mediaType == MediaType.AUDIO && !it.synthetic + } ?: return null + return audio.name ?: Source.nameForIdAndMediaType(senderId, MediaType.AUDIO, 0) + } + + /** Whether an SSRC is already used by a non-synthetic source in the conference. */ + private fun ssrcInUse(ssrc: Long): Boolean = conferenceSources.unmodifiable().values.any { set -> + set.sources.any { !it.synthetic && it.ssrc == ssrc } + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/translation/TranslationSourceManager.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/translation/TranslationSourceManager.kt new file mode 100644 index 0000000..43ca263 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/conference/translation/TranslationSourceManager.kt @@ -0,0 +1,132 @@ +/* + * Copyright @ 2024 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.translation + +import org.jitsi.jicofo.conference.source.Source +import org.jitsi.utils.MediaType +import java.util.concurrent.ThreadLocalRandom + +/** + * The result of recomputing the set of synthetic translation sources. + * + * @param sourcesBySender for each sender endpoint id, the set of synthetic audio sources (one per requested language). + * @param requestNames the names of the synthetic sources the bridge requests from the translator (each encodes the + * language as a "." suffix). + * @param exportNames the names of the (real) sources the bridge exports to the translator, i.e. the senders' base + * audio sources to be translated. + */ +data class TranslationSources( + val sourcesBySender: Map>, + val requestNames: Set, + val exportNames: Set +) { + val isEmpty: Boolean + get() = sourcesBySender.isEmpty() +} + +/** + * Computes and tracks the synthetic audio [Source]s used for live translation. + * + * Given the aggregated request map (sender endpoint id -> requested language codes), it produces one synthetic audio + * source per ``, named `.` where `baseName` is the sender's first audio source + * name. SSRCs are minted to avoid collisions with the conference and are kept stable across updates: an existing + * `` keeps its SSRC, so the map is only ever extended or pruned, never churned. + * + * This class holds the allocation state but performs no side effects (it does not touch the conference source map or + * signal anything). The caller applies the returned [TranslationSources] to the conference. + */ +class TranslationSourceManager( + /** Mints a candidate SSRC. Overridable for tests; defaults to a random 32-bit value. */ + private val ssrcGenerator: () -> Long = { ThreadLocalRandom.current().nextLong(1, MAX_SSRC) } +) { + /** sender endpoint id -> (language -> synthetic source). */ + private val allocated = mutableMapOf>() + + /** + * Recompute the synthetic sources for [requests]. + * + * @param requests sender endpoint id -> requested language codes. + * @param baseNameResolver returns the base (first audio) source name for a sender, or null when the sender is not + * present or has no audio source — such entries are skipped (and any existing synthetic sources for them dropped). + * @param ssrcInUse returns true when an SSRC is already used in the conference (by non-synthetic sources). Newly + * minted SSRCs avoid these as well as any already-allocated synthetic SSRC. + */ + @Synchronized + fun update( + requests: Map>, + baseNameResolver: (String) -> String?, + ssrcInUse: (Long) -> Boolean + ): TranslationSources { + val next = mutableMapOf>() + + for ((sender, languages) in requests) { + if (languages.isEmpty()) continue + val baseName = baseNameResolver(sender) ?: continue + val existingForSender = allocated[sender] + val nextForSender = mutableMapOf() + + for (language in languages.toSet()) { + val existing = existingForSender?.get(language) + nextForSender[language] = if (existing != null) { + // Keep the SSRC stable; refresh the name in case the base source name changed. + val expectedName = nameFor(baseName, language) + if (existing.name == expectedName) existing else existing.copy(name = expectedName) + } else { + Source( + ssrc = mintSsrc(ssrcInUse, next), + mediaType = MediaType.AUDIO, + name = nameFor(baseName, language), + synthetic = true + ) + } + } + next[sender] = nextForSender + } + + allocated.clear() + allocated.putAll(next) + + val sourcesBySender = next.mapValues { (_, byLang) -> byLang.values.toSet() } + val requestNames = next.values.flatMap { it.values }.map { it.name!! }.toSet() + val exportNames = next.keys.mapNotNull { baseNameResolver(it) }.toSet() + + return TranslationSources(sourcesBySender, requestNames, exportNames) + } + + /** All synthetic SSRCs currently allocated. */ + @Synchronized + fun allocatedSsrcs(): Set = allocated.values.flatMap { it.values }.map { it.ssrc }.toSet() + + private fun mintSsrc(ssrcInUse: (Long) -> Boolean, next: Map>): Long { + val usedByNext = next.values.flatMap { it.values }.map { it.ssrc }.toSet() + val usedByAllocated = allocated.values.flatMap { it.values }.map { it.ssrc }.toSet() + repeat(MAX_MINT_ATTEMPTS) { + val candidate = ssrcGenerator() and 0xFFFFFFFFL + if (candidate != 0L && !ssrcInUse(candidate) && candidate !in usedByNext && candidate !in usedByAllocated) { + return candidate + } + } + throw IllegalStateException("Failed to mint a non-conflicting SSRC after $MAX_MINT_ATTEMPTS attempts") + } + + companion object { + private const val MAX_SSRC = 0x1_0000_0000L + private const val MAX_MINT_ATTEMPTS = 1000 + + /** The synthetic source name for a base source name and a language: ".". */ + fun nameFor(baseName: String, language: String) = "$baseName.$language" + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/health/HealthConfig.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/health/HealthConfig.kt new file mode 100644 index 0000000..9adc99c --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/health/HealthConfig.kt @@ -0,0 +1,51 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.health + +import org.jitsi.config.JitsiConfig.Companion.legacyConfig +import org.jitsi.config.JitsiConfig.Companion.newConfig +import org.jitsi.metaconfig.config +import java.time.Duration + +class HealthConfig private constructor() { + val enabled: Boolean by config { + "org.jitsi.jicofo.health.ENABLE_HEALTH_CHECKS".from(legacyConfig) + "jicofo.health.enabled".from(newConfig) + } + + val interval: Duration by config { + "jicofo.health.interval".from(newConfig) + } + + val timeout: Duration by config { + "jicofo.health.timeout".from(newConfig) + } + + val maxCheckDuration: Duration by config { + "jicofo.health.max-check-duration".from(newConfig) + } + + val roomNamePrefix: String by config { + "jicofo.health.room-name-prefix".from(newConfig) + } + + companion object { + @JvmField + val config = HealthConfig() + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/health/JicofoHealthChecker.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/health/JicofoHealthChecker.kt new file mode 100644 index 0000000..7b3d95c --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/health/JicofoHealthChecker.kt @@ -0,0 +1,187 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.health + +import org.jitsi.health.HealthCheckService +import org.jitsi.health.HealthChecker +import org.jitsi.health.Result +import org.jitsi.jicofo.FocusManager +import org.jitsi.jicofo.bridge.BridgeSelector +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.jicofo.xmpp.XmppConfig +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jitsi.utils.logging2.createLogger +import org.jivesoftware.smack.SmackException +import org.jivesoftware.smack.StanzaListener +import org.jivesoftware.smackx.ping.packet.Ping +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.jid.parts.Localpart +import java.time.Clock +import java.time.Duration +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.logging.Level +import kotlin.random.Random + +/** + * Checks the health of Jicofo. + * + * @author Lyubomir Marinov + * @author Pawel Domas + */ +class JicofoHealthChecker( + config: HealthConfig, + private val focusManager: FocusManager, + private val bridgeSelector: BridgeSelector, + private val xmppProviders: Collection +) : HealthCheckService { + private val logger = createLogger() + + /** Counts how many health checks took too long. */ + var totalSlowHealthChecks: Long = 0 + private set + + private val healthChecker = HealthChecker( + config.interval, + config.timeout, + config.maxCheckDuration, + false, + Duration.ofMinutes(5), + { performCheck() }, + Clock.systemUTC() + ) + + fun start() = healthChecker.start() + + fun shutdown() { + try { + healthChecker.stop() + } catch (e: Exception) { + logger.warn("Failed to stop.", e) + } + } + + private fun performCheck(): Result { + val start = System.currentTimeMillis() + return check().also { + val duration = System.currentTimeMillis() - start + if (duration > HealthConfig.config.maxCheckDuration.toMillis()) { + logger.error("Health check took too long: $duration ms") + totalSlowHealthChecks++ + } + healthyMetric.set(it.success) + } + } + + override val result: Result + get() = healthChecker.result + + /** + * Checks the health (status) of a specific [FocusManager]. + * + * @throws Exception if an error occurs while checking the health (status) + * of `focusManager` or the check determines that `focusManager` + * is not healthy + */ + private fun check(): Result { + if (bridgeSelector.operationalBridgeCount <= 0) { + return Result( + success = false, + hardFailure = true, + message = "No operational bridges available (total bridge count: ${BridgeSelector.bridgeCount.get()})" + ) + } + + // Generate a pseudo-random room name. Minimize the risk of clashing with existing conferences. + var roomName: EntityBareJid + do { + roomName = JidCreate.entityBareFrom( + generateRoomName(), + XmppConfig.client.conferenceMucJid + ) + } while (focusManager.getConference(roomName) != null) + + // Create a conference with the generated room name. + try { + if (!focusManager.conferenceRequest( + roomName, + emptyMap(), + loggingLevel = Level.WARNING, + includeInStatistics = false + ).isStarted + ) { + return Result(success = false, hardFailure = true, message = "Test conference failed to start.") + } + } catch (e: SmackException.NoResponseException) { + // Treat timeouts as "soft" as they could be just due to load. + return Result( + success = false, + hardFailure = false, + message = "Test conference failed to start due to timeout." + ) + } + + try { + xmppProviders.forEach { pingXmppProvider(it) } + } catch (e: Exception) { + return Result(success = false, hardFailure = false, message = "XMPP ping failed: ${e.message}") + } + + return Result(success = true) + } + + /** + * The check focusManager.conferenceRequest uses Smack's collectors for the response which is executed + * in its Reader thread. This ping test use a sync stanza listener which is executed in a single thread + * and if something is blocking it healthcheck will fail. + * + * @throws Exception if the check fails or some other error occurs + */ + @Throws(Exception::class) + private fun pingXmppProvider(xmppProvider: XmppProvider) { + val pingResponseWait = CountDownLatch(1) + val xmppDomain = xmppProvider.config.xmppDomain + if (xmppDomain == null) { + logger.debug("Not pinging ${xmppProvider.config.name}, domain not configured.") + return + } + + val p = Ping(JidCreate.bareFrom(xmppDomain)) + val listener = StanzaListener { _ -> pingResponseWait.countDown() } + try { + xmppProvider.xmppConnection.addSyncStanzaListener(listener) { it.stanzaId == p.stanzaId } + xmppProvider.xmppConnection.sendStanza(p) + + // Wait for 5 seconds to receive a response. + if (!pingResponseWait.await(5, TimeUnit.SECONDS)) { + throw RuntimeException("did not receive xmpp ping response for ${xmppProvider.config.name}") + } + } finally { + xmppProvider.xmppConnection.removeSyncStanzaListener(listener) + } + } + + companion object { + val healthyMetric = JicofoMetricsContainer.instance.registerBooleanMetric( + "healthy", + "Whether jicofo is healthy or not.", + true + ) + } +} + +private fun generateRoomName(): Localpart = Localpart.from("${HealthConfig.config.roomNamePrefix}-${Random.nextLong()}") diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/BaseJibri.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/BaseJibri.kt new file mode 100644 index 0000000..4437bac --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/BaseJibri.kt @@ -0,0 +1,215 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jibri + +import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.api.trace.Tracer +import org.jitsi.jicofo.ConferenceConfig +import org.jitsi.jicofo.conference.JitsiMeetConferenceImpl +import org.jitsi.jicofo.jibri.JibriSession.StateListener +import org.jitsi.jicofo.xmpp.IqProcessingResult +import org.jitsi.jicofo.xmpp.IqProcessingResult.AcceptedWithNoResponse +import org.jitsi.jicofo.xmpp.IqProcessingResult.NotProcessed +import org.jitsi.jicofo.xmpp.IqProcessingResult.RejectedWithError +import org.jitsi.jicofo.xmpp.IqRequest +import org.jitsi.jicofo.xmpp.muc.hasModeratorRights +import org.jitsi.jicofo.xmpp.tryToSendStanza +import org.jitsi.tracing.TracingGlobal +import org.jitsi.utils.logging2.Logger +import org.jitsi.xmpp.extensions.jibri.JibriIq +import org.jitsi.xmpp.extensions.jibri.JibriIq.Action +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import java.lang.Exception +import kotlin.use +import org.jitsi.jicofo.util.ErrorResponse.create as error + +/** + * Base class shared between [JibriRecorder] (which can deal with only one [JibriSession]) and [JibriSipGateway] + * (which is capable of handling multiple, simultaneous [JibriSession]s). + * + * @author Pawel Domas + */ +abstract class BaseJibri internal constructor( + protected val conference: JitsiMeetConferenceImpl, + parentLogger: Logger, + val jibriDetector: JibriDetector +) : StateListener { + + protected val logger: Logger = parentLogger.createChildLogger(BaseJibri::class.simpleName) + val tracer: Tracer = TracingGlobal.sdk.getTracer("org.jitsi.jicofo.jibri") + + fun handleJibriRequest(request: JibriRequest): IqProcessingResult = if (accept(request.iq)) { + logger.info("Accepted jibri request: ${request.iq.toXML()}") + // Handle the request in the room's queue. This keeps the request from racing with e.g., member presence. + val chatRoom = conference.chatRoom ?: run { + logger.warn("No chat room found for conference ${conference.roomName}") + return RejectedWithError(request.iq, StanzaError.Condition.internal_server_error) + } + + chatRoom.queueXmppTask { + val span = tracer.spanBuilder("jibri.${request.iq.action}") + .setAttribute("client.id", request.iq.from.toString()) + .setAttribute("recording-mode", request.iq.recordingMode.toString()) + .startSpan() + val response = try { + span.makeCurrent().use { + doHandleIQRequest(request.iq) + } + } catch (e: Exception) { + logger.warn("Failed to handle request: ${request.iq}", e) + request.connection.tryToSendStanza( + IQ.createErrorResponse(request.iq, StanzaError.Condition.internal_server_error) + ) + span.setStatus(StatusCode.ERROR, e.message ?: "") + null + } finally { + span.end() + } + response?.let { request.connection.tryToSendStanza(it) } + } + AcceptedWithNoResponse() + } else { + NotProcessed() + } + + /** + * Returns the [JibriSession] associated with a specific [JibriIq] coming from a client in the conference. + * + * If the extending class can deal with only one [JibriSession] at a time it should return it. If it's + * capable of handling multiple sessions then it should try to identify the session based on the information + * contained in the [iq]. If it's unable to match any session it should return [iq]. + * + * The purpose of having this method abstract is to share the logic for handling start and stop requests. For + * example if there's an incoming stop request it will be handled if this method returns a valid [JibriSession] + * instance. In case of a start request a new session will be created if this method returns `null`. + * + * @param iq the IQ originated from the Jitsi Meet participant (start or stop request) + * @return [JibriSession] if there is any [JibriSession] currently active for given IQ. + */ + protected abstract fun getJibriSessionForMeetIq(iq: JibriIq): JibriSession? + + /** + * @return a list with all [JibriSession]s used by this instance. + */ + abstract val jibriSessions: List + + /** + * This method will be called when a `start IQ` arrives from a Jitsi Meet participant and + * [.getJibriSessionForMeetIq] returns `null`. The implementing class should allocate and store a new + * [JibriSession]. Once a [JibriSession] is created it must be started by the implementing class. + * @param iq the start request coming from a Jitsi Meet participant. + * @return the response to be sent. It should be 'result' if a new session was started or 'error' otherwise. + */ + protected abstract fun handleStartRequest(iq: JibriIq): IQ + + /** + * Checks if the given [JibriIq] should be accepted by this instance. The IQ may originate either from a + * participant in the conference, or from a Jibri instance. It should be accepted if: + * 1a. It originates from a participant in the conference AND + * 1b. Matches the type of this instance (recording/streaming vs SIP), OR + * 2. Originates from a Jibri with which this instance has an active session. + * + * @return `true` if the IQ is to be accepted. + */ + protected fun accept(iq: JibriIq): Boolean { + // Process if it belongs to an active recording session + val session = getJibriSessionForMeetIq(iq) + if (session != null && session.accept(iq)) { + return true + } + + // Check if the implementation wants to deal with this IQ sub-type (recording/live-streaming vs SIP). + if (!acceptType(iq)) { + return false + } + + return conference.roomName.equals(iq.from.asBareJid()) + } + + protected abstract fun acceptType(packet: JibriIq): Boolean + + /** + * Handles an incoming Jibri IQ from either a jibri instance or a participant in the conference. This may block + * waiting for a response over the network. + * + * @return the IQ to be sent back as a response ('result' or 'error'). + */ + private fun doHandleIQRequest(iq: JibriIq): IQ { + logger.debug { "Jibri request. IQ: ${iq.toXML()}" } + + // Coming from a Jibri instance. + val session = getJibriSessionForMeetIq(iq) + if (session != null && session.accept(iq)) { + return session.processJibriIqRequestFromJibri(iq) + } + + if (ConferenceConfig.config.enableModeratorChecks) { + verifyModeratorRole(iq)?.let { + logger.warn("Ignored Jibri request from non-moderator.") + return IQ.createErrorResponse(iq, it) + } + } + + return when (iq.action) { + Action.START -> when (session) { + null -> handleStartRequest(iq) + + else -> { + logger.info("Will not start a Jibri session, a session is already active") + error( + iq, + StanzaError.Condition.unexpected_request, + "Recording or live streaming is already enabled" + ) + } + } + + Action.STOP -> when (session) { + null -> { + logger.warn("Rejecting STOP request for an unknown session.: ${iq.toXML()}") + error(iq, StanzaError.Condition.item_not_found, "Unknown session") + } + + else -> { + session.stop(iq.from) + IQ.createResultIQ(iq) + } + } + + Action.UNDEFINED, null -> { + return error(iq, StanzaError.Condition.bad_request, "undefined action ${iq.toXML()}") + } + } + } + + private fun verifyModeratorRole(iq: JibriIq): StanzaError? { + val role = conference.getRoleForMucJid(iq.from) + return when { + // XXX do we need to keep the difference between `forbidden` and `not_allowed`? + role == null -> StanzaError.getBuilder(StanzaError.Condition.forbidden).build() + + role.hasModeratorRights() -> null + + // no error + else -> StanzaError.getBuilder(StanzaError.Condition.not_allowed).build() + } + } +} + +typealias JibriRequest = IqRequest diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriConfig.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriConfig.kt new file mode 100644 index 0000000..b062527 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriConfig.kt @@ -0,0 +1,70 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jibri + +import org.jitsi.config.JitsiConfig.Companion.legacyConfig +import org.jitsi.config.JitsiConfig.Companion.newConfig +import org.jitsi.jicofo.xmpp.XmppConnectionEnum +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.optionalconfig +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import java.time.Duration + +class JibriConfig private constructor() { + val breweryJid: EntityBareJid? by optionalconfig { + "org.jitsi.jicofo.jibri.BREWERY".from(legacyConfig).convertFrom { + JidCreate.entityBareFrom(it) + } + "jicofo.jibri.brewery-jid".from(newConfig).convertFrom { + JidCreate.entityBareFrom(it) + } + } + + val sipBreweryJid: EntityBareJid? by optionalconfig { + "org.jitsi.jicofo.jibri.SIP_BREWERY".from(legacyConfig).convertFrom { + JidCreate.entityBareFrom(it) + } + "jicofo.jibri-sip.brewery-jid".from(newConfig).convertFrom { + JidCreate.entityBareFrom(it) + } + } + + val pendingTimeout: Duration by config { + "org.jitsi.jicofo.jibri.PENDING_TIMEOUT".from(legacyConfig).convertFrom { Duration.ofSeconds(it) } + "jicofo.jibri.pending-timeout".from(newConfig) + } + + val numRetries: Int by config { + "org.jitsi.jicofo.NUM_JIBRI_RETRIES".from(legacyConfig) + "jicofo.jibri.num-retries".from(newConfig) + } + + val xmppConnectionName: XmppConnectionEnum by config { + "jicofo.jibri.xmpp-connection-name".from(newConfig) + } + + val privateAddressConnectivity: Boolean by config { + "jicofo.jibri.use-private-address-connectivity".from(newConfig) + } + + companion object { + @JvmField + val config = JibriConfig() + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriDetector.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriDetector.kt new file mode 100644 index 0000000..23b57c6 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriDetector.kt @@ -0,0 +1,194 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jibri + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.xmpp.BaseBrewery +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jitsi.utils.concurrent.CustomizableThreadFactory +import org.jitsi.utils.event.AsyncEventEmitter +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.jibri.JibriStatusPacketExt +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.Jid +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors + +/** + * JibriDetector manages the pool of Jibri instances by joining a "brewery" room where Jibris connect to and + * publish their status in MUC presence. + * + * @author Pawel Domas + */ +class JibriDetector( + xmppProvider: XmppProvider, + private val breweryJid: EntityBareJid, + private val isSip: Boolean, + val clock: Clock = Clock.systemUTC() +) : BaseBrewery( + xmppProvider, + breweryJid, + JibriStatusPacketExt.ELEMENT, + JibriStatusPacketExt.NAMESPACE, + createLogger().apply { addContext("type", if (isSip) "sip_jibri" else "jibri") } +) { + private val eventEmitter = AsyncEventEmitter(eventEmitterExecutor) + + val logger = createLogger() + + val xmppConnection = xmppProvider.xmppConnection + + /** + * Selects a Jibri to be used for a recording session. + * + * Selects from the jibris which have advertised themselves as healthy and idle, which haven't been + * selected in the last [SELECT_TIMEOUT], and which have not been reported failed in the last [FAILURE_TIMEOUT]. + * If multiple instances match, selects the one which has failed least recently (or hasn't failed). + * + * @return the XMPP address of the selected instance. + */ + fun selectJibri(): Jid? { + val now = clock.instant() + val oldest = jibriInstances.values.filter { + it.reportsAvailable && Duration.between(it.lastSelected, now) >= SELECT_TIMEOUT + }.minByOrNull { it.lastFailed } ?: return null + + return if (Duration.between(oldest.lastFailed, now) >= FAILURE_TIMEOUT) { + oldest.lastSelected = now + oldest.jid + } else { + null + } + } + + /** + * Notify [JibriDetector] that the instance with JID [jid] failed a request (it returned an error or a request + * timed out). Failed instances are not selected for [FAILURE_TIMEOUT], and after this timeout they are prioritized + * according to the time of failure. + */ + fun instanceFailed(jid: Jid) { + jibriInstances[jid]?.let { + logger.info("Instance failed: $jid. Will not be selected for the next $FAILURE_TIMEOUT") + it.lastFailed = clock.instant() + } + } + + /** The jibri instances to select from */ + private val jibriInstances: MutableMap = ConcurrentHashMap() + + override fun onInstanceStatusChanged(jid: EntityFullJid, presenceExt: JibriStatusPacketExt) { + if (!jibriInstances.containsKey(jid)) { + logger.info("Creating a new instance for $jid, available = ${presenceExt.isAvailable}") + jibriInstances[jid] = JibriInstance(jid, presenceExt.isAvailable) + } + + val jibriInstance = jibriInstances[jid] ?: let { + logger.error("Instance was removed. Thread safety issues?") + return + } + + jibriInstance.reportsAvailable = presenceExt.isAvailable + if (jibriInstance.reportsAvailable) { + // If we receive a new presence indicating the instance is available, override any failures we've detected. + // This is because jibri does NOT periodically advertise presence in the MUC and only updates it when + // something actually changes. + if (jibriInstance.lastFailed != NEVER) { + logger.info("Resetting failure state for $jid") + jibriInstance.lastFailed = NEVER + } + } + } + + override fun notifyInstanceOffline(jid: Jid) { + // Remove from jibriInstances + logger.info("Removing instance $jid") + jibriInstances.remove(jid) + + eventEmitter.fireEvent { instanceOffline(jid) } + } + + fun addHandler(eventHandler: EventHandler) = eventEmitter.addHandler(eventHandler) + fun removeHandler(eventHandler: EventHandler) = eventEmitter.removeHandler(eventHandler) + + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().also { debugState -> + debugState.put("is_sip", isSip) + debugState.put("brewery_jid", breweryJid.toString()) + instances.forEach { instance -> + val instanceJson = JsonNodeFactory.instance.objectNode().apply { + put("health_status", instance.status.healthStatus?.status.toString()) + put("busy_status", instance.status.busyStatus?.status.toString()) + jibriInstances[instance.jid]?.let { + put("reports_available", it.reportsAvailable) + put("last_failed", it.lastFailed.toString()) + put("last_selected", it.lastSelected.toString()) + } + } + debugState.set(instance.jid.resourcepart.toString(), instanceJson) + } + } + + interface EventHandler { + fun instanceOffline(jid: Jid) {} + } + + /** + * The companion object is necessary for the implicit call to this.createLogger() in the super constructor! + */ + companion object { + /** + * TODO: Refactor to use a common executor. + */ + private val eventEmitterExecutor = Executors.newSingleThreadExecutor( + CustomizableThreadFactory("JibriDetector-AsyncEventEmitter", false) + ) + + private val NEVER = Instant.MIN + + /** + * The length of the failure timeout. See [selectJibri] and [instanceFailed]. + */ + val FAILURE_TIMEOUT: Duration = Duration.ofMinutes(1) + + /** + * The length of the select timeout. See [selectJibri]. + */ + val SELECT_TIMEOUT: Duration = Duration.ofMillis(200) + } + + /** + * Contains the relevant state for a Jibri instance. + */ + private class JibriInstance( + /** The jid of the instance */ + val jid: Jid, + /** Whether the jibri reported itself available (healthy and idle) in presence. */ + var reportsAvailable: Boolean + ) { + /** The last time (if any) at which this instance was reported failed via [instanceFailed]. */ + var lastFailed: Instant = NEVER + + /** The last time this instance was selected via [selectJibri]. */ + var lastSelected: Instant = NEVER + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriDetectorMetrics.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriDetectorMetrics.kt new file mode 100644 index 0000000..2055a5c --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriDetectorMetrics.kt @@ -0,0 +1,79 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2023-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jibri + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.metrics.JicofoMetricsContainer + +class JibriDetectorMetrics { + companion object { + private val jibriInstanceCount = JicofoMetricsContainer.instance.registerLongGauge( + "jibri_instances", + "Current number of jibri instances", + 0 + ) + private val jibriInstanceAvailableCount = JicofoMetricsContainer.instance.registerLongGauge( + "jibri_instances_available", + "Current number of available (not in use) jibri instances", + 0 + ) + private val sipJibriInstanceCount = JicofoMetricsContainer.instance.registerLongGauge( + "sip_jibri_instances", + "Current number of SIP jibri instances", + 0 + ) + private val sipJibriInstanceAvailableCount = JicofoMetricsContainer.instance.registerLongGauge( + "sip_jibri_instances_available", + "Current number of available (not in use) SIP jibri instances", + 0 + ) + + fun updateMetrics(jibriDetector: JibriDetector?, sipJibriDetector: JibriDetector?) { + jibriDetector?.instanceCount?.toLong()?.let { + jibriInstanceCount.set(it) + } + jibriDetector?.getInstanceCount { it.status.isAvailable }?.toLong()?.let { + jibriInstanceAvailableCount.set(it) + } + sipJibriDetector?.instanceCount?.toLong()?.let { + sipJibriInstanceCount.set(it) + } + sipJibriDetector?.getInstanceCount { it.status.isAvailable }?.toLong()?.let { + sipJibriInstanceAvailableCount.set(it) + } + } + + fun appendStats(o: ObjectNode) { + o.set( + "jibri_detector", + JsonNodeFactory.instance.objectNode().apply { + put("count", jibriInstanceCount.get()) + put("available", jibriInstanceAvailableCount.get()) + } + ) + o.set( + "sip_jibri_detector", + JsonNodeFactory.instance.objectNode().apply { + put("count", sipJibriInstanceCount.get()) + put("available", sipJibriInstanceAvailableCount.get()) + } + ) + } + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriRecorder.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriRecorder.kt new file mode 100644 index 0000000..79d4e6d --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriRecorder.kt @@ -0,0 +1,193 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jibri + +import org.apache.commons.lang3.StringUtils +import org.jitsi.jicofo.conference.JitsiMeetConferenceImpl +import org.jitsi.jicofo.jibri.JibriConfig.Companion.config +import org.jitsi.jicofo.jibri.JibriSession.StartException +import org.jitsi.jicofo.jibri.JibriSession.StartException.AllBusy +import org.jitsi.jicofo.jibri.JibriSession.StartException.NotAvailable +import org.jitsi.utils.logging2.Logger +import org.jitsi.xmpp.extensions.jibri.JibriIq +import org.jitsi.xmpp.extensions.jibri.JibriIq.RecordingMode +import org.jitsi.xmpp.extensions.jibri.JibriIq.Status +import org.jitsi.xmpp.extensions.jibri.RecordingStatus +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import java.util.* +import org.jitsi.jicofo.util.ErrorResponse.create as error + +/** + * Handles conference recording through Jibri. + * Waits for updates from [JibriDetector] about recorder instance + * availability and publishes that information in Jicofo's MUC presence. + * Handles incoming Jibri IQs coming from conference moderator to + * start/stop the recording. + * + * @author Pawel Domas + * @author Sam Whited + */ +class JibriRecorder( + conference: JitsiMeetConferenceImpl, + jibriDetector: JibriDetector, + parentLogger: Logger +) : BaseJibri( + conference, + parentLogger, + jibriDetector +) { + /** + * The current recording session or null. + */ + private var jibriSession: JibriSession? = null + + fun shutdown() { + jibriSession?.stop(null) + jibriSession = null + } + + /** + * Accepts only [JibriIq] without a SIP address. + */ + override fun acceptType(packet: JibriIq): Boolean { + // the packet cannot contain a SIP address (must be handled + // by JibriSipGateway) + return StringUtils.isBlank(packet.sipAddress) + } + + /** + * [JibriRecorder] has at most one jibri session. + */ + override fun getJibriSessionForMeetIq(iq: JibriIq): JibriSession? = jibriSession + + /** + * {@inheritDoc} + */ + override val jibriSessions: List + get() = jibriSession?.let { listOf(it) } ?: emptyList() + + /** + * Handles a request to start a jibri session coming from a client in the conference and returns the response to + * be sent. + * + * [BaseJibri] has checked that there is no recording session currently active. + */ + override fun handleStartRequest(iq: JibriIq): IQ { + val streamIdIsEmpty = StringUtils.isBlank(iq.streamId) + + return if (streamIdIsEmpty && iq.recordingMode != RecordingMode.FILE) { + // Stream ID is mandatory unless we're recording to a file. + error(iq, StanzaError.Condition.bad_request, "Stream ID is empty or undefined") + } else if (!streamIdIsEmpty && iq.recordingMode == RecordingMode.FILE) { + // Stream ID should not be provided with requests to record to a file. + error(iq, StanzaError.Condition.bad_request, "Stream ID is provided for a FILE recording.") + } else { + val sessionId = UUID.randomUUID().toString() + try { + val jibriSession = JibriSession( + this, + conference.roomName, + iq.from, + config.pendingTimeout.seconds, + config.numRetries, + jibriDetector, + false, null, iq.displayName, iq.streamId, iq.youtubeBroadcastId, sessionId, iq.appData, + conference.isRtcStatsEnabled, + logger + ) + this.jibriSession = jibriSession + jibriSession.start() + logger.info("Started Jibri session") + JibriIq.createResult(iq, sessionId) + } catch (exc: StartException) { + jibriSession = null + when (exc) { + is AllBusy -> { + logger.info("Failed to start a Jibri session, all Jibris were busy") + error(iq, StanzaError.Condition.resource_constraint, "all Jibris are busy") + } + + is NotAvailable -> { + logger.info("Failed to start a Jibri session, no Jibris available") + error(iq, StanzaError.Condition.service_unavailable, "no Jibris available") + } + + else -> { + logger.warn("Failed to start a Jibri session: ${exc.message}", exc) + error(iq, StanzaError.Condition.internal_server_error, exc.message) + } + } + } + } + } + + /** + * {@inheritDoc} + */ + override fun onSessionStateChanged( + jibriSession: JibriSession, + newStatus: Status, + failureReason: JibriIq.FailureReason? + ) { + if (this.jibriSession !== jibriSession) { + logger.error("onSessionStateChanged for unknown session: $jibriSession") + return + } + publishJibriRecordingStatus(newStatus, failureReason) + if (newStatus == Status.OFF) { + this.jibriSession = null + } + } + + /** + * Sends the new jibri status as presence in the conference MUC. + */ + private fun publishJibriRecordingStatus(newStatus: Status, failureReason: JibriIq.FailureReason?) { + logger.info("Got jibri status $newStatus and failure $failureReason") + val jibriSession = jibriSession + if (jibriSession == null) { + // It's possible back-to-back 'stop' requests could be received, and while processing the result of the + // first we set jibriSession // to null, so in the processing of the second one it will already be null. + logger.info("Jibri session was already cleaned up, not sending new status") + return + } + + val recordingStatus = RecordingStatus().apply { + status = newStatus + this.failureReason = failureReason + sessionId = jibriSession.sessionId + when (newStatus) { + Status.ON -> initiator = jibriSession.initiator + Status.OFF -> initiator = jibriSession.terminator + else -> Unit + } + + jibriSession.recordingMode.let { + if (it != RecordingMode.UNDEFINED) { + recordingMode = it + } + } + } + logger.info( + "Publishing new jibri-recording-status: ${recordingStatus.toXML()} in: ${conference.roomName}" + ) + + conference.setPresenceExtension(recordingStatus) + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriSipGateway.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriSipGateway.kt new file mode 100644 index 0000000..b60dcfc --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriSipGateway.kt @@ -0,0 +1,149 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2017-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jibri + +import org.apache.commons.lang3.StringUtils +import org.jitsi.jicofo.conference.JitsiMeetConferenceImpl +import org.jitsi.jicofo.jibri.JibriConfig.Companion.config +import org.jitsi.jicofo.jibri.JibriSession.StartException +import org.jitsi.jicofo.jibri.JibriSession.StartException.AllBusy +import org.jitsi.jicofo.jibri.JibriSession.StartException.NotAvailable +import org.jitsi.utils.logging2.Logger +import org.jitsi.xmpp.extensions.jibri.JibriIq +import org.jitsi.xmpp.extensions.jibri.SipCallState +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import java.util.UUID +import kotlin.collections.HashMap +import org.jitsi.jicofo.util.ErrorResponse.create as error + +/** + * A Jibri SIP gateway that manages SIP calls for single conference. Relies on + * the information provided by [JibriDetector] to tell whether any Jibris + * are currently available and to select one for new [JibriSession]s + * (JibriSession does the actual selection). + * + * @author Pawel Domas + */ +class JibriSipGateway( + conference: JitsiMeetConferenceImpl, + jibriDetector: JibriDetector, + parentLogger: Logger +) : BaseJibri( + conference, + parentLogger, + jibriDetector +) { + /** + * The SIP [JibriSession]s mapped by the SIP address. + */ + private val sipSessions: MutableMap = HashMap() + + /** + * Accepts only [JibriIq] with a SIP address (packets without one are handled by JibriRecorder). + */ + override fun acceptType(packet: JibriIq): Boolean = StringUtils.isNotBlank(packet.sipAddress) + + fun shutdown() { + try { + sipSessions.values.forEach { it.stop(null) } + } finally { + sipSessions.clear() + } + } + + override fun getJibriSessionForMeetIq(iq: JibriIq): JibriSession? = sipSessions[iq.sipAddress] + override val jibriSessions: List + get() = ArrayList(sipSessions.values) + + override fun handleStartRequest(iq: JibriIq): IQ = if (StringUtils.isNotBlank(iq.sipAddress)) { + val sessionId = UUID.randomUUID().toString() + val jibriSession = JibriSession( + this, + conference.roomName, + iq.from, + config.pendingTimeout.seconds, + config.numRetries, + jibriDetector, + false, + iq.sipAddress, + iq.displayName, null, null, sessionId, null, + conference.isRtcStatsEnabled, + logger + ) + sipSessions[iq.sipAddress] = jibriSession + try { + jibriSession.start() + logger.info("Started Jibri session") + JibriIq.createResult(iq, sessionId) + } catch (exc: StartException) { + val reason = exc.message + logger.warn("Failed to start a Jibri session: $reason", exc) + sipSessions.remove(iq.sipAddress) + when (exc) { + is AllBusy -> error(iq, StanzaError.Condition.resource_constraint, "all Jibris are busy") + is NotAvailable -> error(iq, StanzaError.Condition.service_unavailable, "no Jibris available") + else -> error(iq, StanzaError.Condition.internal_server_error, reason) + } + } + } else { + // Bad request - no SIP address + error(iq, StanzaError.Condition.bad_request, "Stream ID is empty or undefined") + } + + override fun onSessionStateChanged( + jibriSession: JibriSession, + newStatus: JibriIq.Status, + failureReason: JibriIq.FailureReason? + ) { + if (!sipSessions.containsValue(jibriSession)) { + logger.error("onSessionStateChanged for unknown session: $jibriSession") + return + } + publishJibriSipCallState(jibriSession, newStatus, failureReason) + if (JibriIq.Status.OFF == newStatus) { + val sipAddress = jibriSession.sipAddress + sipSessions.remove(sipAddress) + logger.info("Removing SIP call: $sipAddress") + } + } + + /** + * Updates the status of specific a [JibriSession]. Jicofo adds multiple [SipCallState] MUC presence extensions + * to its presence. One for each active SIP Jibri session. + * @param session the session for which the new status will be set + * @param newStatus the new status + * @param failureReason option error for OFF state + */ + private fun publishJibriSipCallState( + session: JibriSession, + newStatus: JibriIq.Status, + failureReason: JibriIq.FailureReason? + ) { + val sipCallState = SipCallState().apply { + setState(newStatus) + this.failureReason = failureReason + sipAddress = session.sipAddress + sessionId = session.sessionId + } + logger.info("Publishing new state: ${session.sipAddress} ${sipCallState.toXML()}") + + // Publish that in the presence + conference.chatRoom?.setPresenceExtension(sipCallState) ?: logger.warn("chatRoom is null") + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriStats.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriStats.kt new file mode 100644 index 0000000..87c3829 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriStats.kt @@ -0,0 +1,78 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jicofo.jibri + +import org.jitsi.jicofo.metrics.JicofoMetricsContainer + +/** + * Counts total stats (failures by session type). + */ +class JibriStats { + companion object { + @JvmField + val sipFailures = JicofoMetricsContainer.instance.registerCounter( + "jibri_sip_failures", + "Number of failures for a SIP jibri" + ) + + @JvmField + val recordingFailures = JicofoMetricsContainer.instance.registerCounter( + "jibri_recording_failures", + "Number of failures for a recording jibri" + ) + + @JvmField + val liveStreamingFailures = JicofoMetricsContainer.instance.registerCounter( + "jibri_live_streaming_failures", + "Number of failures for a live-streaming jibri" + ) + + @JvmField + val noInstanceFailures = JicofoMetricsContainer.instance.registerCounter( + "jibri_no_instance_failures", + "Number of failures to start a jibri session because no instance was available or all instances were busy" + ) + + @JvmField + val liveStreamingActive = JicofoMetricsContainer.instance.registerLongGauge( + "jibri_live_streaming_active", + "Current number of active jibris in live-streaming mode" + ) + + @JvmField + val recordingActive = JicofoMetricsContainer.instance.registerLongGauge( + "jibri_recording_active", + "Current number of active jibris in recording mode" + ) + + @JvmField + val sipActive = JicofoMetricsContainer.instance.registerLongGauge( + "jibri_sip_active", + "Current number of active jibris in SIP mode" + ) + + @JvmStatic + fun sessionFailed(type: JibriSession.Type) = when (type) { + JibriSession.Type.SIP_CALL -> sipFailures.inc() + JibriSession.Type.LIVE_STREAMING -> liveStreamingFailures.inc() + JibriSession.Type.RECORDING -> recordingFailures.inc() + } + + @JvmStatic + fun noInstanceFailed() = noInstanceFailures.inc() + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/jigasi/JigasiConfig.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/jigasi/JigasiConfig.kt new file mode 100644 index 0000000..ccd2fba --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/jigasi/JigasiConfig.kt @@ -0,0 +1,52 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jigasi + +import org.jitsi.config.JitsiConfig.Companion.legacyConfig +import org.jitsi.config.JitsiConfig.Companion.newConfig +import org.jitsi.jicofo.xmpp.XmppConnectionEnum +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.optionalconfig +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate + +class JigasiConfig private constructor() { + val breweryJid: EntityBareJid? by optionalconfig { + "org.jitsi.jicofo.jigasi.BREWERY".from(legacyConfig).convertFrom { + JidCreate.entityBareFrom(it) + } + "jicofo.jigasi.brewery-jid".from(newConfig).convertFrom { + JidCreate.entityBareFrom(it) + } + } + + val xmppConnectionName: XmppConnectionEnum by config { + "jicofo.jigasi.xmpp-connection-name".from(newConfig) + } + + val privateAddressConnectivity: Boolean by config { + "jicofo.jigasi.use-private-address-connectivity".from(newConfig) + } + + fun xmppConnectionName() = xmppConnectionName + + companion object { + @JvmField + val config = JigasiConfig() + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/jigasi/JigasiDetector.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/jigasi/JigasiDetector.kt new file mode 100644 index 0000000..84986a2 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/jigasi/JigasiDetector.kt @@ -0,0 +1,167 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jigasi + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.JicofoConfig +import org.jitsi.jicofo.bridge.BridgeConfig +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.jicofo.xmpp.BaseBrewery +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.Jid + +/** + * [JigasiDetector] manages the pool of Jigasi instances by joining a "brewery" room that Jigasis connect to + * and publish their status in MUC presence. + * @author Damian Minkov + */ +open class JigasiDetector( + xmppProvider: XmppProvider, + val breweryJid: EntityBareJid, + private val localRegion: String? = JicofoConfig.config.localRegion +) : BaseBrewery( + xmppProvider, + breweryJid, + ColibriStatsExtension.ELEMENT, + ColibriStatsExtension.NAMESPACE, + createLogger().apply { addContext("type", "jigasi") } +) { + val xmppConnection = xmppProvider.xmppConnection + + override fun onInstanceStatusChanged(jid: EntityFullJid, status: ColibriStatsExtension) {} + override fun notifyInstanceOffline(jid: Jid) {} + + /** + * Selects a jigasi instance which supports transcription. + * @param exclude a list of excluded instances. + * @param preferredRegions a list of preferred regions. + * @return the JID of the selected instance, or `null` if there are no available jigasis that satisfy the + * constraints (are not excluded and support transcription). + */ + fun selectTranscriber(exclude: List = emptyList(), preferredRegions: Collection = emptySet()): Jid? = + selectJigasi(instances, exclude, preferredRegions, localRegion, transcriber = true) + + /** + * Selects a jigasi instance which supports SIP. + * @param exclude a list of excluded instances. + * @param preferredRegions a list of preferred regions. + * @return the JID of the selected instance, or `null` if there are no available jigasis that satisfy the + * constraints (are not excluded and support SIP). + */ + fun selectSipJigasi(exclude: List = emptyList(), preferredRegions: Collection = emptySet()): Jid? = + selectJigasi(instances, exclude, preferredRegions, localRegion, transcriber = false) + + val stats: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("sip_count", sipCount.get()) + put("sip_in_graceful_shutdown_count", sipInGracefulShutdownCount.get()) + put("transcriber_count", transcriberCount.get()) + } + + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().also { debugState -> + debugState.put("brewery_jid", breweryJid.toString()) + instances.forEach { instance -> + val instanceJson = JsonNodeFactory.instance.objectNode().apply { + put("supports_sip", instance.supportsSip()) + put("supports_transcription", instance.supportsTranscription()) + put("is_in_graceful_shutdown", instance.isInGracefulShutdown()) + put("participants", instance.getParticipantCount()) + put("region", instance.getRegion() ?: "null") + } + debugState.set(instance.jid.resourceOrEmpty.toString(), instanceJson) + } + } + + fun updateMetrics() { + sipCount.set(getInstanceCount { it.supportsSip() }.toLong()) + sipInGracefulShutdownCount.set(getInstanceCount { it.supportsSip() && it.isInGracefulShutdown() }.toLong()) + transcriberCount.set(getInstanceCount { it.supportsTranscription() }.toLong()) + } + + /** + * The companion object is necessary for the implicit call to this.createLogger() in the super constructor! + */ + companion object { + @JvmStatic + private fun selectJigasi( + instances: List.BrewInstance>, + exclude: List = emptyList(), + preferredRegions: Collection = emptyList(), + localRegion: String? = null, + transcriber: Boolean = false + ): Jid? { + val availableInstances = instances + .filter { !exclude.contains(it.jid) } + .filter { !it.isInGracefulShutdown() } + .filter { if (transcriber) it.supportsTranscription() else it.supportsSip() } + + // Try to match the preferred region. + availableInstances.filter { it.isInRegion(*preferredRegions.toTypedArray()) }.let { + if (it.isNotEmpty()) return it.leastLoaded()?.jid + } + // Try to match the preferred region groups. + val extendedPreferredRegions = preferredRegions.flatMap { region -> + BridgeConfig.config.getRegionGroup(region) + } + availableInstances.filter { it.isInRegion(*extendedPreferredRegions.toTypedArray()) }.let { + if (it.isNotEmpty()) return it.leastLoaded()?.jid + } + + // Otherwise try to match the local region. + availableInstances.filter { it.isInRegion(localRegion) }.let { + if (it.isNotEmpty()) return it.leastLoaded()?.jid + } + + return availableInstances.leastLoaded()?.jid + } + + val sipCount = JicofoMetricsContainer.instance.registerLongGauge( + "jigasi_sip_count", + "Number of jigasi instances that support SIP" + ) + val sipInGracefulShutdownCount = JicofoMetricsContainer.instance.registerLongGauge( + "jigasi_sip_in_graceful_shutdown_count", + "Number of jigasi instances that support SIP and are in graceful shutdown" + ) + val transcriberCount = JicofoMetricsContainer.instance.registerLongGauge( + "jigasi_transcriber_count", + "Number of jigasi instances that support SIP" + ) + } +} + +private fun BaseBrewery.BrewInstance.isInGracefulShutdown(): Boolean = + java.lang.Boolean.parseBoolean(status.getValueAsString(ColibriStatsExtension.SHUTDOWN_IN_PROGRESS)) +private fun BaseBrewery.BrewInstance.supportsTranscription(): Boolean = + java.lang.Boolean.parseBoolean(status.getValueAsString(ColibriStatsExtension.SUPPORTS_TRANSCRIPTION)) +private fun BaseBrewery.BrewInstance.supportsSip(): Boolean = + java.lang.Boolean.parseBoolean(status.getValueAsString(ColibriStatsExtension.SUPPORTS_SIP)) +private fun BaseBrewery.BrewInstance.isInRegion(vararg regions: String?): Boolean = + regions.contains(this.getRegion()) +private fun BaseBrewery.BrewInstance.getParticipantCount(): Int = + status.getValueAsInt(ColibriStatsExtension.PARTICIPANTS) ?: 0 +private fun BaseBrewery.BrewInstance.getRegion(): String? = + status.getValueAsString(ColibriStatsExtension.REGION) +private fun List.BrewInstance>.leastLoaded() = + minByOrNull { it.getParticipantCount() } diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/Application.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/Application.kt new file mode 100644 index 0000000..1172b3f --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/Application.kt @@ -0,0 +1,354 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.ktor + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.parseHeaderValue +import io.ktor.serialization.jackson.jackson +import io.ktor.server.application.install +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.netty.NettyApplicationEngine +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.routing.Route +import io.ktor.server.routing.RoutingCall +import io.ktor.server.routing.RoutingRequest +import io.ktor.server.routing.get +import io.ktor.server.routing.options +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import io.ktor.server.routing.routing +import org.jitsi.health.HealthCheckService +import org.jitsi.jicofo.ConferenceRequest +import org.jitsi.jicofo.ConferenceStore +import org.jitsi.jicofo.bridgeload.BridgeNotFoundException +import org.jitsi.jicofo.bridgeload.ConferenceNotFoundException +import org.jitsi.jicofo.bridgeload.InvalidParameterException +import org.jitsi.jicofo.bridgeload.LoadRedistributor +import org.jitsi.jicofo.bridgeload.MissingParameterException +import org.jitsi.jicofo.bridgeload.MoveFailedException +import org.jitsi.jicofo.bridgeload.MoveResult +import org.jitsi.jicofo.ktor.exception.BadRequest +import org.jitsi.jicofo.ktor.exception.ExceptionHandler +import org.jitsi.jicofo.ktor.exception.Forbidden +import org.jitsi.jicofo.ktor.exception.MissingParameter +import org.jitsi.jicofo.ktor.exception.NotFound +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.jicofo.version.CurrentVersionImpl +import org.jitsi.jicofo.xmpp.ConferenceIqHandler +import org.jitsi.jicofo.xmpp.XmppCapsStats +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.jitsimeet.ConferenceIq +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.stringprep.XmppStringprepException +import java.time.Duration +import org.jitsi.jicofo.ktor.RestConfig.Companion.config as config + +class Application( + private val healthChecker: HealthCheckService?, + private val conferenceIqHandler: ConferenceIqHandler, + private val conferenceStore: ConferenceStore, + private val loadRedistributor: LoadRedistributor, + private val getStatsJson: () -> ObjectNode, + private val getDebugState: (full: Boolean, confId: EntityBareJid?) -> JsonNode +) { + private val logger = createLogger() + private val server = start() + + private fun start(): EmbeddedServer { + logger.info("Starting ktor on port ${config.port}, host ${config.host}") + return embeddedServer(Netty, port = config.port, host = config.host) { + install(ContentNegotiation) { + jackson {} + } + install(StatusPages) { + exception { call, cause -> + ExceptionHandler.handle(call, cause) + } + } + + routing { + about() + conferenceRequest() + debug() + metrics() + moveEndpoints() + pin() + rtcstats() + stats() + } + }.start(wait = false) + } + + fun stop() = server.stop() + + private fun Route.metrics() { + if (config.enablePrometheus) { + get("/metrics") { + val accepts = + parseHeaderValue(call.request.headers["Accept"]).sortedByDescending { it.quality }.map { it.value } + val (metrics, contentType) = JicofoMetricsContainer.instance.getMetrics(accepts) + call.respondText(metrics, contentType = ContentType.parse(contentType)) + } + } + } + + private fun Route.about() { + data class VersionInfo(val name: String? = null, val version: String? = null, val os: String? = null) + val versionInfo = VersionInfo( + CurrentVersionImpl.VERSION.applicationName, + CurrentVersionImpl.VERSION.toString(), + System.getProperty("os.name") + ) + + route("/about") { + get("version") { + call.respond(versionInfo) + } + healthChecker?.let { + get("health") { + healthChecker.result.let { + if (it.success) { + call.respond("OK") + } else { + val status = it.responseCode ?: if (it.hardFailure) 500 else 503 + call.respondText(ContentType.Text.Plain, HttpStatusCode.fromValue(status)) { + it.message ?: "Unknown error." + } + } + } + } + } + } + } + + private fun Route.conferenceRequest() { + if (config.enableConferenceRequest) { + options("/conference-request/v1") { + call.respond(HttpStatusCode.OK) + } + post("/conference-request/v1") { + val request = try { + call.receive() + } catch (e: Exception) { + throw BadRequest(e.message) + } + + val token = call.request.getToken() + + val response: IQ = try { + conferenceIqHandler.handleConferenceIq(request.toConferenceIq(token)) + } catch (e: XmppStringprepException) { + throw BadRequest("Invalid room name: ${e.message}") + } catch (e: Exception) { + logger.error(e.message, e) + throw BadRequest(e.message) + } + + response.error?.let { + throw when (it.condition) { + StanzaError.Condition.not_authorized -> Forbidden() + StanzaError.Condition.not_acceptable -> BadRequest("invalid-session") + else -> BadRequest(it.toString()) + } + } + if (response !is ConferenceIq) { + throw InternalError() + } + call.respond(ConferenceRequest.fromConferenceIq(response)) + } + } + } + + private fun Route.rtcstats() { + get("/rtcstats") { + val rtcstats = JsonNodeFactory.instance.objectNode() + conferenceStore.getAllConferences().forEach { conference -> + if (conference.includeInStatistics() && conference.isRtcStatsEnabled) { + conference.meetingId?.let { meetingId -> + rtcstats.set(meetingId, conference.rtcstatsState) + } + } + } + + call.respondJson(rtcstats) + } + } + + private fun Route.moveEndpoints() { + if (config.enableMoveEndpoints) { + route("/move-endpoints") { + get("move-endpoint") { + call.respond( + translateException { + loadRedistributor.moveEndpoint( + call.request.queryParameters["conference"], + call.request.queryParameters["endpoint"], + call.request.queryParameters["bridge"], + ) + } + ) + } + get("move-endpoints") { + call.respond( + translateException { + loadRedistributor.moveEndpoints( + call.request.queryParameters["bridge"], + call.request.queryParameters["conference"], + call.request.queryParameters["numEndpoints"]?.toInt() ?: 1 + ) + } + ) + } + get("move-fraction") { + call.respond( + translateException { + loadRedistributor.moveFraction( + call.request.queryParameters["bridge"], + call.request.queryParameters["fraction"]?.toDouble() ?: 0.1 + ) + } + ) + } + } + } + } + + private fun Route.debug() { + if (config.enableDebug) { + route("/debug") { + get("") { + call.respondJson( + getDebugState(call.request.queryParameters["full"] == "true", null) + ) + } + get("conferences") { + val conferencesJson = JsonNodeFactory.instance.arrayNode().apply { + conferenceStore.getAllConferences().forEach { add(it.roomName.toString()) } + } + call.respondJson(conferencesJson) + } + get("conferences-full") { + val conferencesJson = JsonNodeFactory.instance.objectNode().apply { + conferenceStore.getAllConferences().forEach { + set(it.roomName.toString(), it.debugState) + } + } + call.respondJson(conferencesJson) + } + get("/conference/{conference}") { + val conference = call.parameters["conference"] ?: throw MissingParameter("conference") + val conferenceJid = try { + JidCreate.entityBareFrom(conference) + } catch (e: Exception) { + throw BadRequest("Invalid conference ID") + } + call.respondJson( + getDebugState(true, conferenceJid) + ) + } + get("xmpp-caps") { + call.respondJson(XmppCapsStats.stats) + } + } + } + } + + private fun Route.pin() { + data class PinJson(val conferenceId: String, val jvbVersion: String, val durationMinutes: Int) + data class UnpinJson(val conferenceId: String) + + if (config.pinEnabled) { + route("/pin") { + get("") { + call.respond(conferenceStore.getPinnedConferences()) + } + post("") { + val pin = try { + call.receive() + } catch (e: Exception) { + throw BadRequest(e.message) + } + val conferenceJid = try { + JidCreate.entityBareFrom(pin.conferenceId) + } catch (e: Exception) { + throw BadRequest("Invalid conference ID") + } + + conferenceStore.pinConference( + conferenceJid, + pin.jvbVersion, + Duration.ofMinutes(pin.durationMinutes.toLong()) + ) + call.respond(HttpStatusCode.OK) + } + post("/remove") { + val unpin = call.receive() + val conferenceJid = try { + JidCreate.entityBareFrom(unpin.conferenceId) + } catch (e: Exception) { + throw BadRequest("Invalid conference ID") + } + + conferenceStore.unpinConference(conferenceJid) + call.respond(HttpStatusCode.OK) + } + } + } + } + + private fun Route.stats() { + get("/stats") { + call.respondJson(getStatsJson()) + } + } +} + +private suspend fun RoutingCall.respondJson(json: JsonNode) { + respondText(ContentType.Application.Json, HttpStatusCode.OK) { json.toString() } +} + +private fun translateException(block: () -> MoveResult): MoveResult = try { + block() +} catch (e: MoveFailedException) { + throw when (e) { + is BridgeNotFoundException -> NotFound("Bridge not found") + is ConferenceNotFoundException -> NotFound("Conference not found") + is MissingParameterException, is InvalidParameterException -> BadRequest(e.message) + } +} + +private fun RoutingRequest.getToken(): String? = this.headers["Authorization"]?.let { + if (it.startsWith("Bearer ")) { + it.substring("Bearer ".length) + } else { + it + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/RestConfig.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/RestConfig.kt new file mode 100644 index 0000000..bee960d --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/RestConfig.kt @@ -0,0 +1,62 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.ktor + +import org.jitsi.config.JitsiConfig +import org.jitsi.metaconfig.config + +class RestConfig private constructor() { + val port: Int by config { + "jicofo.rest.port".from(JitsiConfig.newConfig) + } + + val host: String by config { + "jicofo.rest.host".from(JitsiConfig.newConfig) + } + + private val enabledProp: Boolean by config { + "jicofo.rest.enabled".from(JitsiConfig.newConfig) + } + + val enabled = enabledProp && port > 0 + + val enablePrometheus: Boolean by config { + "jicofo.rest.prometheus.enabled".from(JitsiConfig.newConfig) + } + + val enableConferenceRequest: Boolean by config { + "jicofo.rest.conference-request.enabled".from(JitsiConfig.newConfig) + } + + val enableMoveEndpoints: Boolean by config { + "jicofo.rest.move-endpoints.enabled".from(JitsiConfig.newConfig) + } + + val enableDebug: Boolean by config { + "jicofo.rest.debug.enabled".from(JitsiConfig.newConfig) + } + + val pinEnabled: Boolean by config { + "jicofo.rest.pin.enabled".from(JitsiConfig.newConfig) + } + + companion object { + @JvmField + val config = RestConfig() + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/exception/Exceptions.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/exception/Exceptions.kt new file mode 100644 index 0000000..559574f --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/exception/Exceptions.kt @@ -0,0 +1,48 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.ktor.exception + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respondText + +sealed class JicofoKtorException(message: String?) : RuntimeException(message) +open class BadRequest(message: String? = null) : JicofoKtorException("Bad request: ${message ?: ""}") +class NotFound(message: String?) : JicofoKtorException("Not found: ${message ?: ""}") +class Forbidden(message: String? = null) : JicofoKtorException("Forbidden: ${message ?: ""}") +class InternalError(message: String? = null) : JicofoKtorException("Internal error: ${message ?: ""}") + +@SuppressFBWarnings(value = ["NP_METHOD_PARAMETER_TIGHTENS_ANNOTATION"], justification = "False positive") +class MissingParameter(parameter: String) : BadRequest("Missing parameter: $parameter") + +object ExceptionHandler { + suspend fun handle(call: ApplicationCall, cause: Throwable) { + when (cause) { + is BadRequest -> call.respond(HttpStatusCode.BadRequest, cause.message) + is Forbidden -> call.respond(HttpStatusCode.Forbidden) + is InternalError -> call.respond(HttpStatusCode.InternalServerError, cause.message) + else -> call.respond(HttpStatusCode.InternalServerError, cause.message) + } + } + + private suspend fun ApplicationCall.respond(status: HttpStatusCode, message: String? = null) { + respondText(ContentType.Text.Plain, status) { message ?: "Error" } + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/util/Cancelable.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/util/Cancelable.kt new file mode 100644 index 0000000..42b96cf --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/util/Cancelable.kt @@ -0,0 +1,22 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +interface Cancelable { + fun cancel() +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/util/OfferOptionsUtil.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/util/OfferOptionsUtil.kt new file mode 100644 index 0000000..481a19c --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/util/OfferOptionsUtil.kt @@ -0,0 +1,31 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import org.jitsi.jicofo.codec.OfferOptions +import org.jitsi.jicofo.conference.Participant + +fun OfferOptions.applyConstraints(participant: Participant) { + audio = audio && participant.hasAudioSupport() + video = video && participant.hasVideoSupport() + sctp = sctp && participant.hasSctpSupport() + rtx = rtx && participant.hasRtxSupport() + remb = remb && participant.hasRembSupport() + tcc = tcc && participant.hasTccSupport() + opusRed = opusRed && participant.hasOpusRedSupport() +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/util/PreferenceAggregator.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/util/PreferenceAggregator.kt new file mode 100644 index 0000000..85687c9 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/util/PreferenceAggregator.kt @@ -0,0 +1,151 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger + +/** Aggregate lists of preferences coming from a large group of people, such that the resulting aggregated + * list consists of preference items supported by everyone, and in a rough consensus of preference order. + * + * The intended use case is maintaining the list of supported codecs for conference visitors. + * + * Preference orders are aggregated using the Borda count; this isn't theoretically optimal, but it should be + * good enough, and it's computationally cheap. + */ +class PreferenceAggregator( + parentLogger: Logger, + private val onChanged: (List) -> Unit +) { + private val logger = createChildLogger(parentLogger) + private val lock = Any() + + var aggregate: List = emptyList() + private set + + var count = 0 + private set + + private val values = mutableMapOf() + + /** + * Add a preference to the aggregator. + */ + fun addPreference(prefs: List) { + val distinctPrefs = prefs.distinct() + if (distinctPrefs != prefs) { + logger.warn("Preferences $prefs contains repeated values") + } + synchronized(lock) { + count++ + distinctPrefs.forEachIndexed { index, element -> + val info = values.computeIfAbsent(element) { ValueInfo() } + info.count++ + info.rankAggregate += index + } + updateAggregate() + } + } + + /** + * Remove a preference from the aggregator. + */ + fun removePreference(prefs: List) { + val distinctPrefs = prefs.distinct() + if (distinctPrefs != prefs) { + logger.warn("Preferences $prefs contains repeated values") + } + synchronized(lock) { + count-- + check(count >= 0) { + "Preference count $count should not be negative" + } + distinctPrefs.forEachIndexed { index, element -> + val info = values[element] + check(info != null) { + "Preference info for $element should exist when preferences are being removed" + } + info.count-- + check(info.count >= 0) { + "Preference count for $element ${info.count} should not be negative" + } + info.rankAggregate -= index + check(info.rankAggregate >= 0) { + "Preference rank aggregate for $element ${info.rankAggregate} should not be negative" + } + if (info.count == 0) { + check(info.rankAggregate == 0) { + "Preference rank aggregate for $element ${info.rankAggregate} should be zero " + + "when preference count is 0" + } + values.remove(element) + } + } + updateAggregate() + } + } + + fun reset() { + synchronized(lock) { + aggregate = emptyList() + count = 0 + values.clear() + } + } + + fun debugState(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + synchronized(lock) { + put("count", count) + set( + "ranks", + JsonNodeFactory.instance.objectNode().apply { + this@PreferenceAggregator.values.asSequence() + .sortedBy { it.value.rankAggregate } + .forEach { set(it.key, it.value.debugState()) } + } + ) + set("aggregate", JsonNodeFactory.instance.arrayNode().apply { aggregate.forEach { add(it) } }) + } + } + + private fun updateAggregate() { + val newAggregate = values.asSequence() + .filter { it.value.count == count } + .sortedBy { it.value.rankAggregate } + .map { it.key } + .toList() + if (aggregate != newAggregate) { + aggregate = newAggregate + /* ?? Do we need to drop the lock before calling this? */ + onChanged(aggregate) + } + } + + private class ValueInfo { + var count = 0 + var rankAggregate = 0 + + fun debugState(): ObjectNode = JsonNodeFactory.instance.objectNode().apply { + put("count", count) + put("rank_aggregate", rankAggregate) + } + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/util/RateLimitedStat.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/util/RateLimitedStat.kt new file mode 100644 index 0000000..d47678b --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/util/RateLimitedStat.kt @@ -0,0 +1,107 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import org.jitsi.jicofo.TaskPools.Companion.scheduledPool +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit + +/** A statistic whose value is only reported at a limited rate. */ +class RateLimitedStat +@JvmOverloads +constructor( + private val changeInterval: Duration, + private val onChanged: (Int) -> Unit, + initialValue: Int = 0, + private val clock: Clock = Clock.systemUTC() +) { + private val lock = Any() + + private var _value = initialValue + + private var lastChanged: Instant? = null + + private var updateTask: Future<*>? = null + + var value: Int + get() = synchronized(lock) { _value } + set(newValue) { + val report: Boolean + synchronized(lock) { + _value = newValue + + report = valueUpdated() + } + + if (report) { + reportChanged() + } + } + + fun adjustValue(delta: Int) { + val report: Boolean + synchronized(lock) { + _value += delta + + report = valueUpdated() + } + + if (report) { + reportChanged() + } + } + + /** Call this inside the synchronization block after the value is updated. If it returns true, + * call [reportChanged] immediately, *outside* the synchronization block. + */ + private fun valueUpdated(): Boolean { + val now = clock.instant() + if (updateTask != null) { + return false + } + + lastChanged?.let { + if (Duration.between(it, now) < changeInterval) { + val notificationTime = it.plus(changeInterval) + val delay = Duration.between(now, notificationTime) + updateTask = scheduledPool.schedule( + { this.reportChanged() }, + delay.toMillis(), + TimeUnit.MILLISECONDS + ) + return false + } + } + return true + } + + private fun reportChanged() { + val value: Int + synchronized(lock) { + value = this._value + lastChanged = clock.instant() + updateTask = null + } + onChanged(value) + } + + fun stop() = updateTask?.cancel(false) +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/visitors/VisitorsConfig.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/visitors/VisitorsConfig.kt new file mode 100644 index 0000000..38c60e3 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/visitors/VisitorsConfig.kt @@ -0,0 +1,55 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.visitors + +import org.jitsi.config.JitsiConfig.Companion.newConfig +import org.jitsi.metaconfig.config +import java.time.Duration + +class VisitorsConfig private constructor() { + val enabled: Boolean by config { + "jicofo.visitors.enabled".from(newConfig) + } + val maxParticipants: Int by config { + "jicofo.visitors.max-participants".from(newConfig) + } + val maxVisitorsPerNode: Int by config { + "jicofo.visitors.max-visitors-per-node".from(newConfig) + } + + val notificationInterval: Duration by config { + "jicofo.visitors.notification-interval".from(newConfig) + } + + val autoEnableBroadcast: Boolean by config { + "jicofo.visitors.auto-enable-broadcast".from(newConfig) + } + + val requireMucConfigFlag: Boolean by config { + "jicofo.visitors.require-muc-config-flag".from(newConfig) + } + + val enableLiveRoom: Boolean by config { + "jicofo.visitors.enable-live-room".from(newConfig) + } + + companion object { + @JvmField + val config = VisitorsConfig() + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/AuthenticationIqHandler.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/AuthenticationIqHandler.kt new file mode 100644 index 0000000..4d41df5 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/AuthenticationIqHandler.kt @@ -0,0 +1,101 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import org.apache.commons.lang3.StringUtils +import org.jitsi.jicofo.auth.AuthenticationAuthority +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.jitsimeet.LoginUrlIq +import org.jitsi.xmpp.extensions.jitsimeet.LogoutIq +import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler +import org.jivesoftware.smack.iqrequest.IQRequestHandler +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError + +class AuthenticationIqHandler(private val authAuthority: AuthenticationAuthority) { + private val logger = createLogger() + val loginUrlIqHandler: AbstractIqRequestHandler = LoginUrlIqHandler() + val logoutIqHandler: AbstractIqRequestHandler = LogoutIqHandler() + + private fun handleLoginUrlIq(loginUrlIq: LoginUrlIq): IQ { + val peerFullJid = loginUrlIq.from.asEntityFullJidIfPossible() + val roomName = loginUrlIq.room ?: return createNotAcceptableErrorResponse(loginUrlIq) + val popup = loginUrlIq.popup != null && loginUrlIq.popup + val machineUID = loginUrlIq.machineUID + if (StringUtils.isBlank(machineUID)) { + return createBadRequestErrorResponse(loginUrlIq, "missing mandatory attribute 'machineUID'") + } + + return LoginUrlIq().apply { + type = IQ.Type.result + stanzaId = loginUrlIq.stanzaId + to = loginUrlIq.from + url = authAuthority.createLoginUrl(machineUID, peerFullJid, roomName, popup) + logger.info("Sending url: ${toXML()}") + } + } + + private fun handleLogoutIq(logoutIq: LogoutIq): IQ = authAuthority.processLogoutIq(logoutIq) + + private inner class LoginUrlIqHandler : AbstractIqRequestHandler( + LoginUrlIq.ELEMENT, + LoginUrlIq.NAMESPACE, + IQ.Type.get, + IQRequestHandler.Mode.sync + ) { + override fun handleIQRequest(iqRequest: IQ): IQ = if (iqRequest is LoginUrlIq) { + // If the IQ comes from mod_client_proxy, parse and substitute the original sender's JID. + val originalFrom = iqRequest.from + iqRequest.from = parseJidFromClientProxyJid(XmppConfig.client.clientProxy, originalFrom) + handleLoginUrlIq(iqRequest).also { + it.to = originalFrom + } + } else { + logger.error("Received an unexpected IQ type: $iqRequest") + createInternalServerErrorResponse(iqRequest) + } + } + + private inner class LogoutIqHandler : AbstractIqRequestHandler( + LogoutIq.ELEMENT, + LogoutIq.NAMESPACE, + IQ.Type.set, + IQRequestHandler.Mode.sync + ) { + override fun handleIQRequest(iqRequest: IQ): IQ = if (iqRequest is LogoutIq) { + // If the IQ comes from mod_client_proxy, parse and substitute the original sender's JID. + val originalFrom = iqRequest.from + iqRequest.from = parseJidFromClientProxyJid(XmppConfig.client.clientProxy, originalFrom) + handleLogoutIq(iqRequest).also { + it.to = originalFrom + } + } else { + logger.error("Received an unexpected IQ type: $iqRequest") + createInternalServerErrorResponse(iqRequest) + } + } + + private fun createErrorResponse(iq: IQ, condition: StanzaError.Condition): IQ = + IQ.createErrorResponse(iq, StanzaError.getBuilder(condition).build()) + private fun createNotAcceptableErrorResponse(iq: IQ): IQ = + createErrorResponse(iq, StanzaError.Condition.not_acceptable) + private fun createBadRequestErrorResponse(iq: IQ, message: String): IQ = + IQ.createErrorResponse(iq, StanzaError.from(StanzaError.Condition.bad_request, message).build()) + private fun createInternalServerErrorResponse(iq: IQ): IQ = + IQ.createErrorResponse(iq, StanzaError.Condition.internal_server_error) +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/AvModerationHandler.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/AvModerationHandler.kt new file mode 100644 index 0000000..fccdf09 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/AvModerationHandler.kt @@ -0,0 +1,130 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.ConferenceStore +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.jitsimeet.JsonMessageExtension +import org.jivesoftware.smack.StanzaListener +import org.jivesoftware.smack.filter.MessageTypeFilter +import org.jivesoftware.smack.packet.Stanza +import org.jxmpp.jid.DomainBareJid +import org.jxmpp.jid.impl.JidCreate +import java.lang.IllegalArgumentException + +/** + * Adds the A/V moderation handling. Process incoming messages and when audio or video moderation is enabled, + * muted all participants in the meeting (that are not moderators). Moderators are always allowed to unmute. + */ +class AvModerationHandler( + private val xmppProvider: XmppProvider, + private val conferenceStore: ConferenceStore +) : XmppProvider.Listener, StanzaListener { + private var avModerationAddress: DomainBareJid? = null + private val logger = createLogger() + + init { + xmppProvider.xmppConnection.addSyncStanzaListener(this, MessageTypeFilter.NORMAL) + xmppProvider.addListener(this) + registrationChanged(xmppProvider.registered) + componentsChanged(xmppProvider.components) + } + + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("address", avModerationAddress.toString()) + } + + override fun processStanza(stanza: Stanza) { + if (stanza.from != avModerationAddress) { + return + } + + val jsonMessage = stanza.getExtension( + JsonMessageExtension::class.java + ) ?: return Unit.also { + logger.warn("Skip processing stanza without JsonMessageExtension") + } + + val message = try { + val m = JsonMessage.parse(jsonMessage.json) + if (m !is AvModerationMessage) { + throw IllegalArgumentException("Expected AvModerationMessage, got ${m.type}") + } + m + } catch (e: Exception) { + logger.warn("Failed to process av_moderation request from ${stanza.from}", e) + return + } + + val (conference, conferenceJid, chatRoom) = try { + val conferenceJid = JidCreate.entityBareFrom(message.room) + val conference = conferenceStore.getConference(conferenceJid) + ?: throw IllegalStateException("Conference $conferenceJid does not exist.") + + Triple( + conference, + conferenceJid, + conference.chatRoom ?: throw IllegalStateException("Conference has no associated chatRoom.") + ) + } catch (e: Exception) { + logger.warn("Failed to process av_moderation request from ${stanza.from}", e) + return + } + + chatRoom.queueXmppTask { + val mediaType = message.mediaType + val enabled = message.enabled + message.whitelists?.let { whitelists -> + whitelists.forEach { (mediaType, whitelist) -> + chatRoom.setAvModerationWhitelist(mediaType, whitelist) + } + } + if (enabled != null && mediaType != null) { + val wasEnabled = chatRoom.isAvModerationEnabled(mediaType) + chatRoom.setAvModerationEnabled(mediaType, enabled) + if (enabled && !wasEnabled) { + logger.info("Moderation for ${message.mediaType} in $conferenceJid was enabled by ${message.actor}") + // let's mute everyone except the actor + conference.muteAllParticipants( + mediaType, + JidCreate.entityFullFrom(message.actor ?: "") + ) + } + } + } + } + + override fun componentsChanged(components: Set) { + val address = components.find { it.type == "av_moderation" }?.address + + avModerationAddress = if (address == null) { + logger.info("No av_moderation component discovered.") + null + } else { + logger.info("Using av_moderation component at $address.") + JidCreate.domainBareFrom(address) + } + } + + fun shutdown() { + xmppProvider.xmppConnection.removeSyncStanzaListener(this) + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/ConferenceIqHandler.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/ConferenceIqHandler.kt new file mode 100644 index 0000000..f2415aa --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/ConferenceIqHandler.kt @@ -0,0 +1,308 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.api.trace.Tracer +import org.jitsi.jicofo.FocusManager +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.auth.AuthenticationAuthority +import org.jitsi.jicofo.auth.ErrorFactory +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.jwt.JitsiToken +import org.jitsi.tracing.TracingGlobal.Companion.sdk +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.jitsimeet.ConferenceIq +import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler +import org.jivesoftware.smack.iqrequest.IQRequestHandler +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import org.jxmpp.jid.DomainBareJid +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import java.lang.Boolean.parseBoolean +import java.util.* +import org.jitsi.jicofo.visitors.VisitorsConfig.Companion.config as visitorsConfig + +/** + * Handles XMPP requests for a new conference ([ConferenceIq]). + */ +class ConferenceIqHandler( + val xmppProvider: XmppProvider, + val focusManager: FocusManager, + val focusAuthJid: String, + val authAuthority: AuthenticationAuthority?, + val jigasiEnabled: Boolean, + val visitorsManager: VisitorsManager +) : XmppProvider.Listener, AbstractIqRequestHandler( + ConferenceIq.ELEMENT, + ConferenceIq.NAMESPACE, + IQ.Type.set, + IQRequestHandler.Mode.sync +) { + private val connection = xmppProvider.xmppConnection + private var breakoutAddress: DomainBareJid? = null + private val logger = createLogger() + private val tracer: Tracer = sdk.getTracer("org.jitsi.jicofo.xmpp") + + init { + xmppProvider.addListener(this) + registrationChanged(xmppProvider.registered) + componentsChanged(xmppProvider.components) + } + + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("breakout_address", breakoutAddress.toString()) + put("focus_auth_jid", focusAuthJid) + put("jigasi_enabled", jigasiEnabled) + put("auth_authority", authAuthority?.javaClass?.simpleName ?: "null") + } + + /** Handle a [ConferenceIq] synchronously and return a response. */ + fun handleConferenceIq(query: ConferenceIq): IQ { + val span = tracer.spanBuilder("xmpp.conference") + .setAttribute("client.id", Objects.toString(query.from)) + .setAttribute("room.id", Objects.toString(query.room)) + .startSpan() + try { + span.makeCurrent().use { + return doHandleConferenceIq(query) + } + } catch (e: Throwable) { + span.setStatus(StatusCode.ERROR, e.message ?: "") + throw e + } finally { + span.end() + } + } + + private fun doHandleConferenceIq(query: ConferenceIq): IQ { + val room = query.room ?: return IQ.createErrorResponse( + query, + StanzaError.from(StanzaError.Condition.bad_request, "No 'room' specified.").build() + ) + val token = parseToken(query.token) + val userId = token?.context?.user?.id + val groupId = token?.context?.group + + val response = ConferenceIq().apply { + type = IQ.Type.result + stanzaId = query.stanzaId + from = query.to + to = query.from + this.room = query.room + focusJid = focusAuthJid + + if (authAuthority != null) { + addProperty(ConferenceIq.Property("authentication", "true")) + addProperty(ConferenceIq.Property("externalAuth", authAuthority.isExternal.toString())) + } else { + addProperty(ConferenceIq.Property("authentication", "false")) + } + + if (jigasiEnabled) { + addProperty(ConferenceIq.Property("sipGatewayEnabled", "true")) + } + } + + logger.info("Conference request for room $room, from ${query.from}, token=${query.token != null}") + logger.debug { "User ID: $userId, Group ID: $groupId" } + conferenceRequestCounter.inc() + var conference = focusManager.getConference(room) + val roomExists = conference != null + if (!roomExists) { + newConferenceRequestCounter.inc() + if (!parseBoolean(query.propertiesMap.getOrDefault("rtcstatsEnabled", "true"))) { + logger.info("New room with rtcstats disabled: $room") + } + } + + // Authentication logic + val error: IQ? = processExtensions(query, room, response, roomExists) + if (error != null) { + return error + } + + val visitorSupported = query.properties.any { it.name == "visitors-version" } + val visitorRequested = query.properties.any { it.name == "visitor" && it.value == "true" } + var visitorsLive = conference?.chatRoom?.visitorsLive ?: false + logger.debug { + "visitorSupported=$visitorSupported, visitorRequested=$visitorRequested, visitorsLive=$visitorsLive" + } + // Here we return early without creating a conference. + if (visitorSupported && visitorRequested && visitorsConfig.enableLiveRoom && !visitorsLive) { + logger.debug("Sending to queue") + response.isReady = false + response.addProperty(ConferenceIq.Property("live", "false")) + return response + } + + // If the conference didn't exist previously, it will be created and the MUC will be joined here (blocking). + conference = focusManager.conferenceRequest(room, query.propertiesMap) + response.isReady = conference.isStarted && (conference.chatRoom?.isJoined == true) + + // We've now joined the MUC and room metadata has been set. + visitorsLive = conference.chatRoom?.visitorsLive ?: false + val visitorsSupported = visitorsManager.enabled && + (conference.chatRoom?.visitorsEnabled == true || !visitorsConfig.requireMucConfigFlag) + response.addProperty("visitors-supported", visitorsSupported.toString()) + val allowedInMainRoom = conference.chatRoom?.isAllowedInMainRoom(userId, groupId) == true + val preferredInMainRoom = conference.chatRoom?.isPreferredInMainRoom(userId, groupId) == true + + logger.debug { + "allowedInMainRoom=$allowedInMainRoom, preferredInMainRoom=$preferredInMainRoom, visitorsLive=$visitorsLive" + } + if (visitorsConfig.enableLiveRoom && !allowedInMainRoom && !visitorsLive && visitorSupported) { + logger.debug("Sending to queue") + response.isReady = false + response.addProperty(ConferenceIq.Property("live", "false")) + return response + } + + val vnode = if (visitorSupported && visitorsManager.enabled && (visitorRequested || !preferredInMainRoom)) { + conference.redirectVisitor( + visitorRequested || !allowedInMainRoom, + userId, + groupId + ) + } else { + null + } + if (visitorsManager.enabled && !visitorSupported) { + logger.info("Endpoint with no visitor support.") + } + + XmppConfig.visitors[vnode]?.jid?.let { + logger.info("Redirecting to $vnode") + response.vnode = vnode + response.focusJid = it + } ?: run { + if (vnode != null) { + logger.error("No XmppConnectionConfig for vnode=$vnode") + } + } + + return response + } + + /** + * Additional logic added for conference IQ processing like authentication. + * + * @param query ConferenceIq query + * @param response ConferenceIq response which can be modified during this processing. + * @param roomExists true if room mentioned in the query already exists. + * + * @return null if everything went ok or an error/response IQ + * which should be returned to the user + */ + private fun processExtensions( + query: ConferenceIq, + room: EntityBareJid, + response: ConferenceIq?, + roomExists: Boolean + ): IQ? { + val isBreakoutRoom = breakoutAddress != null && room.domain == breakoutAddress + + // Authentication. We do not perform authentication for breakout rooms, expecting the breakout room prosody + // module to handle it. + if (!isBreakoutRoom && authAuthority != null) { + val authErrorOrResponse = authAuthority.processAuthentication(query, response) + + // Checks if authentication module wants to cancel further + // processing and eventually returns its response + if (authErrorOrResponse != null) { + return authErrorOrResponse + } + // Only authenticated users are allowed to create new rooms + if (!roomExists) { + // If an associated breakout room exists and all members have left the main room, skip + // authentication for the main room so users can go back to it. + if (!focusManager.hasBreakoutRooms(room) && authAuthority.getUserIdentity(query.from) == null) { + // Error not authorized + return ErrorFactory.createNotAuthorizedError(query, "not authorized user domain") + } + } + } + + return null + } + + override fun handleIQRequest(iqRequest: IQ?): IQ? { + if (iqRequest !is ConferenceIq) { + return IQ.createErrorResponse( + iqRequest, + StanzaError.getBuilder(StanzaError.Condition.internal_server_error).build() + ).also { + logger.error("Received an unexpected IQ type: $iqRequest") + } + } + + // If the IQ comes from mod_client_proxy, parse and substitute the original sender's JID. + val originalFrom = iqRequest.from + iqRequest.from = parseJidFromClientProxyJid(XmppConfig.client.clientProxy, originalFrom) + + TaskPools.ioPool.execute { + val response = handleConferenceIq(iqRequest).apply { to = originalFrom } + + try { + connection.sendStanza(response) + } catch (e: Exception) { + logger.error("Failed to send response", e) + } + } + + return null + } + + override fun componentsChanged(components: Set) { + val address = components.find { it.type == "breakout_rooms" }?.address + + breakoutAddress = if (address == null) { + logger.info("No breakout room component discovered.") + null + } else { + logger.info("Using breakout room component at $address.") + JidCreate.domainBareFrom(address) + } + } + + companion object { + val conferenceRequestCounter = JicofoMetricsContainer.instance.registerCounter( + "conference_requests", + "Number of conference requests received." + ) + val newConferenceRequestCounter = JicofoMetricsContainer.instance.registerCounter( + "conference_requests_new", + "Number of conference requests received for new conferences." + ) + } +} + +/** + * Read context.user.id from an unparsed token. Note that no validation is performed on the token, this is intentional. + * Validation will be performed when the user attempts to login to XMPP. + */ +private fun parseToken(token: String?): JitsiToken? = try { + token?.let { return JitsiToken.parseWithoutValidation(it) } + null +} catch (e: Throwable) { + null +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/JibriIqHandler.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/JibriIqHandler.kt new file mode 100644 index 0000000..7583350 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/JibriIqHandler.kt @@ -0,0 +1,74 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2017-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import org.jitsi.jicofo.ConferenceStore +import org.jitsi.jicofo.jibri.BaseJibri +import org.jitsi.jicofo.xmpp.IqProcessingResult.AcceptedWithNoResponse +import org.jitsi.jicofo.xmpp.IqProcessingResult.AcceptedWithResponse +import org.jitsi.jicofo.xmpp.IqProcessingResult.RejectedWithError +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.jibri.JibriIq +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.iqrequest.IQRequestHandler +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError + +/** + * A Smack [IQRequestHandler] for "jibri" IQs. Terminates all "jibri" IQs received by Smack, but delegates their + * handling to specific [BaseJibri] instances. + */ +class JibriIqHandler( + connections: Set, + private val conferenceStore: ConferenceStore, + /** Whether any jibri detectors are configured (used only for logging). */ + private val hasJibriDetector: () -> Boolean = { false } +) : + AbstractIqHandler( + connections, + JibriIq.ELEMENT, + JibriIq.NAMESPACE, + setOf(IQ.Type.set), + IQRequestHandler.Mode.sync + ) { + val logger = createLogger() + + /** + * {@inheritDoc} + * Pass the request to the first [BaseJibri] that wants it. + * + * Note that this is synchronized to ensure correct use of the synchronized list (and we want to avoid using a + * copy on write list for performance reasons). + */ + override fun handleRequest(request: IqRequest): IqProcessingResult { + // TODO: we should be able to recognize the conference for a jibri IQ simply based on the `to` address. + conferenceStore.getAllConferences().forEach { conference -> + when (val result = conference.handleJibriRequest(request)) { + is AcceptedWithResponse, is AcceptedWithNoResponse, is RejectedWithError -> return result + else -> Unit // Proceed to the next conference + } + } + + // No conference accepted the request. + logger.warn("Jibri IQ not accepted by any conference: ${request.iq.toXML()}") + if (!hasJibriDetector()) { + logger.warn("No jibri detectors configured.") + } + return RejectedWithError(request, StanzaError.Condition.item_not_found) + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/JigasiIqHandler.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/JigasiIqHandler.kt new file mode 100644 index 0000000..1f42870 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/JigasiIqHandler.kt @@ -0,0 +1,272 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.ConferenceStore +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jitsi.jicofo.jigasi.JigasiDetector +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.jicofo.xmpp.IqProcessingResult.AcceptedWithNoResponse +import org.jitsi.jicofo.xmpp.IqProcessingResult.RejectedWithError +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.rayo.DialIq +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.SmackException +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import org.jivesoftware.smack.packet.id.StandardStanzaIdSource +import org.jxmpp.jid.Jid +import org.jxmpp.jid.impl.JidCreate +import java.util.concurrent.atomic.AtomicInteger + +class JigasiIqHandler( + connections: Set, + private val conferenceStore: ConferenceStore, + private val jigasiDetector: JigasiDetector +) : AbstractIqHandler( + connections, + DialIq.ELEMENT, + DialIq.NAMESPACE, + setOf(IQ.Type.set) +) { + private val logger = createLogger() + + private val stanzaIdSource = stanzaIdSourceFactory.constructStanzaIdSource() + + private val stats = Stats() + val statsJson: ObjectNode + get() = stats.toJson() + + override fun handleRequest(request: IqRequest): IqProcessingResult { + val conferenceJid = request.iq.from.asEntityBareJidIfPossible() + ?: return RejectedWithError(request, StanzaError.Condition.bad_request).also { + logger.warn("Rejected request with invalid conferenceJid: ${request.iq.from}") + Stats.rejectedRequests.inc() + } + + val conference = conferenceStore.getConference(conferenceJid) + // search for visitor room with that jid, maybe it's an invite from a visitor + ?: conferenceStore.getAllConferences().find { c -> c.visitorRoomsJids.contains(conferenceJid) } + ?: return RejectedWithError(request, StanzaError.Condition.item_not_found).also { + logger.warn("Rejected request for non-existent conference: $conferenceJid") + Stats.rejectedRequests.inc() + } + + if (!conference.acceptJigasiRequest(request.iq.from)) { + return RejectedWithError(request, StanzaError.Condition.forbidden).also { + logger.warn("Rejected request from unauthorized user: ${request.iq.from}") + Stats.rejectedRequests.inc() + } + } + + val roomNameHeader = request.iq.getHeader("JvbRoomName") + if (roomNameHeader != null && JidCreate.entityBareFrom(roomNameHeader) != conference.roomName) { + return RejectedWithError(request, StanzaError.Condition.forbidden).also { + logger.warn( + "Rejecting request with non-matching JvbRoomName: from=${request.iq.from} " + + ", roomName=${conference.roomName}, JvbRoomName=$roomNameHeader" + ) + Stats.rejectedRequests.inc() + } + } + + logger.info("Accepted jigasi request from ${request.iq.from}: ${request.iq.toXML()}") + Stats.acceptedRequests.inc() + + TaskPools.ioPool.execute { + try { + inviteJigasi(request, conference) + } catch (e: Exception) { + logger.warn("Failed to invite jigasi", e) + request.connection.tryToSendStanza( + IQ.createErrorResponse(request.iq, StanzaError.Condition.internal_server_error) + ) + } + } + return AcceptedWithNoResponse() + } + + /** + * Invites a jigasi given a specific validated [request] from a participant. Handles jigasi instance selection and + * retry logic. Sends an IQ response for the [request]. + */ + private fun inviteJigasi( + request: IqRequest, + conference: JitsiMeetConference, + retryCount: Int = 2, + exclude: List = emptyList() + ) { + val selector = if (request.iq.destination == "jitsi_meet_transcribe") { + if (conference.chatRoom?.asyncTranscription == true) { + logger.warn( + "Rejected transcriber dial-out request, room is configured for async transcription: " + + request.iq.toXML() + ) + request.connection.tryToSendStanza( + IQ.createErrorResponse( + request.iq, + StanzaError.getBuilder(StanzaError.Condition.not_allowed).build() + ) + ) + Stats.rejectedRequests.inc() + return + } + if (conference.hasTranscriber()) { + logger.warn("Request failed, transcriber already available: ${request.iq.toXML()}") + IQ.createErrorResponse( + request.iq, + StanzaError.getBuilder(StanzaError.Condition.conflict).build() + ) + return + } + jigasiDetector::selectTranscriber + } else { + jigasiDetector::selectSipJigasi + } + + // Check if Jigasi is available + val jigasiJid = selector(exclude, conference.bridgeRegions) ?: run { + logger.warn("Request failed, no instances available: ${request.iq.toXML()}") + request.connection.tryToSendStanza( + IQ.createErrorResponse( + request.iq, + StanzaError.getBuilder(StanzaError.Condition.service_unavailable).build() + ) + ) + stats.noInstanceAvailable() + return + } + + logger.info("Selected $jigasiJid (request from ${request.iq.from})") + // Forward the request to the selected Jigasi instance. + val requestToJigasi = DialIq(request.iq).apply { + from = null + to = jigasiJid + stanzaId = stanzaIdSource.newStanzaId + } + val responseFromJigasi = try { + jigasiDetector.xmppConnection.sendIqAndGetResponse(requestToJigasi) + } catch (e: SmackException.NotConnectedException) { + logger.error("Request failed, XMPP not connected: ${request.iq.toXML()}") + stats.xmppNotConnected() + return + } + + if (responseFromJigasi == null || responseFromJigasi.error != null) { + // Timeout or error. + if (responseFromJigasi == null) { + logger.warn("Jigasi instance timed out: $jigasiJid") + Stats.singleInstanceTimeouts.inc() + } else { + logger.warn("Jigasi instance returned error ($jigasiJid): ${responseFromJigasi.toXML()}") + Stats.singleInstanceErrors.inc() + } + + if (retryCount > 0) { + logger.info("Will retry up to $retryCount more times.") + Stats.retries.inc() + // Do not try the same instance again. + inviteJigasi(request, conference, retryCount - 1, exclude + jigasiJid) + } else { + val condition = if (responseFromJigasi == null) { + StanzaError.Condition.remote_server_timeout + } else { + StanzaError.Condition.undefined_condition + } + logger.warn("Request failed, all instances failed.") + stats.allInstancesFailed() + request.connection.tryToSendStanza( + IQ.createErrorResponse(request.iq, StanzaError.getBuilder(condition).build()) + ) + } + + return + } + + logger.info("Response from jigasi: ${responseFromJigasi.toXML()}") + // Successful response from Jigasi, forward it as the response to the client. + request.connection.tryToSendStanza( + responseFromJigasi.apply { + from = null + to = request.iq.from + stanzaId = request.iq.stanzaId + } + ) + } + + companion object { + private val stanzaIdSourceFactory = StandardStanzaIdSource.Factory() + } + + class Stats { + /** User requests which failed due to no jigasi instances being available. */ + private val requestsFailedNoInstanceAvailable = AtomicInteger() + + /** User requests which failed due to the jigasi XMPP connection not being connected. */ + private val requestsFailedXmppNotConnected = AtomicInteger() + + /** User requests which failed because all jigasi instances (up to the max retry count) failed. */ + private val requestsFailedAllInstancesFailed = AtomicInteger() + + fun noInstanceAvailable() = requestsFailedNoInstanceAvailable.incrementAndGet() + fun xmppNotConnected() = requestsFailedXmppNotConnected.incrementAndGet() + fun allInstancesFailed() = requestsFailedAllInstancesFailed.incrementAndGet() + + fun toJson() = statsJson().apply { + put("requests_failed_no_instance", requestsFailedNoInstanceAvailable.get()) + put("requests_failed_xmpp_not_connected", requestsFailedXmppNotConnected.get()) + } + + companion object { + private const val PREFIX = "jigasi_iq_handler" + val rejectedRequests = JicofoMetricsContainer.instance.registerCounter( + "${PREFIX}_rejected_requests", + "User requests which were rejected (e.g. not authorized, bad request)." + ) + val acceptedRequests = JicofoMetricsContainer.instance.registerCounter( + "${PREFIX}_accepted_requests", + "User requests which were accepted." + ) + val retries = JicofoMetricsContainer.instance.registerCounter( + "${PREFIX}_retries", + "Requests retried with a different jigasi instance." + ) + val singleInstanceErrors = JicofoMetricsContainer.instance.registerCounter( + "${PREFIX}_instance_errors", + "Errors received from jigasi instances." + ) + val singleInstanceTimeouts = JicofoMetricsContainer.instance.registerCounter( + "${PREFIX}_instance_timeouts", + "Timeouts for requests sent to jigasi instances." + ) + + fun statsJson() = JsonNodeFactory.instance.objectNode().apply { + put("rejected_requests", rejectedRequests.get()) + put("accepted_requests", acceptedRequests.get()) + put("retries", retries.get()) + put("instance_errors", singleInstanceErrors.get()) + put("instance_timeout", singleInstanceTimeouts.get()) + } + } + } +} + +private fun JitsiMeetConference.hasTranscriber(): Boolean = this.chatRoom?.members?.any { it.isTranscriber } ?: false diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/MuteIqHandler.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/MuteIqHandler.kt new file mode 100644 index 0000000..1c4a3cd --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/MuteIqHandler.kt @@ -0,0 +1,184 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import org.jitsi.jicofo.ConferenceStore +import org.jitsi.jicofo.MediaType +import org.jitsi.jicofo.conference.MuteResult +import org.jitsi.jicofo.xmpp.IqProcessingResult.AcceptedWithNoResponse +import org.jitsi.jicofo.xmpp.IqProcessingResult.RejectedWithError +import org.jitsi.utils.logging2.LoggerImpl +import org.jitsi.xmpp.extensions.jitsimeet.AbstractMuteIq +import org.jitsi.xmpp.extensions.jitsimeet.MuteDesktopIq +import org.jitsi.xmpp.extensions.jitsimeet.MuteIq +import org.jitsi.xmpp.extensions.jitsimeet.MuteVideoIq +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.iqrequest.IQRequestHandler +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import org.jxmpp.jid.Jid + +class AudioMuteIqHandler( + connections: Set, + private val conferenceStore: ConferenceStore +) : + AbstractIqHandler( + connections, + AbstractMuteIq.ELEMENT, + MuteIq.NAMESPACE, + setOf(IQ.Type.set), + IQRequestHandler.Mode.sync + ) { + override fun handleRequest(request: IqRequest): IqProcessingResult = handleRequest( + MuteRequest( + request.iq, + request.connection, + conferenceStore, + request.iq.mute, + request.iq.jid, + MediaType.AUDIO + ) + ) +} + +class VideoMuteIqHandler( + connections: Set, + private val conferenceStore: ConferenceStore +) : + AbstractIqHandler( + connections, + AbstractMuteIq.ELEMENT, + MuteVideoIq.NAMESPACE, + setOf(IQ.Type.set), + IQRequestHandler.Mode.sync + ) { + override fun handleRequest(request: IqRequest): IqProcessingResult = handleRequest( + MuteRequest( + request.iq, + request.connection, + conferenceStore, + request.iq.mute, + request.iq.jid, + MediaType.VIDEO + ) + ) +} + +class DesktopMuteIqHandler( + connections: Set, + private val conferenceStore: ConferenceStore +) : + AbstractIqHandler( + connections, + AbstractMuteIq.ELEMENT, + MuteDesktopIq.NAMESPACE, + setOf(IQ.Type.set), + IQRequestHandler.Mode.sync + ) { + override fun handleRequest(request: IqRequest): IqProcessingResult = handleRequest( + MuteRequest( + request.iq, + request.connection, + conferenceStore, + request.iq.mute, + request.iq.jid, + MediaType.DESKTOP + ) + ) +} + +private val logger = LoggerImpl("org.jitsi.jicofo.xmpp.MuteIqHandler") + +private fun handleRequest(request: MuteRequest): IqProcessingResult { + val jidToMute = request.jidToMute + val doMute = request.doMute + val mediaType = request.mediaType + if (doMute == null || jidToMute == null) { + // Do not use toXML() here, it throws when the mute field is missing. + logger.warn("Mute request missing required fields: from=${request.iq.from} jid=$jidToMute mute=$doMute") + return RejectedWithError(request.iq, StanzaError.Condition.bad_request) + } + + val conference = request.conferenceStore.getConference(request.iq.from.asEntityBareJidIfPossible()) + ?: return RejectedWithError(request.iq, StanzaError.Condition.item_not_found).also { + logger.warn("Mute request for unknown conference: ${request.iq.toXML()}") + } + + val chatRoom = conference.chatRoom + ?: return RejectedWithError(request.iq, StanzaError.Condition.item_not_found).also { + logger.warn("Mute request for conference with no chat room: ${request.iq.toXML()}") + } + + chatRoom.queueXmppTask { + try { + when (conference.handleMuteRequest(request.iq.from, jidToMute, doMute, mediaType)) { + MuteResult.SUCCESS -> { + request.connection.tryToSendStanza(IQ.createResultIQ(request.iq)) + // If this was a remote mute, notify the participant that was muted. + if (request.iq.from != request.jidToMute) { + request.connection.tryToSendStanza( + createMuteIq(mediaType).apply { + actor = request.iq.from + type = IQ.Type.set + to = request.jidToMute + mute = request.doMute + } + ) + } + } + + MuteResult.NOT_ALLOWED -> request.connection.tryToSendStanza( + IQ.createErrorResponse( + request.iq, + StanzaError.getBuilder(StanzaError.Condition.not_allowed).build() + ) + ) + + MuteResult.ERROR -> request.connection.tryToSendStanza( + IQ.createErrorResponse( + request.iq, + StanzaError.getBuilder(StanzaError.Condition.internal_server_error).build() + ) + ) + } + } catch (e: Exception) { + logger.warn("Failed to handle mute request: ${request.iq.toXML()}", e) + request.connection.tryToSendStanza( + IQ.createErrorResponse(request.iq, StanzaError.Condition.internal_server_error) + ) + } + } + + return AcceptedWithNoResponse() +} + +private data class MuteRequest( + val iq: IQ, + val connection: AbstractXMPPConnection, + val conferenceStore: ConferenceStore, + val doMute: Boolean?, + val jidToMute: Jid?, + val mediaType: MediaType +) + +fun createMuteIq(mediaType: MediaType) = when (mediaType) { + MediaType.AUDIO -> MuteIq() + MediaType.VIDEO -> MuteVideoIq() + MediaType.DESKTOP -> MuteDesktopIq() + else -> throw IllegalArgumentException("Unsupported media type: $mediaType") +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/RoomMetadataHandler.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/RoomMetadataHandler.kt new file mode 100644 index 0000000..42c87fa --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/RoomMetadataHandler.kt @@ -0,0 +1,101 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2024-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.ConferenceStore +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.jitsimeet.JsonMessageExtension +import org.jivesoftware.smack.StanzaListener +import org.jivesoftware.smack.filter.MessageTypeFilter +import org.jivesoftware.smack.packet.Stanza +import org.jxmpp.jid.DomainBareJid +import org.jxmpp.jid.impl.JidCreate + +class RoomMetadataHandler( + private val xmppProvider: XmppProvider, + private val conferenceStore: ConferenceStore +) : XmppProvider.Listener, StanzaListener { + private var componentAddress: DomainBareJid? = null + private val logger = createLogger() + + init { + xmppProvider.xmppConnection.addSyncStanzaListener(this, MessageTypeFilter.NORMAL) + xmppProvider.addListener(this) + registrationChanged(xmppProvider.registered) + componentsChanged(xmppProvider.components) + } + + val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("address", componentAddress.toString()) + } + + private fun doProcess(jsonMessage: JsonMessageExtension) { + val (chatRoom, roomMetadata) = try { + val conferenceJid = JidCreate.entityBareFrom(jsonMessage.getAttribute("room")?.toString()) + val roomMetadata = JsonMessage.parse(jsonMessage.json) + + if (roomMetadata !is RoomMetadata) { + throw IllegalArgumentException("Received invalid message type: ${roomMetadata.type}") + } + + val conference = conferenceStore.getConference(conferenceJid) + ?: throw IllegalStateException("Conference $conferenceJid does not exist.") + + Pair( + conference.chatRoom ?: throw IllegalStateException("Conference has no associated chatRoom."), + roomMetadata + ) + } catch (e: Exception) { + logger.info("Failed to process room_metadata request for room=${jsonMessage.getAttribute("room")}: $e") + return + } + + chatRoom.setRoomMetadata(roomMetadata) + } + + override fun processStanza(stanza: Stanza) { + if (stanza.from != componentAddress) { + return + } + + val jsonMessage = stanza.getExtension(JsonMessageExtension::class.java) ?: return Unit.also { + logger.warn("Skip processing stanza without JsonMessageExtension.") + } + + doProcess(jsonMessage) + } + + override fun componentsChanged(components: Set) { + val address = components.find { it.type == "room_metadata" }?.address + + componentAddress = if (address == null) { + logger.info("No room_metadata component discovered.") + null + } else { + logger.info("Using room_metadata component at $address.") + JidCreate.domainBareFrom(address) + } + } + + fun shutdown() { + xmppProvider.xmppConnection.removeSyncStanzaListener(this) + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/Smack.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/Smack.kt new file mode 100644 index 0000000..556edb8 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/Smack.kt @@ -0,0 +1,169 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import org.jitsi.xmpp.extensions.DefaultPacketExtensionProvider +import org.jitsi.xmpp.extensions.clientrequirements.ClientRequirementsIq +import org.jitsi.xmpp.extensions.colibri.ColibriStatsIqProvider +import org.jitsi.xmpp.extensions.colibri.ForcefulShutdownIqProvider +import org.jitsi.xmpp.extensions.colibri.GracefulShutdownIqProvider +import org.jitsi.xmpp.extensions.colibri2.IqProviderUtils +import org.jitsi.xmpp.extensions.health.HealthCheckIQProvider +import org.jitsi.xmpp.extensions.health.HealthStatusPacketExt +import org.jitsi.xmpp.extensions.jibri.JibriBusyStatusPacketExt +import org.jitsi.xmpp.extensions.jibri.JibriIq +import org.jitsi.xmpp.extensions.jibri.JibriIqProvider +import org.jitsi.xmpp.extensions.jibri.JibriStatusPacketExt +import org.jitsi.xmpp.extensions.jingle.JingleIQ +import org.jitsi.xmpp.extensions.jingle.JingleIQProvider +import org.jitsi.xmpp.extensions.jitsimeet.AudioMutedExtension +import org.jitsi.xmpp.extensions.jitsimeet.BridgeSessionPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.ClientVersionPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.ConferenceIqProvider +import org.jitsi.xmpp.extensions.jitsimeet.FeatureExtension +import org.jitsi.xmpp.extensions.jitsimeet.FeaturesExtension +import org.jitsi.xmpp.extensions.jitsimeet.IceStatePacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantCodecList +import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantRegionPacketExtension +import org.jitsi.xmpp.extensions.jitsimeet.JsonMessageExtension +import org.jitsi.xmpp.extensions.jitsimeet.LoginUrlIqProvider +import org.jitsi.xmpp.extensions.jitsimeet.LogoutIqProvider +import org.jitsi.xmpp.extensions.jitsimeet.StartMutedProvider +import org.jitsi.xmpp.extensions.jitsimeet.StatsId +import org.jitsi.xmpp.extensions.jitsimeet.UserInfoPacketExt +import org.jitsi.xmpp.extensions.jitsimeet.VideoMutedExtension +import org.jitsi.xmpp.extensions.rayo.RayoIqProvider +import org.jivesoftware.smack.ReconnectionManager +import org.jivesoftware.smack.SASLAuthentication +import org.jivesoftware.smack.SmackConfiguration +import org.jivesoftware.smack.provider.ProviderManager +import org.jivesoftware.smack.tcp.XMPPTCPConnection +import org.jivesoftware.smackx.caps.EntityCapsManager + +fun initializeSmack() { + org.jitsi.xmpp.Smack.initialize(useJitsiXmppStringprep = XmppConfig.config.useJitsiJidValidation) + + EntityCapsManager.setDefaultEntityNode("http://jitsi.org/jicofo") + + // Default to 15 seconds, may be overriden by the different connections. + SmackConfiguration.setDefaultReplyTimeout(15000) + + ReconnectionManager.setEnabledPerDefault(true) + // Jicofo handles at most two connections and most of the time that is localhost and the number + // of jicofo instances is small so we can afford to retry quickly. + ReconnectionManager.setDefaultFixedDelay(2) + + // Smack uses SASL Mechanisms ANONYMOUS and PLAIN, but tries to authenticate with GSSAPI when it's offered + // by the server. Disable GSSAPI. + SASLAuthentication.unregisterSASLMechanism("org.jivesoftware.smack.sasl.javax.SASLGSSAPIMechanism") + + // Enable Stream Management + XMPPTCPConnection.setUseStreamManagementResumptionDefault(true) + XMPPTCPConnection.setUseStreamManagementDefault(true) + + registerXmppExtensions() +} + +fun registerXmppExtensions() { + // Constructors called to register extension providers + ConferenceIqProvider() + LoginUrlIqProvider() + LogoutIqProvider() + GracefulShutdownIqProvider.registerIQProvider() + ForcefulShutdownIqProvider.registerIQProvider() + ColibriStatsIqProvider() + HealthCheckIQProvider.registerIQProvider() + // ice-state + ProviderManager.addExtensionProvider( + IceStatePacketExtension.ELEMENT, + IceStatePacketExtension.NAMESPACE, + DefaultPacketExtensionProvider(IceStatePacketExtension::class.java) + ) + // bridge-session + ProviderManager.addExtensionProvider( + BridgeSessionPacketExtension.ELEMENT, + BridgeSessionPacketExtension.NAMESPACE, + DefaultPacketExtensionProvider(BridgeSessionPacketExtension::class.java) + ) + // Jibri IQs + ProviderManager.addIQProvider(JibriIq.ELEMENT, JibriIq.NAMESPACE, JibriIqProvider()) + JibriStatusPacketExt.registerExtensionProvider() + JibriBusyStatusPacketExt.registerExtensionProvider() + HealthStatusPacketExt.registerExtensionProvider() + // User info + ProviderManager.addExtensionProvider( + UserInfoPacketExt.ELEMENT, + UserInfoPacketExt.NAMESPACE, + DefaultPacketExtensionProvider(UserInfoPacketExt::class.java) + ) + ProviderManager.addExtensionProvider( + JitsiParticipantRegionPacketExtension.ELEMENT, + JitsiParticipantRegionPacketExtension.NAMESPACE, + DefaultPacketExtensionProvider(JitsiParticipantRegionPacketExtension::class.java) + ) + ProviderManager.addExtensionProvider( + StatsId.ELEMENT, + StatsId.NAMESPACE, + StatsId.Provider() + ) + ProviderManager.addExtensionProvider( + JitsiParticipantCodecList.ELEMENT, + JitsiParticipantCodecList.NAMESPACE, + DefaultPacketExtensionProvider(JitsiParticipantCodecList::class.java) + ) + + // register Jingle + ProviderManager.addIQProvider( + JingleIQ.ELEMENT, + JingleIQ.NAMESPACE, + JingleIQProvider() + ) + ProviderManager.addExtensionProvider( + JsonMessageExtension.ELEMENT, + JsonMessageExtension.NAMESPACE, + DefaultPacketExtensionProvider(JsonMessageExtension::class.java) + ) + ProviderManager.addExtensionProvider( + FeaturesExtension.ELEMENT, + FeaturesExtension.NAMESPACE, + DefaultPacketExtensionProvider(FeaturesExtension::class.java) + ) + ProviderManager.addExtensionProvider( + FeatureExtension.ELEMENT, + FeatureExtension.NAMESPACE, + DefaultPacketExtensionProvider(FeatureExtension::class.java) + ) + ClientRequirementsIq.registerProviders() + ClientVersionPacketExtension.registerProvider() + RayoIqProvider().registerRayoIQs() + org.jitsi.xmpp.Smack.registerMuteIqProviders() + StartMutedProvider.registerStartMutedProvider() + + ProviderManager.addExtensionProvider( + AudioMutedExtension.ELEMENT, + AudioMutedExtension.NAMESPACE, + DefaultPacketExtensionProvider(AudioMutedExtension::class.java) + ) + + ProviderManager.addExtensionProvider( + VideoMutedExtension.ELEMENT, + VideoMutedExtension.NAMESPACE, + DefaultPacketExtensionProvider(VideoMutedExtension::class.java) + ) + IqProviderUtils.registerProviders() +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/VisitorConnectionMonitor.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/VisitorConnectionMonitor.kt new file mode 100644 index 0000000..a8edfd3 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/VisitorConnectionMonitor.kt @@ -0,0 +1,68 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import org.jitsi.jicofo.TaskPools +import org.jitsi.utils.logging2.createLogger +import org.jivesoftware.smack.ConnectionListener +import org.jivesoftware.smack.XMPPConnection + +/** + * Monitors the XMPP connections to the visitor nodes. + * + * A visitor node keeps the state of the MUCs that jicofo joined on it. This state is lost when the XMPP stream is + * not resumed, for example because the visitor node restarted. Jicofo is not an occupant of these MUCs anymore, so + * it does not receive presence from them. The state that jicofo keeps for them is stale and it must be discarded. + * + * Smack does not re-join the MUCs and it does not change its own state, so [onConnectionReset] is the only + * notification that this happened. + */ +class VisitorConnectionMonitor( + visitorConnections: List, + /** Called with the name of a visitor node whose XMPP stream was re-established without being resumed. */ + private val onConnectionReset: (String) -> Unit +) { + private val logger = createLogger() + + private val connectionListeners: List> = visitorConnections.map { provider -> + val name = provider.config.name + val listener = object : ConnectionListener { + override fun authenticated(connection: XMPPConnection?, resumed: Boolean) { + if (resumed) { + // The visitor node kept our session, so the MUCs that we joined on it are still joined. + return + } + logger.info("The XMPP stream to visitor node $name was not resumed.") + // Do not do the work in Smack's thread. + TaskPools.ioPool.submit { + try { + onConnectionReset(name) + } catch (e: Throwable) { + logger.error("Failed to handle a connection reset for visitor node $name", e) + } + } + } + } + provider.xmppConnection.addConnectionListener(listener) + provider to listener + } + + fun shutdown() = connectionListeners.forEach { (provider, listener) -> + provider.xmppConnection.removeConnectionListener(listener) + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/VisitorsManager.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/VisitorsManager.kt new file mode 100644 index 0000000..b38f6ec --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/VisitorsManager.kt @@ -0,0 +1,119 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2023-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import org.jitsi.jicofo.ConferenceStore +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.visitors.VisitorsConfig +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.visitors.VisitorsIq +import org.jivesoftware.smack.StanzaListener +import org.jivesoftware.smack.filter.StanzaFilter +import org.jivesoftware.smack.packet.ExtensionElement +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.Stanza +import org.jxmpp.jid.DomainBareJid +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import java.lang.Exception + +/** + * Serve two purposes: + * 1. Communicates with a prosody "visitors" component (jicofo initiates communication). + * 2. Handle incoming [VisitorsIq]s from endpoints in a conference. + */ +class VisitorsManager( + val xmppProvider: XmppProvider, + val conferenceStore: ConferenceStore +) : StanzaListener { + init { + xmppProvider.xmppConnection.addSyncStanzaListener(this, VisitorsIqFilter()) + } + + private val logger = createLogger().apply { + addContext("connection", xmppProvider.config.name) + } + + var address: DomainBareJid? = null + + private fun updateAddress(components: Set) { + address = components.find { it.type == "visitors" }?.address?.let { JidCreate.domainBareFrom(it) } + logger.info("VisitorsComponentManager is now ${if (enabled) "en" else "dis"}abled with address $address") + } + + private fun createIq(roomJid: EntityBareJid, extensions: List): VisitorsIq { + val address = this.address ?: throw Exception("Component not available.") + return VisitorsIq.Builder(xmppProvider.xmppConnection).apply { + to(address) + ofType(IQ.Type.get) + room = roomJid + addExtensions(extensions) + }.build() + } + + /** Send an IQ, block for response or timeout, return the result. */ + fun sendIqToComponentAndGetResponse(roomJid: EntityBareJid, extensions: List): IQ? = + xmppProvider.xmppConnection.sendIqAndGetResponse(createIq(roomJid, extensions)) + + /** Send an IQ, return immediately. Log an error if there's no response. */ + fun sendIqToComponent(roomJid: EntityBareJid, extensions: List) { + TaskPools.ioPool.submit { + val response = sendIqToComponentAndGetResponse(roomJid, extensions) + when { + response == null -> logger.warn("Timeout waiting for VisitorsIq response.") + + response.type == IQ.Type.result -> { + logger.info("Received VisitorsIq response: ${response.toXML()}") + } + + else -> logger.warn("Received error response: ${response.toXML()}") + } + } + } + + init { + updateAddress(xmppProvider.components) + xmppProvider.addListener( + object : XmppProvider.Listener { + override fun componentsChanged(components: Set) { + updateAddress(components) + } + } + ) + } + val enabled + get() = VisitorsConfig.config.enabled && address != null + + override fun processStanza(stanza: Stanza) { + if (stanza !is VisitorsIq) { + logger.error("Received unexpected stanza type received: ${stanza.javaClass.name}") + return + } + + conferenceStore.getConference(stanza.room) ?: run { + logger.warn("Ignoring VisitorsIq for unknown conference ${stanza.room}.") + return + } + logger.info("Received VisitorsIq: ${stanza.toXML()}") + // TODO pass to the conference + } +} + +class VisitorsIqFilter : StanzaFilter { + override fun accept(stanza: Stanza) = stanza is VisitorsIq +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppServices.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppServices.kt new file mode 100644 index 0000000..cf61ebf --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppServices.kt @@ -0,0 +1,158 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.ConferenceStore +import org.jitsi.jicofo.FocusManager +import org.jitsi.jicofo.auth.AbstractAuthAuthority +import org.jitsi.jicofo.jigasi.JigasiConfig +import org.jitsi.jicofo.jigasi.JigasiDetector +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.jicofo.xmpp.jingle.JingleIqRequestHandler +import org.jitsi.utils.logging2.createLogger + +class XmppServices( + conferenceStore: ConferenceStore, + authenticationAuthority: AbstractAuthAuthority?, + focusManager: FocusManager, + /** Whether any jibri detectors are configured. Evaluated lazily, because detectors are created after [XmppServices]. */ + hasJibriDetector: () -> Boolean = { false } +) { + private val logger = createLogger() + + val clientConnection: XmppProvider = XmppProvider(XmppConfig.client, logger).apply { + start() + } + + val serviceConnection: XmppProvider = if (XmppConfig.service.enabled) { + logger.info("Using a dedicated Service XMPP connection.") + XmppProvider(XmppConfig.service, logger).apply { start() } + } else { + logger.info("No dedicated Service XMPP connection configured, re-using the client XMPP connection.") + clientConnection + } + + val visitorConnections: List = XmppConfig.visitors.values.mapNotNull { config -> + if (config.enabled) { + logger.info("Using XMPP visitor connection ${config.name}") + XmppProvider(config, logger).apply { start() } + } else { + logger.info("Visitor connection ${config.name} is disabled.") + null + } + } + + private val visitorConnectionMonitor = VisitorConnectionMonitor(visitorConnections) { node -> + focusManager.visitorConnectionReset(node) + } + + fun getXmppConnectionByName(name: XmppConnectionEnum) = when (name) { + XmppConnectionEnum.Client -> clientConnection + XmppConnectionEnum.Service -> serviceConnection + } + + fun getXmppVisitorConnectionByName(name: String) = visitorConnections.find { it.config.name == name } + + val jigasiDetector = JigasiConfig.config.breweryJid?.let { breweryJid -> + JigasiDetector( + getXmppConnectionByName(JigasiConfig.config.xmppConnectionName), + breweryJid + ).apply { + init() + JicofoMetricsContainer.instance.metricsUpdater.addUpdateTask { updateMetrics() } + } + } ?: run { + logger.info("No Jigasi detector configured.") + null + } + + private val jibriIqHandler = JibriIqHandler( + setOf(clientConnection.xmppConnection, serviceConnection.xmppConnection), + conferenceStore, + hasJibriDetector + ) + + private val jigasiIqHandler = if (jigasiDetector != null) { + JigasiIqHandler( + setOf(clientConnection.xmppConnection, serviceConnection.xmppConnection) + visitorConnections.map { + it.xmppConnection + }.toSet(), + conferenceStore, + jigasiDetector + ) + } else { + null + } + val jigasiStats: ObjectNode + get() = jigasiIqHandler?.statsJson ?: JsonNodeFactory.instance.objectNode() + + val avModerationHandler = AvModerationHandler(clientConnection, conferenceStore) + val roomMetadataHandler = RoomMetadataHandler(clientConnection, conferenceStore) + private val audioMuteHandler = AudioMuteIqHandler(setOf(clientConnection.xmppConnection), conferenceStore) + private val videoMuteHandler = VideoMuteIqHandler(setOf(clientConnection.xmppConnection), conferenceStore) + private val desktopMuteHandler = DesktopMuteIqHandler(setOf(clientConnection.xmppConnection), conferenceStore) + val jingleHandler = JingleIqRequestHandler( + visitorConnections.map { it.xmppConnection }.toSet() + clientConnection.xmppConnection + ) + val visitorsManager = VisitorsManager(clientConnection, focusManager) + + val conferenceIqHandler = ConferenceIqHandler( + xmppProvider = clientConnection, + focusManager = focusManager, + focusAuthJid = XmppConfig.client.jid, + authAuthority = authenticationAuthority, + jigasiEnabled = jigasiDetector != null, + visitorsManager + ).apply { + clientConnection.xmppConnection.registerIQRequestHandler(this) + } + + private val authenticationIqHandler: AuthenticationIqHandler? = if (authenticationAuthority == null) { + null + } else { + AuthenticationIqHandler(authenticationAuthority).also { + clientConnection.xmppConnection.registerIQRequestHandler(it.loginUrlIqHandler) + clientConnection.xmppConnection.registerIQRequestHandler(it.logoutIqHandler) + } + } + + fun shutdown() { + clientConnection.shutdown() + if (serviceConnection != clientConnection) { + serviceConnection.shutdown() + } + jigasiDetector?.shutdown() + jibriIqHandler.shutdown() + jigasiIqHandler?.shutdown() + audioMuteHandler.shutdown() + videoMuteHandler.shutdown() + desktopMuteHandler.shutdown() + avModerationHandler.shutdown() + roomMetadataHandler.shutdown() + jingleHandler.shutdown() + visitorConnectionMonitor.shutdown() + + clientConnection.xmppConnection.unregisterIQRequestHandler(conferenceIqHandler) + authenticationIqHandler?.let { + clientConnection.xmppConnection.unregisterIQRequestHandler(it.loginUrlIqHandler) + clientConnection.xmppConnection.unregisterIQRequestHandler(it.logoutIqHandler) + } + } +} diff --git a/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomRoleManager.kt b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomRoleManager.kt new file mode 100644 index 0000000..b078d16 --- /dev/null +++ b/jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomRoleManager.kt @@ -0,0 +1,171 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.auth.AuthenticationAuthority +import org.jitsi.jicofo.auth.AuthenticationListener +import org.jitsi.utils.logging2.createLogger +import org.jitsi.utils.queue.PacketQueue + +/** + * Manages the XMPP roles of occupants in a chat room, i.e. grants ownership to certain users. + */ +sealed class ChatRoomRoleManager( + protected val chatRoom: ChatRoom +) : ChatRoomListener { + protected val logger = createLogger() + + override fun memberLeft(member: ChatRoomMember) = memberLeftOrKicked(member) + override fun memberKicked(member: ChatRoomMember) = memberLeftOrKicked(member) + + protected open fun memberLeftOrKicked(member: ChatRoomMember) {} + + open fun stop() {} + open fun grantOwnership() {} + + open val debugState: ObjectNode = JsonNodeFactory.instance.objectNode() + + protected val queue = PacketQueue( + Integer.MAX_VALUE, + false, + "chat-room-role-manager-queue", + { + it.run() + return@PacketQueue true + }, + TaskPools.ioPool + ) +} + +/** + * A [ChatRoomRoleManager] which grants ownership to a single occupant in the room (if the room is left without an + * owner, it elects a new one). + */ +class AutoOwnerRoleManager(chatRoom: ChatRoom) : ChatRoomRoleManager(chatRoom) { + private var owner: ChatRoomMember? = null + + override fun grantOwnership() = queue.add { electNewOwner() } + override fun memberJoined(member: ChatRoomMember) { + if (owner == null) { + electNewOwner() + } + } + + override fun memberLeftOrKicked(member: ChatRoomMember) { + if (member == owner) { + logger.debug("The owner left the room, electing a new one.") + owner = null + electNewOwner() + } + } + + override fun localRoleChanged(newRole: MemberRole) { + if (!newRole.hasOwnerRights()) { + logger.error("Local role has no owner rights, can not manage roles.") + return + } + + electNewOwner() + } + + private fun electNewOwner() { + queue.add { + if (owner != null) { + return@add + } + + owner = chatRoom.members.find { !(it.isJibri || it.isJigasi) && it.role.hasOwnerRights() } + if (owner != null) { + return@add + } + + val newOwner = chatRoom.members.find { !(it.isJibri || it.isJigasi) && it.role != MemberRole.VISITOR } + if (newOwner != null) { + logger.info("Electing new owner: $newOwner") + chatRoom.grantOwnership(newOwner) + owner = newOwner + } + } + } + + override val debugState: ObjectNode + get() = JsonNodeFactory.instance.objectNode().apply { + put("class", this@AutoOwnerRoleManager.javaClass.simpleName) + put("owner", owner?.jid?.toString() ?: "null") + } +} + +/** A [ChatRoomRoleManager] which grants ownership to all authenticated users. */ +class AuthenticationRoleManager( + chatRoom: ChatRoom, + /** Used to check whether a user is authenticated, and subscribe to authentication events. */ + private val authenticationAuthority: AuthenticationAuthority +) : ChatRoomRoleManager(chatRoom) { + + private val authenticationListener = AuthenticationListener { userJid, _, _ -> + chatRoom.members.find { it.jid == userJid }?.let { + queue.add { + logger.info("Granting ownership to $it.") + chatRoom.grantOwnership(it) + } + } + } + + init { + authenticationAuthority.addAuthenticationListener(authenticationListener) + } + + override fun grantOwnership() = queue.add { grantOwnerToAuthenticatedUsers() } + + private fun grantOwnerToAuthenticatedUsers() { + chatRoom.members.filter { + !it.role.hasOwnerRights() && authenticationAuthority.getSessionForJid(it.jid) != null + }.forEach { + chatRoom.grantOwnership(it) + } + } + + override fun localRoleChanged(newRole: MemberRole) { + if (!newRole.hasOwnerRights()) { + logger.error("Local role has no owner rights, can not manage roles.") + return + } + + queue.add { grantOwnerToAuthenticatedUsers() } + } + + /** + * Handles cases where moderators(already authenticated users) reload and join again. + */ + override fun memberJoined(member: ChatRoomMember) { + if (member.role != MemberRole.OWNER && authenticationAuthority.getSessionForJid(member.jid) != null) { + queue.add { + chatRoom.grantOwnership(member) + } + } + } + + override fun stop() = authenticationAuthority.removeAuthenticationListener(authenticationListener) + + override val debugState: ObjectNode = JsonNodeFactory.instance.objectNode().apply { + put("class", this@AuthenticationRoleManager.javaClass.simpleName) + } +} diff --git a/jicofo/src/main/resources/reference.conf b/jicofo/src/main/resources/reference.conf new file mode 100644 index 0000000..2ce1c8d --- /dev/null +++ b/jicofo/src/main/resources/reference.conf @@ -0,0 +1,254 @@ +// Default values for configuration read by classes in the jicofo module. Defaults for other jicofo.* sections live +// in the reference.conf of the module that owns them (jicofo-common, jicofo-selector). +jicofo { + // Authentication with external services + authentication { + // Setting enabled=false is the same as type=NONE + enabled = false + // The type of authentication. Supported values are XMPP, JWT or NONE (default). + type = NONE + + // The pattern of authentication URL. + # login-url = + + authentication-lifetime = 24 hours + enable-auto-login = true + } + + conference { + // Whether to automatically grant the 'owner' role to the first participant in the conference (and subsequently to + // the next in line when the current owner leaves). + enable-auto-owner = true + + // Can be used to disable moderator checks for starting a recording or placing a call + enable-moderator-checks = true + + // How long to wait for the initial participant in a conference. + initial-timeout = 15 seconds + + // The maximum number of sources an endpoint is allowed to signal in a conferencee. + max-ssrcs-per-user = 20 + + // The maximum number of ssrc-groups an endpoint is allowed to signal in a conferencee. + max-ssrc-groups-per-user = ${jicofo.conference.max-ssrcs-per-user} + + // How long a participant's media session will be kept alive once it remains the only participant in the room. + single-participant-timeout = 20 seconds + + // The minimum number of participants required for the conference to be started. + min-participants = 2 + + // The maximum number of participants that can send their audio at the same time. + max-audio-senders = 999999 + + // The maximum number of participants that can send their video at the same time. + max-video-senders = 999999 + + // Whether to strip simulcast when signaling sources to receivers. + strip-simulcast = true + + // Whether to use SSRC rewriting for participants that support it. + use-ssrc-rewriting = true + + // Whether to have the bridge stamp the sdes:mid header extension on rewritten media (and signal per-slot mids), + // for participants that support mid-based demuxing. Only takes effect together with use-ssrc-rewriting. + use-rtp-mid-demux = true + + // Whether to use a JSON encoding of sources instead of the standard Jingle encoding (only used for participants + // that signal support for JSON encoded sources). + use-json-encoded-sources = true + + shared-document { + // If `true` the shared document uses a random name. Otherwise, it uses the conference name. + use-random-name = false + } + + // How much to delay signaling Jingle source-add and source-remove in order to batch them and reduce the number of + // messages (based on conference size). The values are in milliseconds. + source-signaling-delays = { + // Conferences with size <50 will have delay=0. + // Conferences with size between 50 and 99 will have delay=500 ms. + #50 = 500 + // Conferences with size >=100 have delay=1000 ms. + #100 = 1000 + } + + // The method to use when re-inviting participants. Either RestartJingle (terminate and re-create the whole jingle + // session) or ReplaceTransport (send a transport-replace). + reinvite-method = "RestartJingle" + + // Whether to honor in-place ICE restart requests, i.e. a session-info with . + // With this the bridge rotates its ICE credentials in place and the new transport is signaled to the participant + // in a transport-info, without tearing the session down. If disabled such requests are rejected and the client is + // expected to fall back to a full session restart (session-terminate with ). + enable-ice-restart = true + + // Rate limits for a participant requesting a restart. Applied separately to full session restarts + // (session-terminate) and in-place ICE restarts (session-info), so that a burst of one can not exhaust the other's + // budget. + restart-request-rate-limits { + // Never accept a request unless at least `min-interval` has passed since the last request + min-interval = 5 seconds + // Accept at most `max-requests` per `interval` + max-requests = 5 + interval = 1 minute + } + + // The capabilities that endpoints are required to advertise. An endpoint which does not advertise a capability is + // notified, and (for a "hard" requirement) is not invited to the conference. It stays in the MUC and can still use + // the features which do not need a media session, e.g. chat. + client-requirements { + // Whether to check the capabilities of endpoints. + enabled = true + + // Whether to actually skip inviting an endpoint which does not advertise a capability with a "hard" level. When + // this is false jicofo only logs and updates metrics, and notifies the endpoint as if the level were "soft". + enforce = false + + // The requirements, keyed by the name of a feature known to jicofo (see Features.kt). The set of requirements is + // maintained in jicofo (see ClientRequirementsConfig.defaultRequirements), and the entries here change it. + // level: "hard" (the endpoint is not invited), "soft" (the endpoint is invited and warned), or "off" (the + // requirement is disabled). + // details: text (in English) sent to the endpoint, which describes how to add support for the capability. + // url: a URL with more information, sent to the endpoint. + requirements { + #SSRC_REWRITING_V1 { + # level = "hard" + # details = "Update to jitsi-meet 10.2 or later." + # url = "https://jitsi.github.io/handbook/" + #} + } + } + } + + // Configuration for the internal health checks performed by jicofo. + health { + // Whether to perform health checks. + enabled = false + + // The interval between health checks. If set to 0, periodic health checks will not be performed. + interval = 10 seconds + + # The timeout for a health check + timeout = 30 seconds + + # If performing a health check takes longer than this, it is considered unsuccessful. + max-check-duration = 20 seconds + + # The prefix to use when creating MUC rooms for the purpose of health checks. + room-name-prefix = "__jicofo-health-check" + } + + jibri { + // The JID of the MUC to be used as a brewery for jibri instances for streaming. + # brewery-jid = "jibribrewery@example.com" + + // How many times to retry a given Jibri request before giving up. Set to -1 to allow infinite retries. + num-retries = 5 + + // How long to wait for Jibri to start recording from the time it accepts a START request. + pending-timeout = 90 seconds + + // The XMPP connection to use to communicate with Jibri instances. Either `Client` or `Service` (case-sensitive). + // See the corresponding XMPP connection configuration under `xmpp`. + // Note that if no separate Service connection has been configured, all services will automatically use the + // Client connection. + xmpp-connection-name = Client + + // Enable use of private addresses for ICE for jibri + use-private-address-connectivity = true + } + + jibri-sip { + // The JID of the MUC to be used as a brewery for jibri instances for SIP. + # brewery-jid = "jibrisipbrewery@example.com" + } + + jigasi { + // The JID of the MUC to be used as a brewery for jigasi instances. + # brewery-jid = "jigasibrewery@example.com" + + // The XMPP connection to use to communicate with Jigasi instances. Either `Client` or `Service` (case-sensitive). + // See the corresponding XMPP connection configuration under `xmpp`. + // Note that if no separate Service connection has been configured, all services will automatically use the + // Client connection. + xmpp-connection-name = Client + + // Enable use of private addresses for ICE for jigasi + use-private-address-connectivity = true + } + + rest { + enabled = true + port = 8888 + host = "127.0.0.1" + + prometheus { + // Enable the prometheus /metrics endpoint. + enabled = true + } + conference-request { + // Enable the conference-request endpoint + enabled = true + } + move-endpoints { + // Enable the move-endpoint API. + enabled = true + } + debug { + enabled = true + } + pin { + enabled = true + } + } + + # Visitors are endpoints which are allowed to receive audio/video in a conference, but not send any. For better + # scalability visitors are not members of the conferencec MUC. Instead, they login to a separate XMPP server and join + # a separate "visitor MUC" associated with the conference MUC. Presence from the main MUC is propagated to the set of + # associated visitor MUCs. + # + # Jicofo connects to a set of XMPP servers used only for visitors (configured under `jicofoo.xmpp.visitors`). It's + # its responsibility to select a "visitor node" for each endpoint that requests to join a conference and redirect + # it to that node. + # + # Note: the vnode-join-latency-interval default is defined in jicofo-common. + visitors { + # Whether visitors (XMPP MUC members with role "visitor") should be invited into the conference. + enabled = false + + # The maximum number of participants in the main room, after which conference requests will be redirected to + # visitor nodes. + max-participants = 50 + + # The maximum number of visitors per node (visitor MUC). Note that this is not a hard limit: once it is reached for + # all available visitor nodes, visitors are redirected to the visitor MUC with fewest visitors. + max-visitors-per-node = 500 + + # The minimum interval at which notification counts are updated + notification-interval = 15 seconds + + # The visitors process has two parts: + # 1. Endpoints are redirected to a vnode, and they join that separate MUC as visitors. They still don't receive + # presence for the main participants and do not have Jingle sessions. + # 2. The visitors' and main MUCs are connected together (visitors now receive presence for participants), and + # jicofo starts Jingle sessions with visitors (visitors now receive media for participants). + # + # With this option enabled the second part is started automatically as soon as any visitors join. Otherwise, jicofo + # waits to receive a extension from one of the moderators in the conference. + auto-enable-broadcast = true + + # Whether to require the "visitorsEnabled" flag in the MUC config form before enabling visitors for a conference. + # This allows the visitors option to be controlled per-conference. + require-muc-config-flag = false + + # Whether to evaluate visitorsLive room flag, which can be controlled server-side + # via the room-metadata service + enable-live-room = false + } +} + +tracing { + enabled = false + service-name = "jicofo" +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/ConferenceConfigTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/ConferenceConfigTest.kt new file mode 100644 index 0000000..cbd91a4 --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/ConferenceConfigTest.kt @@ -0,0 +1,58 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.config.withNewConfig + +class ConferenceConfigTest : ShouldSpec() { + init { + context("source-signaling-delays") { + context("With no delay configured") { + for (i in 0..100) { + ConferenceConfig.config.getSourceSignalingDelayMs(i) shouldBe 0 + } + } + context("With delay configured") { + withNewConfig( + """ + jicofo.conference.source-signaling-delays { + // Intentionally out of order + 200 = 1000, + 100 = 500, + 300 = 2000 + } + """.trimIndent() + ) { + ConferenceConfig.config.apply { + getSourceSignalingDelayMs(0) shouldBe 0 + getSourceSignalingDelayMs(50) shouldBe 0 + getSourceSignalingDelayMs(99) shouldBe 0 + getSourceSignalingDelayMs(100) shouldBe 500 + getSourceSignalingDelayMs(199) shouldBe 500 + getSourceSignalingDelayMs(200) shouldBe 1000 + getSourceSignalingDelayMs(299) shouldBe 1000 + getSourceSignalingDelayMs(300) shouldBe 2000 + getSourceSignalingDelayMs(5000) shouldBe 2000 + } + } + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/ConferenceIqHandlerTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/ConferenceIqHandlerTest.kt new file mode 100644 index 0000000..a5c6afc --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/ConferenceIqHandlerTest.kt @@ -0,0 +1,58 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jitsi.jicofo.xmpp.ConferenceIqHandler +import org.jitsi.xmpp.extensions.jitsimeet.ConferenceIq +import org.jivesoftware.smack.packet.IQ +import org.jxmpp.jid.impl.JidCreate + +class ConferenceIqHandlerTest : ShouldSpec() { + private val conferenceIqHandler = ConferenceIqHandler( + xmppProvider = mockk(relaxed = true), + focusManager = mockk { + every { getConference(any()) } returns null + every { conferenceRequest(any(), any()) } returns mockk { + every { isStarted() } returns true + every { chatRoom } returns null + } + }, + focusAuthJid = "", + authAuthority = null, + jigasiEnabled = false, + visitorsManager = mockk(relaxed = true) + ) + + init { + context("Handling a ConferenceIQ") { + val conferenceIq = ConferenceIq().apply { + this.from = from + room = JidCreate.entityBareFrom("testRoom@example.com") + to = JidCreate.from("example.com") + type = IQ.Type.set + } + + conferenceIqHandler.handleConferenceIq(conferenceIq).shouldBeInstanceOf() + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/ConferenceRequestTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/ConferenceRequestTest.kt new file mode 100644 index 0000000..9f74efa --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/ConferenceRequestTest.kt @@ -0,0 +1,89 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe + +class ConferenceRequestTest : ShouldSpec() { + init { + val conferenceRequest = ConferenceRequest( + room = "r@example.com", + ready = true, + sessionId = "s", + identity = "i", + machineUid = "m", + vnode = "v", + focusJid = "f", + properties = mutableMapOf("k1" to "v1", "k2" to "v2") + ) + + fun ConferenceRequest.shouldEqual(other: ConferenceRequest) { + room shouldBe other.room + ready shouldBe other.ready + sessionId shouldBe other.sessionId + identity shouldBe other.identity + machineUid shouldBe other.machineUid + vnode shouldBe other.vnode + focusJid shouldBe other.focusJid + properties.toMap() shouldBe other.properties.toMap() + } + + context("To JSON") { + ConferenceRequest(room = "r@example.com").toJson() shouldBe "{\"room\":\"r@example.com\"}" + ConferenceRequest(room = "r@example.com", ready = false).toJson() shouldBe + "{\"room\":\"r@example.com\",\"ready\":false}" + + val parsed = ConferenceRequest.parseJson(conferenceRequest.toJson()) + parsed.shouldEqual(conferenceRequest) + } + context("Parse JSON") { + val parsed = ConferenceRequest.parseJson( + """ + { + "room": "r@example.com", + "ready": true, + "sessionId": "s", + "identity": "i", + "machineUid": "m", + "vnode": "v", + "focusJid": "f", + "properties": { "k1": "v1", "k2": "v2" } + } + """.trimIndent() + ) + parsed.shouldEqual(conferenceRequest) + } + context("To ConferenceIq") { + conferenceRequest.toConferenceIq().let { + it.room.toString() shouldBe "r@example.com" + it.isReady shouldBe true + it.focusJid shouldBe "f" + it.identity shouldBe "i" + it.machineUID shouldBe "m" + it.sessionId shouldBe "s" + it.vnode shouldBe "v" + it.properties.associate { Pair(it.name, it.value) } shouldBe mapOf("k1" to "v1", "k2" to "v2") + } + } + context("From ConferenceIq") { + val conferenceIq = conferenceRequest.toConferenceIq() + ConferenceRequest.fromConferenceIq(conferenceIq).shouldEqual(conferenceRequest) + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/DebugStateTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/DebugStateTest.kt new file mode 100644 index 0000000..e77f2ed --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/DebugStateTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo + +import io.kotest.core.spec.style.ShouldSpec +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.bridge.BridgeSelector +import org.jitsi.jicofo.jibri.JibriChatRoomMember +import org.jitsi.jicofo.jibri.JibriDetector +import org.jitsi.jicofo.jigasi.JigasiDetector +import org.jitsi.jicofo.util.shouldBeValidJson +import org.jitsi.jicofo.xmpp.muc.ChatRoomImpl +import org.jitsi.jicofo.xmpp.muc.ChatRoomMember +import org.jitsi.jicofo.xmpp.muc.ChatRoomMemberImpl +import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.impl.JidCreate +import java.util.logging.Level + +/** + * All debugState interfaces should produce valid JSON. + */ +class DebugStateTest : ShouldSpec() { + init { + context("BridgeSelector") { + val bridgeSelector = BridgeSelector().apply { addJvbAddress(JidCreate.from("jvb")) } + bridgeSelector.debugState.shouldBeValidJson() + } + context("FocusManager") { + val conferenceJid = JidCreate.entityBareFrom("conference@example.com") + + val focusManager = FocusManager(jicofoServices = mockk(relaxed = true)).apply { + conferenceRequest(conferenceJid, emptyMap()) + } + focusManager.getDebugState(true).shouldBeValidJson() + } + context("ChatRoomImpl and members") { + val conferenceJid = JidCreate.entityBareFrom("conference@example.com") + + val chatRoom = ChatRoomImpl(mockk(relaxed = true), conferenceJid, Level.INFO) { } + chatRoom.debugState.shouldBeValidJson() + + val member = ChatRoomMemberImpl( + JidCreate.entityFullFrom("conference@example.com/member"), + chatRoom, + mockk(relaxed = true) + ) + member.debugState.shouldBeValidJson() + } + context("Jigasi detector") { + val jigasiDetector = JigasiDetector( + mockk(relaxed = true), + JidCreate.entityBareFrom("JigasiBrewery@example.com") + ) + jigasiDetector.processMemberPresence( + jigasiChatMember(JidCreate.entityFullFrom("JigasiBrewery@example.com/jigasi-1")) + ) + jigasiDetector.debugState.shouldBeValidJson() + println(jigasiDetector.debugState.toString()) + } + context("Jibri detector") { + val jibriDetector = JibriDetector( + mockk(relaxed = true), + JidCreate.entityBareFrom("JibriBrewery@example.com"), + false + ) + + // This registers with the detector internally. + JibriChatRoomMember( + JidCreate.entityFullFrom("JibriBrewery@example.com/jibri-1"), + jibriDetector + ) + jibriDetector.debugState.shouldBeValidJson() + println(jibriDetector.debugState.toString()) + } + } +} + +private fun jigasiChatMember(jid: EntityFullJid) = mockk { + every { occupantJid } returns jid + every { presence } returns mockk { + every { + getExtensionElement(ColibriStatsExtension.ELEMENT, ColibriStatsExtension.NAMESPACE) + } answers { + ColibriStatsExtension() + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/KotestProjectConfig.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/KotestProjectConfig.kt new file mode 100644 index 0000000..e6a788b --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/KotestProjectConfig.kt @@ -0,0 +1,29 @@ +import io.kotest.core.config.AbstractProjectConfig +import org.jitsi.jicofo.metrics.JicofoMetricsContainer +import org.jitsi.metaconfig.MetaconfigSettings + +/* + * Copyright @ 2020 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class KotestProjectConfig : AbstractProjectConfig() { + override suspend fun beforeProject() = super.beforeProject().also { + // The only purpose of config caching is performance. We always want caching disabled in tests (so we can + // freely modify the config without affecting other tests executing afterwards). + MetaconfigSettings.cacheEnabled = false + + JicofoMetricsContainer.instance.checkForNameConflicts = false + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/auth/AuthConfigTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/auth/AuthConfigTest.kt new file mode 100644 index 0000000..55501a1 --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/auth/AuthConfigTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright @ 2020 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.auth + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.config.withLegacyConfig +import org.jitsi.config.withNewConfig +import java.time.Duration + +class AuthConfigTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + init { + context("Default values") { + AuthConfig.config.apply { + enableAutoLogin shouldBe true + shouldThrow { loginUrl } + type shouldBe AuthConfig.Type.NONE + authenticationLifetime shouldBe Duration.ofHours(24) + } + } + context("With legacy config") { + withLegacyConfig( + """ + org.jitsi.jicofo.auth.URL=XMPP:test@example.com + org.jitsi.jicofo.auth.DISABLE_AUTOLOGIN=true + org.jitsi.jicofo.auth.AUTH_LIFETIME=60000 + """ + ) { + AuthConfig.config.apply { + enableAutoLogin shouldBe false + loginUrl shouldBe "test@example.com" + type shouldBe AuthConfig.Type.XMPP + authenticationLifetime shouldBe Duration.ofMillis(60000) + } + } + } + context("With new config") { + withNewConfig( + """ + jicofo.authentication { + enabled = true + type = JWT + login-url = login + enable-auto-login = false + authentication-lifetime = 5 minutes + } + """ + ) { + AuthConfig.config.apply { + enableAutoLogin shouldBe false + loginUrl shouldBe "login" + type shouldBe AuthConfig.Type.JWT + authenticationLifetime shouldBe Duration.ofMinutes(5) + } + } + withNewConfig( + """ + jicofo.authentication { + enabled = false + } + """ + ) { + AuthConfig.config.apply { + type shouldBe AuthConfig.Type.NONE + } + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/auth/XMPPDomainAuthAuthorityTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/auth/XMPPDomainAuthAuthorityTest.kt new file mode 100644 index 0000000..ebe3d3f --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/auth/XMPPDomainAuthAuthorityTest.kt @@ -0,0 +1,217 @@ +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.FocusManager +import org.jitsi.jicofo.auth.XMPPDomainAuthAuthority +import org.jitsi.jicofo.xmpp.ConferenceIqHandler +import org.jitsi.xmpp.extensions.jitsimeet.ConferenceIq +import org.jitsi.xmpp.extensions.jitsimeet.SessionInvalidPacketExtension +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError.Condition +import org.jxmpp.jid.impl.JidCreate +import java.time.Duration + +class XMPPDomainAuthAuthorityTest : ShouldSpec() { + private val authDomain = "auth.server.net" + private val guestDomain = "guest.server.net" + private val authAuthority = XMPPDomainAuthAuthority( + true, + Duration.ofDays(1), + JidCreate.domainBareFrom(authDomain) + ) + val focusManager = mockk(relaxed = true) + + /** The authentication logic is shared between [XMPPDomainAuthAuthority] and the IQ handler, so we test both. */ + private val conferenceIqHandler = ConferenceIqHandler( + xmppProvider = mockk(relaxed = true), + focusManager = focusManager, + focusAuthJid = "", + authAuthority = authAuthority, + jigasiEnabled = false, + visitorsManager = mockk(relaxed = true) + ) + + override fun isolationMode(): IsolationMode = IsolationMode.SingleInstance + + init { + val user1GuestJid = JidCreate.from("user1@$guestDomain") + val user1AuthJid = JidCreate.from("user1@$authDomain") + val user1MachineUid = "machine1uid" + + val user2GuestJid = JidCreate.from("user2@$guestDomain") + val user2AuthJid = JidCreate.from("user2@$authDomain") + val user2MachineUid = "machine2uid" + + val room1 = JidCreate.entityBareFrom("testroom1@example.com") + val room2 = JidCreate.entityBareFrom("newroom@example.com") + val room3 = JidCreate.entityBareFrom("newroom2@example.com") + + context("CASE 1: guest Domain, no session-id passed and room does not exist") { + every { focusManager.getConference(any()) } returns null + val query = ConferenceIq().apply { + from = user1GuestJid + sessionId = null + room = room1 + machineUID = user1MachineUid + to = JidCreate.from("jicofo@example.com") + type = IQ.Type.set + } + + conferenceIqHandler.handleConferenceIq(query).error.condition shouldBe Condition.not_authorized + } + + // Save the session ID that will be created with the request below + lateinit var user1SessionId: String + context("CASE 2: Auth domain, no session-id and room does not exist") { + every { focusManager.getConference(any()) } returns null + val query = ConferenceIq().apply { + to = JidCreate.from("jicofo@example.com") + type = IQ.Type.set + from = user1AuthJid + sessionId = null + room = room1 + machineUID = user1MachineUid + } + + conferenceIqHandler.handleConferenceIq(query).let { + it.shouldBeInstanceOf() + it.sessionId shouldNotBe null + user1SessionId = it.sessionId + } + } + + context("CASE 3: guest domain, no session-id, room exists") { + every { focusManager.getConference(any()) } returns mockk(relaxed = true) + val query = ConferenceIq().apply { + to = JidCreate.from("jicofo@example.com") + from = user2GuestJid + sessionId = null + room = room1 + machineUID = user2MachineUid + } + + println("query=${query.toXML()}") + conferenceIqHandler.handleConferenceIq(query).let { + it.shouldBeInstanceOf() + it.sessionId shouldBe null + } + } + + context("CASE 4: guest domain, session-id, room does not exists") { + every { focusManager.getConference(any()) } returns null + val query = ConferenceIq().apply { + to = JidCreate.from("jicofo@example.com") + type = IQ.Type.set + from = user1GuestJid + sessionId = user1SessionId + machineUID = user1MachineUid + room = room2 + } + + conferenceIqHandler.handleConferenceIq(query).let { + it.shouldBeInstanceOf() + it.sessionId shouldBe user1SessionId + } + } + + context("CASE 5: guest jid, invalid session-id, room exists") { + every { focusManager.getConference(any()) } returns mockk() + val query = ConferenceIq().apply { + room = room2 + to = JidCreate.from("jicofo@example.com") + type = IQ.Type.set + from = user2GuestJid + sessionId = "someinvalidsessionid" + machineUID = user2MachineUid + } + + conferenceIqHandler.handleConferenceIq(query).let { + val sessionInvalidPacketExtension: SessionInvalidPacketExtension = + it.error.getExtension( + SessionInvalidPacketExtension.ELEMENT, + SessionInvalidPacketExtension.NAMESPACE + ) + sessionInvalidPacketExtension shouldNotBe null + } + } + + context("CASE 6: do not allow to use session-id from different machine") { + every { focusManager.getConference(any()) } returns mockk() + val query = ConferenceIq().apply { + room = room2 + to = JidCreate.from("jicofo@example.com") + type = IQ.Type.set + sessionId = user1SessionId + from = user2GuestJid + machineUID = user2MachineUid + } + + conferenceIqHandler.handleConferenceIq(query).error.condition shouldBe Condition.not_acceptable + } + + context("CASE 7: auth jid, but stolen session id") { + every { focusManager.getConference(any()) } returns mockk() + val query = ConferenceIq().apply { + room = room2 + to = JidCreate.from("jicofo@example.com") + type = IQ.Type.set + sessionId = user1SessionId + from = user2GuestJid + machineUID = user2MachineUid + } + + conferenceIqHandler.handleConferenceIq(query).error.condition shouldBe Condition.not_acceptable + } + + context("CASE 8: guest jid, session used without machine UID") { + every { focusManager.getConference(any()) } returns mockk() + val query = ConferenceIq().apply { + room = room2 + to = JidCreate.from("jicofo@example.com") + type = IQ.Type.set + from = user1GuestJid + sessionId = user1SessionId + machineUID = null + } + + conferenceIqHandler.handleConferenceIq(query).error.condition shouldBe Condition.not_acceptable + } + + context("CASE 9: auth jid, try to create session without machine UID") { + every { focusManager.getConference(any()) } returns mockk() + val query = ConferenceIq().apply { + to = JidCreate.from("jicofo@example.com") + type = IQ.Type.set + room = room3 + from = user2AuthJid + sessionId = null + machineUID = null + } + + conferenceIqHandler.handleConferenceIq(query).error.condition shouldBe Condition.not_acceptable + } + + context("CASE 10: same user, different machine UID - assign separate session") { + every { focusManager.getConference(any()) } returns mockk(relaxed = true) + val user3MachineUID = "user3machineUID" + val query = ConferenceIq().apply { + room = room3 + to = JidCreate.from("jicofo@example.com") + type = IQ.Type.set + from = user1AuthJid + machineUID = user3MachineUID + sessionId = null + } + + conferenceIqHandler.handleConferenceIq(query).let { + it.shouldBeInstanceOf() + it.sessionId shouldNotBe null + it.sessionId shouldNotBe user1SessionId + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/bridge/BridgePinTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/bridge/BridgePinTest.kt new file mode 100644 index 0000000..ead23f2 --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/bridge/BridgePinTest.kt @@ -0,0 +1,114 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2022-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridge + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import org.jitsi.jicofo.FocusManager +import org.jitsi.utils.time.FakeClock +import org.jxmpp.jid.impl.JidCreate +import java.time.Duration + +/** + * Test conference pin operations. + */ +class BridgePinTest : ShouldSpec() { + + private val conf1 = JidCreate.entityBareFrom("test@conference.meet.jit.si") + private val conf2 = JidCreate.entityBareFrom("hope@conference.meet.jit.si") + private val conf3 = JidCreate.entityBareFrom("pray@conference.meet.jit.si") + private val v1 = "1.1.1" + private val v2 = "2.2.2" + private val v3 = "3.3.3" + + init { + context("basic functionality") { + val clock = FakeClock() + val focusManager = FocusManager(mockk(), clock) + + focusManager.pinConference(conf1, v1, Duration.ofMinutes(10)) + focusManager.pinConference(conf2, v2, Duration.ofMinutes(12)) + focusManager.pinConference(conf3, v3, Duration.ofMinutes(14)) + + should("pin correctly") { + focusManager.getPinnedConferences().size shouldBe 3 + focusManager.getBridgeVersionForConference(conf1) shouldBe v1 + focusManager.getBridgeVersionForConference(conf2) shouldBe v2 + focusManager.getBridgeVersionForConference(conf3) shouldBe v3 + } + + should("expire") { + clock.elapse(Duration.ofMinutes(11)) + focusManager.getPinnedConferences().size shouldBe 2 + focusManager.getBridgeVersionForConference(conf1) shouldBe null + focusManager.getBridgeVersionForConference(conf2) shouldBe v2 + focusManager.getBridgeVersionForConference(conf3) shouldBe v3 + + clock.elapse(Duration.ofMinutes(2)) + focusManager.getPinnedConferences().size shouldBe 1 + focusManager.getBridgeVersionForConference(conf1) shouldBe null + focusManager.getBridgeVersionForConference(conf2) shouldBe null + focusManager.getBridgeVersionForConference(conf3) shouldBe v3 + + clock.elapse(Duration.ofMinutes(2)) + focusManager.getPinnedConferences().size shouldBe 0 + focusManager.getBridgeVersionForConference(conf1) shouldBe null + focusManager.getBridgeVersionForConference(conf2) shouldBe null + focusManager.getBridgeVersionForConference(conf3) shouldBe null + } + } + context("modifications") { + val clock = FakeClock() + val focusManager = FocusManager(mockk(), clock) + + focusManager.pinConference(conf1, v1, Duration.ofMinutes(10)) + focusManager.pinConference(conf2, v2, Duration.ofMinutes(12)) + focusManager.pinConference(conf3, v3, Duration.ofMinutes(14)) + + should("unpin") { + focusManager.unpinConference(conf3) + focusManager.getPinnedConferences().size shouldBe 2 + focusManager.getBridgeVersionForConference(conf1) shouldBe v1 + focusManager.getBridgeVersionForConference(conf2) shouldBe v2 + focusManager.getBridgeVersionForConference(conf3) shouldBe null + } + + should("modify version and timeout") { + clock.elapse(Duration.ofMinutes(4)) + focusManager.pinConference(conf1, v3, Duration.ofMinutes(10)) + focusManager.getPinnedConferences().size shouldBe 2 + focusManager.getBridgeVersionForConference(conf1) shouldBe v3 + focusManager.getBridgeVersionForConference(conf2) shouldBe v2 + focusManager.getBridgeVersionForConference(conf3) shouldBe null + + clock.elapse(Duration.ofMinutes(9)) + focusManager.getPinnedConferences().size shouldBe 1 + focusManager.getBridgeVersionForConference(conf1) shouldBe v3 + focusManager.getBridgeVersionForConference(conf2) shouldBe null + focusManager.getBridgeVersionForConference(conf3) shouldBe null + + clock.elapse(Duration.ofMinutes(2)) + focusManager.getPinnedConferences().size shouldBe 0 + focusManager.getBridgeVersionForConference(conf1) shouldBe null + focusManager.getBridgeVersionForConference(conf2) shouldBe null + focusManager.getBridgeVersionForConference(conf3) shouldBe null + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/bridgeload/LoadRedistributorTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/bridgeload/LoadRedistributorTest.kt new file mode 100644 index 0000000..825ce67 --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/bridgeload/LoadRedistributorTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.bridgeload + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.bridge.Bridge +import org.jitsi.jicofo.bridge.BridgeSelector +import org.jitsi.jicofo.bridge.ConferenceBridgeProperties +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jitsi.jicofo.util.ListConferenceStore +import org.jxmpp.jid.impl.JidCreate + +class LoadRedistributorTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val bridgeJid = JidCreate.from("jvbbrewery@muc.example.com/jvb1") + private val bridge: Bridge = mockk(relaxed = true) { + every { jid } returns bridgeJid + } + private val bridgeSelector: BridgeSelector = mockk().also { selector -> + every { selector.get(any()) } answers { if (firstArg() == bridgeJid) bridge else null } + } + private val conferenceStore = ListConferenceStore() + + /** A conference with [participantCount] endpoints on [bridge]. Records the moves requested of it. */ + private fun addConference(name: String, participantCount: Int): JitsiMeetConference { + val moveRequests = mutableListOf() + val conference = mockk(relaxed = true) { + every { roomName } returns JidCreate.entityBareFrom(name) + every { bridges } returns mapOf( + bridge to ConferenceBridgeProperties(participantCount) + ) + every { moveEndpoints(bridge, any()) } answers { secondArg().also { moveRequests.add(it) } } + } + conferenceStore.add(conference) + return conference + } + + private val loadRedistributor = LoadRedistributor(conferenceStore, bridgeSelector) + + init { + context("moveEndpoint") { + val conference = addConference("conf1@conference.example.com", 5) + every { conference.moveEndpoint("ep1", any()) } returns true + every { conference.moveEndpoint("ep2", any()) } returns false + + should("fail without required parameters") { + shouldThrow { loadRedistributor.moveEndpoint(null, "ep1", null) } + shouldThrow { + loadRedistributor.moveEndpoint("conf1@conference.example.com", null, null) + } + } + should("fail with an invalid or unknown conference") { + shouldThrow { loadRedistributor.moveEndpoint("invalid jid", "ep1", null) } + shouldThrow { + loadRedistributor.moveEndpoint("other@conference.example.com", "ep1", null) + } + } + should("fail with an unknown bridge") { + shouldThrow { + loadRedistributor.moveEndpoint("conf1@conference.example.com", "ep1", "unknown@example.com") + } + } + should("move an endpoint") { + loadRedistributor.moveEndpoint( + "conf1@conference.example.com", + "ep1", + bridgeJid.toString() + ) shouldBe MoveResult(1, 1) + } + should("report failure to move an endpoint") { + loadRedistributor.moveEndpoint( + "conf1@conference.example.com", + "ep2", + null + ) shouldBe MoveResult(0, 0) + } + } + context("moveEndpoints") { + // Conferences sorted by size descending: big (10), medium (5), small (1) + addConference("big@conference.example.com", 10) + addConference("medium@conference.example.com", 5) + addConference("small@conference.example.com", 1) + + should("fail without a bridge") { + shouldThrow { loadRedistributor.moveEndpoints(null, null, 1) } + } + should("move endpoints from the largest conference first") { + loadRedistributor.moveEndpoints(bridgeJid.toString(), null, 8) shouldBe MoveResult(8, 1) + } + should("spill over to smaller conferences") { + loadRedistributor.moveEndpoints(bridgeJid.toString(), null, 12) shouldBe MoveResult(12, 2) + } + should("move at most the total number of endpoints") { + loadRedistributor.moveEndpoints(bridgeJid.toString(), null, 100) shouldBe MoveResult(16, 3) + } + should("only move from the given conference when specified") { + loadRedistributor.moveEndpoints( + bridgeJid.toString(), + "medium@conference.example.com", + 100 + ) shouldBe MoveResult(5, 1) + } + } + context("moveFraction") { + addConference("big@conference.example.com", 10) + addConference("medium@conference.example.com", 5) + addConference("small@conference.example.com", 1) + + should("fail without a bridge") { + shouldThrow { loadRedistributor.moveFraction(null, 0.5) } + } + should("move the given fraction of all endpoints") { + // 0.5 of 16 = 8 + loadRedistributor.moveFraction(bridgeJid.toString(), 0.5) shouldBe MoveResult(8, 1) + } + should("move everything with fraction 1") { + loadRedistributor.moveFraction(bridgeJid.toString(), 1.0) shouldBe MoveResult(16, 3) + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/codec/JingleOfferFactoryTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/codec/JingleOfferFactoryTest.kt new file mode 100644 index 0000000..e93fca2 --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/codec/JingleOfferFactoryTest.kt @@ -0,0 +1,62 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2015-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.codec + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension +import org.jitsi.xmpp.extensions.jingle.PayloadTypePacketExtension +import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension +import org.jitsi.jicofo.codec.JingleOfferFactory.INSTANCE as jingleOfferFactory + +class JingleOfferFactoryTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.SingleInstance + init { + context("With default options") { + val offer = jingleOfferFactory.createOffer(OfferOptions()) + + offer.find { it.name == "audio" } shouldNotBe null + offer.find { it.name == "data" } shouldNotBe null + + val videoContent = offer.find { it.name == "video" } + videoContent shouldNotBe null + videoContent!!.containsRtx() shouldBe true + } + context("Without audio and data") { + val offer = jingleOfferFactory.createOffer(OfferOptions(audio = false, sctp = false)) + + offer.find { it.name == "audio" } shouldBe null + offer.find { it.name == "video" } shouldNotBe null + offer.find { it.name == "data" } shouldBe null + } + context("Without RTX") { + val offer = jingleOfferFactory.createOffer(OfferOptions(rtx = false)) + val videoContent = offer.find { it.name == "video" } + + videoContent shouldNotBe null + videoContent!!.containsRtx() shouldBe false + } + } + + private fun ContentPacketExtension.containsRtx() = + getChildExtensionsOfType(RtpDescriptionPacketExtension::class.java).any { + it.getChildExtensionsOfType(PayloadTypePacketExtension::class.java).any { it.name == "rtx" } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ClientRequirementsHandlerTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ClientRequirementsHandlerTest.kt new file mode 100644 index 0000000..7487698 --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ClientRequirementsHandlerTest.kt @@ -0,0 +1,252 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.ClientRequirementsConfig +import org.jitsi.jicofo.xmpp.Features +import org.jitsi.jicofo.xmpp.muc.ChatRoomMember +import org.jitsi.utils.logging2.LoggerImpl +import org.jitsi.xmpp.extensions.clientrequirements.ClientRequirementsIq +import org.jitsi.xmpp.extensions.clientrequirements.RequirementLevel +import org.jitsi.xmpp.extensions.clientrequirements.RequirementsAction +import org.jivesoftware.smack.packet.Message +import org.jivesoftware.smack.packet.Stanza +import org.jxmpp.jid.impl.JidCreate + +/** The features of a client which supports everything that the tests below require. */ +private val allFeatures = Features.defaultFeatures + Features.SSRC_REWRITING_V1 + Features.CLIENT_REQUIREMENTS_1 + +private fun member( + features: Set, + discovered: Boolean = true, + jibri: Boolean = false, + jigasi: Boolean = false, + transcriber: Boolean = false, + id: String = "abcdabcd" +) = mockk(relaxed = true) { + every { name } returns id + every { occupantJid } returns JidCreate.entityFullFrom("room@conference.example.com/$id") + every { this@mockk.features } returns features + every { featuresDiscovered } returns discovered + every { isJibri } returns jibri + every { isJigasi } returns jigasi + every { isTranscriber } returns transcriber +} + +class ClientRequirementsHandlerTest : ShouldSpec() { + private val sent = mutableListOf() + + private fun handler() = ClientRequirementsHandler( + LoggerImpl(javaClass.name), + ClientRequirementsConfig(), + { _, stanza -> sent.add(stanza) } + ) + + init { + beforeTest { sent.clear() } + + context("With no requirements configured") { + withNewConfig(configWithRequirements("")) { + should("Invite and not notify") { + handler().checkAndNotify(member(Features.defaultFeatures)) shouldBe true + sent.size shouldBe 0 + } + } + } + + context("With the check disabled") { + withNewConfig( + """ + jicofo.conference.client-requirements { + enabled = false + enforce = true + requirements { SSRC_REWRITING_V1 { level = "hard" } } + } + """.trimIndent() + ) { + should("Invite and not notify") { + handler().checkAndNotify(member(Features.defaultFeatures)) shouldBe true + sent.size shouldBe 0 + } + } + } + + context("With a hard requirement") { + withNewConfig( + configWithRequirements( + """SSRC_REWRITING_V1 { level = "hard", details = "Update.", url = "https://example.com" }""" + ) + ) { + should("Invite a client which advertises the feature") { + handler().checkAndNotify(member(allFeatures)) shouldBe true + sent.size shouldBe 0 + } + should("Not invite a client which does not advertise the feature") { + val m = member(Features.defaultFeatures + Features.CLIENT_REQUIREMENTS_1) + handler().checkAndNotify(m) shouldBe false + + sent.size shouldBe 1 + val iq = sent[0].shouldBeInstanceOf() + iq.to shouldBe m.occupantJid + iq.action shouldBe RequirementsAction.REJECT + iq.missingFeatures.size shouldBe 1 + iq.missingFeatures[0].let { + it.feature shouldBe Features.SSRC_REWRITING_V1.value + it.name shouldBe "SSRC_REWRITING_V1" + it.level shouldBe RequirementLevel.HARD + it.details shouldBe "Update." + it.url shouldBe "https://example.com" + } + } + should("Send a message to a client which does not support the IQ") { + val m = member(Features.defaultFeatures) + handler().checkAndNotify(m) shouldBe false + + sent.size shouldBe 1 + val message = sent[0].shouldBeInstanceOf() + message.to shouldBe m.occupantJid + message.type shouldBe Message.Type.chat + message.body!!.contains("SSRC_REWRITING_V1") shouldBe true + message.body!!.contains("Update.") shouldBe true + message.body!!.contains("https://example.com") shouldBe true + } + should("Not notify again when a member is checked twice") { + val h = handler() + val m = member(Features.defaultFeatures + Features.CLIENT_REQUIREMENTS_1) + h.checkAndNotify(m) shouldBe false + h.checkAndNotify(m) shouldBe false + sent.size shouldBe 1 + h.rejectedCount shouldBe 1 + + // Once the member leaves its state is cleared. + h.memberLeft(m) shouldBe true + h.rejectedCount shouldBe 0 + h.checkAndNotify(m) shouldBe false + sent.size shouldBe 2 + } + should("Not check a member whose features were not discovered") { + handler().checkAndNotify(member(Features.defaultFeatures, discovered = false)) shouldBe true + sent.size shouldBe 0 + } + should("Not check jibri, jigasi or the transcriber") { + val h = handler() + h.checkAndNotify(member(Features.defaultFeatures, jibri = true)) shouldBe true + h.checkAndNotify(member(Features.defaultFeatures, jigasi = true)) shouldBe true + h.checkAndNotify(member(Features.defaultFeatures, transcriber = true)) shouldBe true + sent.size shouldBe 0 + } + } + } + + context("With a hard requirement and enforce disabled") { + withNewConfig( + """ + jicofo.conference.client-requirements { + enabled = true + enforce = false + requirements { SSRC_REWRITING_V1 { level = "hard" } } + } + """.trimIndent() + ) { + should("Invite, and notify with a warning") { + val h = handler() + h.checkAndNotify(member(Features.defaultFeatures + Features.CLIENT_REQUIREMENTS_1)) shouldBe true + h.rejectedCount shouldBe 0 + + sent.size shouldBe 1 + val iq = sent[0].shouldBeInstanceOf() + iq.action shouldBe RequirementsAction.WARN + iq.missingFeatures[0].level shouldBe RequirementLevel.SOFT + } + } + } + + context("With a soft requirement") { + withNewConfig(configWithRequirements("""SSRC_REWRITING_V1 { level = "soft" }""")) { + should("Invite, and notify with a warning") { + handler().checkAndNotify( + member(Features.defaultFeatures + Features.CLIENT_REQUIREMENTS_1) + ) shouldBe true + + sent.size shouldBe 1 + val iq = sent[0].shouldBeInstanceOf() + iq.action shouldBe RequirementsAction.WARN + iq.missingFeatures[0].level shouldBe RequirementLevel.SOFT + } + } + } + + context("With a requirement disabled in configuration") { + withNewConfig(configWithRequirements("""SSRC_REWRITING_V1 { level = "off" }""")) { + should("Invite and not notify") { + handler().checkAndNotify(member(Features.defaultFeatures)) shouldBe true + sent.size shouldBe 0 + } + } + } + + context("With multiple missing requirements") { + withNewConfig( + configWithRequirements( + """ + SSRC_REWRITING_V1 { level = "hard" } + START_MUTED_RMD { level = "soft" } + """.trimIndent() + ) + ) { + should("Include all of them in a single IQ") { + handler().checkAndNotify( + member(Features.defaultFeatures + Features.CLIENT_REQUIREMENTS_1) + ) shouldBe false + + sent.size shouldBe 1 + val iq = sent[0].shouldBeInstanceOf() + iq.action shouldBe RequirementsAction.REJECT + iq.missingFeatures.map { it.name }.toSet() shouldBe setOf("SSRC_REWRITING_V1", "START_MUTED_RMD") + } + should("Send one message per requirement to a client which does not support the IQ") { + handler().checkAndNotify(member(Features.defaultFeatures)) shouldBe false + sent.size shouldBe 2 + } + } + } + + context("With a requirement for an unknown feature") { + withNewConfig(configWithRequirements("""NOT_A_FEATURE { level = "hard" }""")) { + should("Ignore it") { + handler().checkAndNotify(member(Features.defaultFeatures)) shouldBe true + sent.size shouldBe 0 + } + } + } + } +} + +private fun configWithRequirements(requirements: String) = """ + jicofo.conference.client-requirements { + enabled = true + enforce = true + requirements { + $requirements + } + } +""".trimIndent() diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ConferenceTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ConferenceTest.kt new file mode 100644 index 0000000..a64691b --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ConferenceTest.kt @@ -0,0 +1,319 @@ +/* + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.ConferenceConfig +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.conference.source.EndpointSourceSet.Companion.fromJingle +import org.jitsi.jicofo.conference.source.Source +import org.jitsi.jicofo.conference.source.ValidationFailedException +import org.jitsi.jicofo.mock.ConferenceHarness +import org.jitsi.jicofo.mock.inPlaceExecutor +import org.jitsi.jicofo.mock.inPlaceScheduledExecutor +import org.jitsi.jicofo.util.shouldBeValidJson +import org.jitsi.jicofo.xmpp.muc.ChatRoomMember +import org.jitsi.jicofo.xmpp.muc.MemberRole +import org.jitsi.utils.MediaType +import org.jitsi.xmpp.extensions.jingle.JingleAction +import org.jivesoftware.smack.packet.IQ + +class ConferenceTest : ShouldSpec() { + private val harness = ConferenceHarness() + private val xmppConnection = harness.xmppConnection + private val jingleSessions = harness.jingleSessions + private val chatRoom = harness.chatRoom + private val conference = harness.conference + + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.ioPool = inPlaceExecutor + TaskPools.scheduledPool = inPlaceScheduledExecutor + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetIoPool() + TaskPools.resetScheduledPool() + } + + private fun addParticipants(n: Int): List = harness.addParticipants(n) + private fun ChatRoomMember.getParticipant() = harness.getParticipant(this) + private fun ChatRoomMember.getRemoteParticipant() = harness.getRemoteParticipant(this) + + init { + context("Test inviting 2 participants initially") { + // Simulate occupants entering the MUC + val member1 = chatRoom.addMember("member1") + val member2 = chatRoom.addMember("member2") + + conference.participantCount shouldBe 2 + + val participant1 = member1.getParticipant()!! + val participant2 = member2.getParticipant()!! + + val remoteParticipant1 = member1.getRemoteParticipant()!! + val remoteParticipant2 = member2.getRemoteParticipant()!! + + val jingleSession1 = jingleSessions.find { it.sid == remoteParticipant1.sessionInitiate.sid }!! + val jingleSession2 = jingleSessions.find { it.sid == remoteParticipant2.sessionInitiate.sid }!! + + jingleSession1.processIq(remoteParticipant1.createSessionAccept()) + jingleSession2.processIq(remoteParticipant2.createSessionAccept()) + + participant1.jingleSession shouldNotBe null + participant2.jingleSession shouldNotBe null + + // They should have 1 audio and 1 video source each + participant1.sources.sources.size shouldBe 2 + participant2.sources.sources.size shouldBe 2 + + context("And then inviting one more") { + addParticipants(1) + conference.participantCount shouldBe 3 + } + context("Single participant timeout") { + chatRoom.removeMember(member1) + // The single-participant timeout should execute immediately and end the jingle session + conference.participantCount shouldBe 0 + harness.ended shouldBe false + + chatRoom.removeMember(member2) + conference.participantCount shouldBe 0 + harness.ended shouldBe true + } + } + context("Test inviting more than 2 initially") { + val members = addParticipants(5) + conference.participantCount shouldBe 5 + + members.forEach { member -> + member.getParticipant().let { + it shouldNotBe null + it!!.jingleSession shouldNotBe null + it.sources.sources.size shouldBe 2 + } + } + + members.forEach { chatRoom.removeMember(it) } + conference.participantCount shouldBe 0 + harness.ended shouldBe true + } + context("Test participants leaving before accepting a session") { + val members = buildList { + repeat(5) { i -> + add(chatRoom.addMember("member-$i")) + } + } + + conference.participantCount shouldBe 5 + members.take(3).forEach { member -> + val remoteParticipant = xmppConnection.remoteParticipants[member.occupantJid]!! + val jingleSession = jingleSessions.find { it.sid == remoteParticipant.sessionInitiate.sid }!! + jingleSession.processIq(remoteParticipant.createSessionAccept()) + } + members.drop(3).forEach { chatRoom.removeMember(it) } + + conference.participantCount shouldBe 3 + + members.take(3).forEach { member -> + val participant = conference.getParticipant(member.occupantJid)!! + participant.jingleSession shouldNotBe null + participant.sources.sources.size shouldBe 2 + } + + members.take(3).forEach { chatRoom.removeMember(it) } + conference.participantCount shouldBe 0 + harness.ended shouldBe true + } + context("Sender limits") { + withNewConfig("jicofo.conference.max-video-senders=5, jicofo.conference.max-audio-senders=5") { + context("Configuration") { + ConferenceConfig.config.maxVideoSenders shouldBe 5 + ConferenceConfig.config.maxAudioSenders shouldBe 5 + } + chatRoom.chatRoom.apply { every { videoSendersCount } returns 0 } + chatRoom.chatRoom.apply { every { audioSendersCount } returns 0 } + var ssrcs = 1L + fun nextSource(mediaType: MediaType) = EndpointSourceSet(Source(ssrcs++, mediaType)) + + context("Video") { + val videoSource = nextSource(MediaType.VIDEO) + conference.addSource(mockk(relaxed = true), videoSource) + shouldThrow { + conference.addSource(mockk(relaxed = true), videoSource) + } + shouldThrow { + conference.addSource(mockk(relaxed = true), EndpointSourceSet(Source(-1, MediaType.VIDEO))) + } + + chatRoom.chatRoom.apply { every { videoSendersCount } returns 10 } + shouldThrow { + conference.addSource(mockk(relaxed = true), nextSource(MediaType.VIDEO)) + } + + chatRoom.chatRoom.apply { every { videoSendersCount } returns 2 } + conference.addSource(mockk(relaxed = true), nextSource(MediaType.VIDEO)) + } + context("Audio") { + val audioSource = nextSource(MediaType.AUDIO) + conference.addSource(mockk(relaxed = true), audioSource) + shouldThrow { + conference.addSource(mockk(relaxed = true), audioSource) + } + shouldThrow { + conference.addSource(mockk(relaxed = true), EndpointSourceSet(Source(-1, MediaType.AUDIO))) + } + + chatRoom.chatRoom.apply { every { audioSendersCount } returns 10 } + shouldThrow { + conference.addSource(mockk(relaxed = true), nextSource(MediaType.AUDIO)) + } + + chatRoom.chatRoom.apply { every { audioSendersCount } returns 2 } + conference.addSource(mockk(relaxed = true), nextSource(MediaType.AUDIO)) + } + } + } + context("Muting") { + val members = addParticipants(2) + val muter = members[0].getParticipant()!! + val mutee = members[1].getParticipant()!! + fun mute() = conference.handleMuteRequest( + muter.mucJid, + mutee.mucJid, + true, + org.jitsi.jicofo.MediaType.AUDIO + ) + fun unmute() = conference.handleMuteRequest( + muter.mucJid, + mutee.mucJid, + false, + org.jitsi.jicofo.MediaType.VIDEO + ) + + context("When the muter is an owner") { + every { muter.chatMember.role } returns MemberRole.OWNER + mute() shouldBe MuteResult.SUCCESS + unmute() shouldBe MuteResult.NOT_ALLOWED + } + context("When the muter is a moderator") { + every { muter.chatMember.role } returns MemberRole.MODERATOR + mute() shouldBe MuteResult.SUCCESS + unmute() shouldBe MuteResult.NOT_ALLOWED + } + context("When the muter is a participant") { + every { muter.chatMember.role } returns MemberRole.PARTICIPANT + mute() shouldBe MuteResult.NOT_ALLOWED + unmute() shouldBe MuteResult.NOT_ALLOWED + } + context("When the muter is a visitor") { + every { muter.chatMember.role } returns MemberRole.VISITOR + mute() shouldBe MuteResult.NOT_ALLOWED + unmute() shouldBe MuteResult.NOT_ALLOWED + } + } + context("Signaling sources") { + val members = addParticipants(2) + val participants = members.map { it.getParticipant()!! } + val remoteParticipants = members.map { it.getRemoteParticipant()!! } + participants.forEach { it.sources.sources.size shouldBe 2 } + + val member3 = addParticipants(1)[0] + val participant3 = member3.getParticipant()!! + val remoteParticipant3 = member3.getRemoteParticipant()!! + val jingleSession3 = participant3.jingleSession!! + participant3.sources.sources.size shouldBe 2 + + remoteParticipants.forEach { + val lastJingleMessageSent = it.requests.last() + lastJingleMessageSent.action shouldBe JingleAction.SOURCEADD + fromJingle(lastJingleMessageSent.contentList) shouldBe participant3.sources + } + + // Add an additional source + val newSource = EndpointSourceSet(remoteParticipant3.nextSource(MediaType.VIDEO)) + jingleSession3.processIq(remoteParticipant3.createSourceAdd(newSource)) + remoteParticipants.forEach { + val lastJingleMessageSent = it.requests.last() + lastJingleMessageSent.action shouldBe JingleAction.SOURCEADD + fromJingle(lastJingleMessageSent.contentList) shouldBe newSource + } + + // Now remove it + jingleSession3.processIq(remoteParticipant3.createSourceRemove(newSource)) + remoteParticipants.forEach { + val lastJingleMessageSent = it.requests.last() + lastJingleMessageSent.action shouldBe JingleAction.SOURCEREMOVE + fromJingle(lastJingleMessageSent.contentList) shouldBe newSource + } + + context("Adding sources already signaled") { + val sources = remoteParticipant3.sources + jingleSession3.processIq(remoteParticipant3.createSourceAdd(sources)) + + xmppConnection.requests.last().type shouldBe IQ.Type.error + } + context("Adding sources used by another participant") { + val sources = remoteParticipants[1].sources + jingleSession3.processIq(remoteParticipant3.createSourceAdd(sources)) + + xmppConnection.requests.last().type shouldBe IQ.Type.error + } + context("Adding invalid sources") { + jingleSession3.processIq(remoteParticipant3.createSourceAdd(remoteParticipant3.sources)) + + xmppConnection.requests.last().type shouldBe IQ.Type.error + } + context("A participant leaving") { + val newSource2 = EndpointSourceSet(remoteParticipant3.nextSource(MediaType.AUDIO)) + jingleSession3.processIq(remoteParticipant3.createSourceAdd(newSource2)) + remoteParticipants.forEach { + val lastJingleMessageSent = it.requests.last() + lastJingleMessageSent.action shouldBe JingleAction.SOURCEADD + fromJingle(lastJingleMessageSent.contentList) shouldBe newSource2 + } + + chatRoom.removeMember(member3) + remoteParticipants.forEach { + val lastJingleMessageSent = it.requests.last() + // A source-remove should NOT have been sent on leave. + lastJingleMessageSent.action shouldBe JingleAction.SOURCEADD + } + } + } + context("Debug state") { + conference.debugState.shouldBeValidJson() + + val members = addParticipants(5) + + conference.debugState.shouldBeValidJson() + members.map { it.getParticipant()!! }.forEach { + it.getDebugState(true).shouldBeValidJson() + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ConferenceUtilTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ConferenceUtilTest.kt new file mode 100644 index 0000000..123788b --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ConferenceUtilTest.kt @@ -0,0 +1,95 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.mock.MockXmppProvider +import org.jitsi.jicofo.xmpp.muc.ChatRoom +import org.jxmpp.jid.impl.JidCreate + +class ConferenceUtilTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + private val v1 = MockXmppProvider(name = "v1", xmppDomain = "v1.example.com") + private val v2 = MockXmppProvider(name = "v2", xmppDomain = "v2.example.com") + private val allNodes = listOf(v1.xmppProvider, v2.xmppProvider) + + /** Create a room on [provider] with [visitors] visitors in it. */ + private fun room(provider: MockXmppProvider, visitors: Int): ChatRoom = + provider.getRoom(JidCreate.entityBareFrom("room@conference.${provider.name}")) + .also { it.visitors = visitors }.chatRoom + + init { + context("selectVisitorNode") { + withNewConfig("jicofo.visitors.max-visitors-per-node = 10") { + context("With no nodes in use") { + should("select one of the registered nodes") { + listOf("v1", "v2") shouldContain selectVisitorNode(emptyMap(), allNodes) + } + should("not select a node whose connection is down") { + v1.registered = false + selectVisitorNode(emptyMap(), allNodes) shouldBe "v2" + } + } + context("With a node already in use") { + val existingNodes = mapOf("v1" to room(v1, 1)) + + should("re-use it while it has capacity") { + selectVisitorNode(existingNodes, allNodes) shouldBe "v1" + } + should("select another node once it is full") { + selectVisitorNode(mapOf("v1" to room(v1, 10)), allNodes) shouldBe "v2" + } + // Without this, jicofo keeps sending visitors to a node that it can not signal to, which is what + // happens while the node is restarting. + should("not re-use it while its connection is down") { + v1.registered = false + selectVisitorNode(existingNodes, allNodes) shouldBe "v2" + } + should("re-use it once its connection is back up") { + v1.registered = false + selectVisitorNode(existingNodes, allNodes) shouldBe "v2" + v1.registered = true + selectVisitorNode(existingNodes, allNodes) shouldBe "v1" + } + should("fall back to it when it is the only node, even if its connection is down") { + v1.registered = false + selectVisitorNode(existingNodes, listOf(v1.xmppProvider)) shouldBe "v1" + } + } + context("With all nodes in use and down") { + should("still select a node") { + v1.registered = false + v2.registered = false + val existingNodes = mapOf("v1" to room(v1, 1), "v2" to room(v2, 1)) + listOf("v1", "v2") shouldContain selectVisitorNode(existingNodes, allNodes) + } + } + context("With no nodes configured") { + should("return null") { + selectVisitorNode(emptyMap(), emptyList()) shouldBe null + } + } + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ConferenceVisitorsTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ConferenceVisitorsTest.kt new file mode 100644 index 0000000..0b8ae5e --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ConferenceVisitorsTest.kt @@ -0,0 +1,154 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.verify +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.mock.ConferenceHarness +import org.jitsi.jicofo.mock.inPlaceExecutor +import org.jitsi.jicofo.mock.inPlaceScheduledExecutor +import org.jitsi.jicofo.xmpp.muc.ChatRoomInfo +import org.jitsi.jicofo.xmpp.muc.MemberRole + +/** + * Tests the way a conference handles the visitor node that it uses, in particular the node restarting. + */ +class ConferenceVisitorsTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.ioPool = inPlaceExecutor + TaskPools.scheduledPool = inPlaceScheduledExecutor + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetIoPool() + TaskPools.resetScheduledPool() + } + + init { + context("A conference with a visitor node") { + withNewConfig( + """ + jicofo.visitors { + enabled = true + max-participants = 1 + max-visitors-per-node = 100 + require-muc-config-flag = false + auto-enable-broadcast = true + } + jicofo.xmpp.visitors.v1 { + hostname = "v1.example.com" + xmpp-domain = "v1.example.com" + conference-service = "conference.v1.example.com" + } + """ + ) { + val harness = ConferenceHarness(visitorNodeNames = listOf("v1")) + val conference = harness.conference + val visitorRoom = harness.visitorRoom("v1") + every { harness.chatRoom.chatRoom.visitorsEnabled } returns true + + // Two participants in the main room, so that the conference starts inviting. + harness.addParticipants(2) + + context("Redirecting a visitor") { + conference.redirectVisitor(true, null, null) shouldBe "v1" + + should("join the visitor room and connect the node") { + verify(exactly = 1) { visitorRoom.chatRoom.join() } + harness.connectedVnodes() shouldContain "v1" + conference.visitorRoomsJids shouldContainExactly listOf(visitorRoom.roomJid) + } + should("invite the visitors that join the room") { + val visitor = visitorRoom.addMember("visitor-1", MemberRole.VISITOR) + conference.getParticipant(visitor.occupantJid) shouldNotBe null + } + } + + context("When the visitor node is restarted") { + conference.redirectVisitor(true, null, null) shouldBe "v1" + val visitor = visitorRoom.addMember("visitor-1", MemberRole.VISITOR) + conference.getParticipant(visitor.occupantJid) shouldNotBe null + + // The node lost the state of the MUC, so jicofo is not an occupant of it anymore. + conference.visitorConnectionReset("v1") + + should("leave the stale visitor room") { + verify { visitorRoom.chatRoom.leave() } + conference.visitorRoomsJids.shouldBeEmpty() + } + should("disconnect the node") { + harness.disconnectedVnodes() shouldContain "v1" + } + // We do not receive presence for these visitors leaving, so without this each one keeps a + // Participant and an endpoint on a bridge for the rest of the conference. + should("terminate the visitors that were in the stale room") { + conference.getParticipant(visitor.occupantJid) shouldBe null + } + should("join the room again when the next visitor is redirected") { + conference.redirectVisitor(true, null, null) shouldBe "v1" + verify(exactly = 2) { visitorRoom.chatRoom.join() } + conference.visitorRoomsJids shouldContainExactly listOf(visitorRoom.roomJid) + harness.connectedVnodes() shouldContainExactly listOf("v1", "v1") + } + } + + context("When a node that the conference does not use is restarted") { + conference.redirectVisitor(true, null, null) shouldBe "v1" + + should("keep the visitor room") { + conference.visitorConnectionReset("v2") + + harness.disconnectedVnodes().shouldBeEmpty() + conference.visitorRoomsJids shouldContainExactly listOf(visitorRoom.roomJid) + } + } + + context("When joining the visitor room fails") { + every { visitorRoom.chatRoom.join() } throws Exception("Failed to join") + shouldThrow { conference.redirectVisitor(true, null, null) } + + // Otherwise the conference keeps sending visitors to a room that it never joined. + should("not keep the room that it failed to join") { + conference.visitorRoomsJids.shouldBeEmpty() + } + should("try to join again for the next visitor") { + every { visitorRoom.chatRoom.join() } returns ChatRoomInfo(null, null) + + conference.redirectVisitor(true, null, null) shouldBe "v1" + verify(exactly = 2) { visitorRoom.chatRoom.join() } + conference.visitorRoomsJids shouldContainExactly listOf(visitorRoom.roomJid) + } + } + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/IceRestartTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/IceRestartTest.kt new file mode 100644 index 0000000..e78d50e --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/IceRestartTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.jitsi.config.withNewConfig +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.mock.ConferenceHarness +import org.jitsi.jicofo.mock.inPlaceExecutor +import org.jitsi.jicofo.mock.inPlaceScheduledExecutor +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension +import org.jitsi.xmpp.extensions.jingle.JingleAction +import org.jitsi.xmpp.extensions.jingle.JingleIQ +import org.jitsi.xmpp.extensions.jitsimeet.BridgeSessionPacketExtension +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError + +/** + * Tests the in-place ICE restart flow end to end within jicofo: a client `session-info` with + * `` results in a colibri2 request to the bridge, and the bridge's rotated + * transport is relayed back to the client in a Jingle `transport-info`. + */ +class IceRestartTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + private val harness = ConferenceHarness() + private val xmppConnection = harness.xmppConnection + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.ioPool = inPlaceExecutor + TaskPools.scheduledPool = inPlaceScheduledExecutor + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetIoPool() + TaskPools.resetScheduledPool() + } + + /** The colibri2 requests jicofo sent which ask for an ICE restart. */ + private fun iceRestartRequests() = xmppConnection.requests.filterIsInstance() + .flatMap { it.endpoints }.filter { it.transport?.iceRestart == true } + + /** The error condition of the response jicofo sent to a request, or null if it was accepted. */ + private fun errorConditionOf(request: IQ) = xmppConnection.requests + .find { it.stanzaId == request.stanzaId && it.type == IQ.Type.error }?.error?.condition + + init { + context("A conference with two participants") { + // min-participants is 2, so nothing is invited before the second one joins. + val member = harness.addParticipants(2).first() + val participant = harness.getParticipant(member).shouldNotBeNull() + val remoteParticipant = harness.getRemoteParticipant(member).shouldNotBeNull() + val sessionInitiate = remoteParticipant.sessionInitiate + val jingleSession = harness.jingleSessions.find { it.sid == sessionInitiate.sid }.shouldNotBeNull() + val bridgeSessionId = sessionInitiate.getExtension(BridgeSessionPacketExtension::class.java) + .shouldNotBeNull().id.shouldNotBeNull() + + /** The `transport-info`s jicofo sent to the client (the session-initiate does not use one). */ + fun transportInfosToClient() = + remoteParticipant.requests.filter { it.action == JingleAction.TRANSPORT_INFO } + + fun createIceRestartRequest(bsId: String? = bridgeSessionId) = + JingleIQ(JingleAction.SESSION_INFO, sessionInitiate.sid).apply { + from = sessionInitiate.to + to = sessionInitiate.from + type = IQ.Type.set + stanzaId = "ice-restart-request-$bsId" + addExtension( + BridgeSessionPacketExtension().apply { + id = bsId + setIceRestart(true) + } + ) + } + + context("A valid request") { + val request = createIceRestartRequest() + jingleSession.processIq(request) + + should("be accepted") { + errorConditionOf(request) shouldBe null + } + should("result in a colibri2 ICE restart request for the endpoint") { + iceRestartRequests().map { it.id } shouldBe listOf(participant.endpointId) + } + should("relay the bridge's rotated transport to the client in a transport-info") { + val transportInfos = transportInfosToClient() + transportInfos.size shouldBe 1 + val transport = transportInfos.first().contentList + .firstNotNullOf { it.getFirstChildOfType(IceUdpTransportPacketExtension::class.java) } + // The mock bridge rotates its credentials and tags them with the generation. + transport.iceGeneration shouldBe 1 + transport.ufrag shouldBe "ufrag-${harness.conference.meetingId}-${participant.endpointId}-1" + } + should("not terminate the Jingle session") { + participant.jingleSession shouldBe jingleSession + remoteParticipant.requests.none { it.action == JingleAction.SESSION_TERMINATE } shouldBe true + } + } + + context("A request with a stale bridge-session ID") { + val request = createIceRestartRequest("not-the-current-bridge-session") + jingleSession.processIq(request) + + should("be rejected with item-not-found") { + errorConditionOf(request) shouldBe StanzaError.Condition.item_not_found + } + should("not ask the bridge for anything") { + iceRestartRequests().size shouldBe 0 + } + should("not signal anything to the client") { + transportInfosToClient().size shouldBe 0 + } + } + + context("A request when the feature is disabled") { + withNewConfig("jicofo.conference.enable-ice-restart=false") { + val request = createIceRestartRequest() + jingleSession.processIq(request) + + should("be rejected with feature-not-implemented") { + errorConditionOf(request) shouldBe StanzaError.Condition.feature_not_implemented + } + should("not ask the bridge for anything") { + iceRestartRequests().size shouldBe 0 + } + } + } + + context("Repeated requests") { + // The configured limits allow 5 requests per minute, but never two within 5 seconds of each other. + val first = createIceRestartRequest() + jingleSession.processIq(first) + val second = JingleIQ(JingleAction.SESSION_INFO, sessionInitiate.sid).apply { + from = sessionInitiate.to + to = sessionInitiate.from + type = IQ.Type.set + stanzaId = "second-ice-restart-request" + addExtension( + BridgeSessionPacketExtension().apply { + id = bridgeSessionId + setIceRestart(true) + } + ) + } + jingleSession.processIq(second) + + should("rate-limit the second one") { + errorConditionOf(first) shouldBe null + errorConditionOf(second) shouldBe StanzaError.Condition.resource_constraint + } + should("only ask the bridge once") { + iceRestartRequests().size shouldBe 1 + } + } + + context("A session-info without the ice-restart flag") { + // The existing ice-state=failed path must be unaffected. + val request = JingleIQ(JingleAction.SESSION_INFO, sessionInitiate.sid).apply { + from = sessionInitiate.to + to = sessionInitiate.from + type = IQ.Type.set + stanzaId = "plain-session-info" + addExtension(BridgeSessionPacketExtension().apply { id = bridgeSessionId }) + } + jingleSession.processIq(request) + + should("not trigger an ICE restart") { + iceRestartRequests().size shouldBe 0 + transportInfosToClient().size shouldBe 0 + } + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ParticipantInviteRunnableTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ParticipantInviteRunnableTest.kt new file mode 100644 index 0000000..876203c --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/ParticipantInviteRunnableTest.kt @@ -0,0 +1,179 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.jitsi.jicofo.bridge.colibri.ColibriAllocation +import org.jitsi.jicofo.bridge.colibri.ColibriSessionManager +import org.jitsi.jicofo.bridge.colibri.SSRC_OWNER_JVB +import org.jitsi.jicofo.conference.source.ConferenceSourceMap +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.conference.source.Source +import org.jitsi.jicofo.conference.source.SsrcGroup +import org.jitsi.jicofo.conference.source.SsrcGroupSemantics +import org.jitsi.jicofo.xmpp.Features +import org.jitsi.jicofo.xmpp.jingle.JingleSession +import org.jitsi.jicofo.xmpp.muc.MemberRole +import org.jitsi.utils.MediaType +import org.jitsi.utils.logging2.LoggerImpl +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension +import org.jxmpp.jid.impl.JidCreate + +class ParticipantInviteRunnableTest : ShouldSpec({ + context("Creating and sending an offer") { + val feedbackSources = ConferenceSourceMap( + SSRC_OWNER_JVB to EndpointSourceSet( + setOf( + Source(1234L, MediaType.AUDIO, "audio-feedback"), + Source(5678L, MediaType.VIDEO, "video-feedback") + ) + ) + ) + val jid1 = "p1" + val jid2 = "p2" + val conferenceSources = ConferenceSourceMap( + jid1 to EndpointSourceSet( + setOf( + Source(1L, MediaType.AUDIO, "audio-p1"), + Source(2L, MediaType.VIDEO, "video-p1"), + ) + ), + jid2 to EndpointSourceSet( + setOf( + Source(11L, MediaType.AUDIO, "audio-p2"), + Source(12L, MediaType.VIDEO, "video-p2"), + Source(13L, MediaType.VIDEO, "video-p2"), + Source(14L, MediaType.VIDEO, "video-p2"), + Source(15L, MediaType.VIDEO, "video-p2"), + Source(16L, MediaType.VIDEO, "video-p2"), + Source(17L, MediaType.VIDEO, "video-p2"), + ), + setOf( + SsrcGroup(SsrcGroupSemantics.Sim, listOf(12L, 14L, 16L), MediaType.VIDEO), + SsrcGroup(SsrcGroupSemantics.Fid, listOf(12L, 13L), MediaType.VIDEO), + SsrcGroup(SsrcGroupSemantics.Fid, listOf(14L, 15L), MediaType.VIDEO), + SsrcGroup(SsrcGroupSemantics.Fid, listOf(16L, 17L), MediaType.VIDEO) + ) + ) + ) + val colibriSessionManager = mockk { + every { allocate(any()) } returns ColibriAllocation( + feedbackSources, + IceUdpTransportPacketExtension(), + null, + null, + null + ) + } + + // Capture the jingle contents that the invite runnable will send via Jingle. + val jingleContentsSlot = slot>() + val sourcesContentsSlot = slot() + val conference = mockk { + every { sources } returns conferenceSources + every { chatRoom } returns mockk { + every { hasMember(any()) } returns true + } + every { getSourcesForParticipant(any()) } returns EndpointSourceSet.EMPTY + } + listOf(true, false).forEach { supportsVideo -> + val features = Features.defaultFeatures.toMutableSet().apply { + remove(Features.SCTP) + if (!supportsVideo) remove(Features.VIDEO) + } + + val participant = object : Participant( + mockk { + every { occupantJid } returns JidCreate.entityFullFrom("conference@example.com/participant") + every { name } returns "participant" + every { role } returns MemberRole.OWNER + every { sourceInfos } returns emptySet() + every { statsId } returns "statsId" + every { region } returns "region" + every { isJibri } returns false + every { isJigasi } returns false + every { isTranscriber } returns false + every { diarize } returns false + }, + conference, + mockk(), + supportedFeatures = features, + ) { + override fun createNewJingleSession(): JingleSession = mockk { + every { + initiateSession( + capture(jingleContentsSlot), + any(), + capture(sourcesContentsSlot) + ) + } returns true + } + } + + val participantInviteRunnable = ParticipantInviteRunnable( + conference, + colibriSessionManager, + participant, + false, + false, + false, + LoggerImpl("test") + ) + + context("When the participant ${if (supportsVideo) "supports" else "does not support"} video") { + participantInviteRunnable.run() + + jingleContentsSlot.isCaptured shouldBe true + jingleContentsSlot.captured.apply { + size shouldBe if (supportsVideo) 2 else 1 + any { it.name == "video" } shouldBe supportsVideo + any { it.name == "audio" } shouldBe true + } + + sourcesContentsSlot.isCaptured shouldBe true + val sources = sourcesContentsSlot.captured + should("Have some audio sources") { + sources.values.all { it.hasAudio } shouldBe true + } + should("Have video sources iff video is supported") { + sources.values.all { it.hasVideo } shouldBe supportsVideo + } + + should("Have the correct SSRC groups") { + // p1 has no groups + sources[jid1]!!.ssrcGroups.shouldBeEmpty() + + // p2 has simulcast. It should be stripped, but RTX should be retained. + val p2ssrcGroups = sources[jid2]!!.ssrcGroups + if (!supportsVideo) { + p2ssrcGroups.shouldBeEmpty() + } else { + p2ssrcGroups.any { it.semantics == SsrcGroupSemantics.Sim } shouldBe false + p2ssrcGroups.any { it.semantics == SsrcGroupSemantics.Fid } shouldBe true + } + } + } + } + } +}) diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/source/SourceSignalingTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/source/SourceSignalingTest.kt new file mode 100644 index 0000000..8ee170a --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/source/SourceSignalingTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.source + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import org.jitsi.jicofo.conference.AddOrRemove.Add +import org.jitsi.jicofo.conference.AddOrRemove.Remove +import org.jitsi.jicofo.conference.SourceSignaling +import org.jitsi.jicofo.util.shouldBeValidJson +import org.jitsi.utils.MediaType + +class SourceSignalingTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + init { + val e1 = "endpoint1" + val e1a = Source(1, MediaType.AUDIO) + val e1v = Source(11, MediaType.VIDEO) + val s1 = ConferenceSourceMap(e1 to EndpointSourceSet(setOf(e1a, e1v))).unmodifiable + + val e2 = "endpoint2" + val e2a = Source(2, MediaType.AUDIO) + val e2v = Source(22, MediaType.VIDEO) + val s2 = ConferenceSourceMap(e2 to EndpointSourceSet(setOf(e2a, e2v))).unmodifiable + + val e2a2 = Source(222, MediaType.AUDIO) + val e2v2 = Source(2222, MediaType.VIDEO) + val s2new = ConferenceSourceMap(e2 to EndpointSourceSet(setOf(e2a2, e2v2))).unmodifiable + + val e3 = "endpoint3" + val e3a = Source(3, MediaType.AUDIO) + val s3 = ConferenceSourceMap(e3 to EndpointSourceSet(e3a)).unmodifiable + + context("Queueing remote sources") { + val sourceSignaling = SourceSignaling() + sourceSignaling.update().shouldBeEmpty() + + context("Resetting") { + sourceSignaling.addSources(s1) + sourceSignaling.reset(ConferenceSourceMap()) + sourceSignaling.update().shouldBeEmpty() + + sourceSignaling.addSources(s1) + sourceSignaling.reset(s2) + sourceSignaling.update().shouldBeEmpty() + } + + context("Adding a single source") { + sourceSignaling.addSources(s1) + sourceSignaling.update().let { + it.size shouldBe 1 + it[0].action shouldBe Add + it[0].sources.toMap() shouldBe s1.toMap() + } + } + + context("Adding multiple sources") { + // Consecutive source-adds should be merged. + sourceSignaling.addSources(s1) + sourceSignaling.addSources(s2) + sourceSignaling.update().let { + it.size shouldBe 1 + it[0].action shouldBe Add + it[0].sources.toMap() shouldBe (s1 + s2).toMap() + } + } + + context("Adding multiple sources in multiple API calls") { + // Consecutive source-adds should be merged. + sourceSignaling.addSources(s1) + sourceSignaling.addSources(s2) + sourceSignaling.addSources(s2new) + sourceSignaling.update().let { + it.size shouldBe 1 + it[0].action shouldBe Add + it[0].sources.toMap() shouldBe (s1 + s2 + s2new).toMap() + } + } + + context("Adding and removing sources") { + // A source-remove after a series of source-adds should be a new entry. + sourceSignaling.addSources(s1) + sourceSignaling.addSources(s2) + sourceSignaling.addSources(s2new) + sourceSignaling.removeSources(s2new) + sourceSignaling.update().let { + it.size shouldBe 1 + it[0].action shouldBe Add + it[0].sources.toMap() shouldBe (s1 + s2).toMap() + } + } + + context("Adding, removing, then adding again") { + // A source-add following source-remove should be a new entry. + sourceSignaling.addSources(s1) + sourceSignaling.addSources(s2) + sourceSignaling.addSources(s2new) + sourceSignaling.removeSources(s2new) + sourceSignaling.addSources(s3) + sourceSignaling.update().let { + it.size shouldBe 1 + it[0].action shouldBe Add + it[0].sources.toMap() shouldBe (s1 + s2 + s3).toMap() + } + } + + context("Adding and removing the same source") { + sourceSignaling.addSources(s1) + sourceSignaling.removeSources(s1) + sourceSignaling.update().shouldBeEmpty() + } + + sourceSignaling.debugState.shouldBeValidJson() + } + context("Filtering") { + val sourceSignaling = SourceSignaling(audio = true, video = false, stripSimulcast = true) + + sourceSignaling.reset(s1 + s2).shouldBe( + ConferenceSourceMap( + e1 to EndpointSourceSet(e1a), + e2 to EndpointSourceSet(e2a) + ) + ) + + sourceSignaling.addSources(s2new) + sourceSignaling.update().let { + it.size shouldBe 1 + it[0].action shouldBe Add + it[0].sources shouldBe ConferenceSourceMap(e2 to EndpointSourceSet(e2a2)) + } + + sourceSignaling.removeSources(s1) + sourceSignaling.update().let { + it.size shouldBe 1 + it[0].action shouldBe Remove + it[0].sources shouldBe ConferenceSourceMap(e1 to EndpointSourceSet(e1a)) + } + + sourceSignaling.addSources(s1) + sourceSignaling.removeSources(s1) + sourceSignaling.update().shouldBeEmpty() + + sourceSignaling.removeSources(s2) + sourceSignaling.addSources(s2) + sourceSignaling.update().shouldBeEmpty() + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/translation/TranslationSourceManagerTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/translation/TranslationSourceManagerTest.kt new file mode 100644 index 0000000..1fa0eb8 --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/conference/translation/TranslationSourceManagerTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright @ 2024 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.conference.translation + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import org.jitsi.utils.MediaType + +class TranslationSourceManagerTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + /** A deterministic SSRC generator returning 100, 101, 102, ... */ + private var nextSsrc = 100L + private val sequentialGenerator: () -> Long = { nextSsrc++ } + + /** Every sender resolves to "-a0" unless overridden. */ + private val baseName: (String) -> String? = { "$it-a0" } + private val noneInUse: (Long) -> Boolean = { false } + + init { + val manager = TranslationSourceManager(sequentialGenerator) + + context("A single sender and language") { + val result = manager.update(mapOf("aaaaaaaa" to listOf("en")), baseName, noneInUse) + val sources = result.sourcesBySender["aaaaaaaa"]!! + + should("produce one synthetic audio source named .") { + sources.size shouldBe 1 + val source = sources.first() + source.name shouldBe "aaaaaaaa-a0.en" + source.mediaType shouldBe MediaType.AUDIO + source.synthetic shouldBe true + source.ssrc shouldBe 100L + } + should("report the request and export names for the connect") { + result.requestNames shouldContainExactly setOf("aaaaaaaa-a0.en") + result.exportNames shouldContainExactly setOf("aaaaaaaa-a0") + } + } + + context("Multiple languages for one sender") { + val result = manager.update(mapOf("aaaaaaaa" to listOf("en", "es")), baseName, noneInUse) + val sources = result.sourcesBySender["aaaaaaaa"]!! + + should("produce one source per language with distinct ssrcs") { + sources.map { it.name } shouldContainExactlyInAnyOrder listOf("aaaaaaaa-a0.en", "aaaaaaaa-a0.es") + sources.map { it.ssrc }.toSet().size shouldBe 2 + } + should("request both synthetic names") { + result.requestNames shouldContainExactlyInAnyOrder listOf("aaaaaaaa-a0.en", "aaaaaaaa-a0.es") + } + } + + context("Stability across updates") { + val first = manager.update(mapOf("aaaaaaaa" to listOf("en")), baseName, noneInUse) + val ssrc = first.sourcesBySender["aaaaaaaa"]!!.first().ssrc + + should("keep the same ssrc when re-applying the same request") { + val second = manager.update(mapOf("aaaaaaaa" to listOf("en")), baseName, noneInUse) + second.sourcesBySender["aaaaaaaa"]!!.first().ssrc shouldBe ssrc + } + should("keep existing ssrc and mint a fresh one when adding a language") { + val second = manager.update(mapOf("aaaaaaaa" to listOf("en", "es")), baseName, noneInUse) + val byName = second.sourcesBySender["aaaaaaaa"]!!.associateBy { it.name } + byName["aaaaaaaa-a0.en"]!!.ssrc shouldBe ssrc + byName["aaaaaaaa-a0.es"]!!.ssrc shouldBe 101L + } + should("drop a language that is no longer requested, keeping the other") { + manager.update(mapOf("aaaaaaaa" to listOf("en", "es")), baseName, noneInUse) + val third = manager.update(mapOf("aaaaaaaa" to listOf("es")), baseName, noneInUse) + val sources = third.sourcesBySender["aaaaaaaa"]!! + sources.size shouldBe 1 + sources.first().name shouldBe "aaaaaaaa-a0.es" + } + } + + context("SSRC collision avoidance") { + // 100 is already used by the conference; the manager must skip it and pick 101. + val result = manager.update(mapOf("aaaaaaaa" to listOf("en")), baseName, ssrcInUse = { it == 100L }) + should("mint an ssrc that does not collide with the conference") { + result.sourcesBySender["aaaaaaaa"]!!.first().ssrc shouldBe 101L + } + } + + context("Sender not present or without an audio source") { + val result = manager.update( + mapOf("aaaaaaaa" to listOf("en"), "bbbbbbbb" to listOf("fr")), + baseNameResolver = { if (it == "aaaaaaaa") "aaaaaaaa-a0" else null }, + noneInUse + ) + should("skip the unresolved sender and keep the resolved one") { + result.sourcesBySender.keys shouldContainExactly setOf("aaaaaaaa") + result.requestNames shouldContainExactly setOf("aaaaaaaa-a0.en") + } + } + + context("Sender's audio source appears on a later update") { + var hasAudio = false + val lateResolver: (String) -> String? = { if (it == "aaaaaaaa" && hasAudio) "aaaaaaaa-a0" else null } + + // Request arrives before the sender has an audio source: skipped, no synthetic source. + val first = manager.update(mapOf("aaaaaaaa" to listOf("es")), lateResolver, noneInUse) + + should("skip the sender while it has no audio source") { + first.isEmpty shouldBe true + } + should("create the synthetic source once the audio source appears and the same request is re-applied") { + hasAudio = true + val second = manager.update(mapOf("aaaaaaaa" to listOf("es")), lateResolver, noneInUse) + + second.sourcesBySender["aaaaaaaa"]!!.map { it.name } shouldContainExactly listOf("aaaaaaaa-a0.es") + } + } + + context("Empty requests") { + manager.update(mapOf("aaaaaaaa" to listOf("en")), baseName, noneInUse) + val result = manager.update(emptyMap(), baseName, noneInUse) + should("clear all synthetic sources") { + result.isEmpty shouldBe true + manager.allocatedSsrcs().isEmpty() shouldBe true + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/jibri/JibriDetectorTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/jibri/JibriDetectorTest.kt new file mode 100644 index 0000000..be9f90d --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/jibri/JibriDetectorTest.kt @@ -0,0 +1,181 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jibri + +import com.fasterxml.jackson.databind.node.ObjectNode +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.jibri.JibriDetector.Companion.FAILURE_TIMEOUT +import org.jitsi.jicofo.jibri.JibriDetector.Companion.SELECT_TIMEOUT +import org.jitsi.jicofo.xmpp.Features +import org.jitsi.jicofo.xmpp.XmppProvider +import org.jitsi.jicofo.xmpp.muc.ChatRoom +import org.jitsi.jicofo.xmpp.muc.ChatRoomMember +import org.jitsi.jicofo.xmpp.muc.MemberRole +import org.jitsi.jicofo.xmpp.muc.SourceInfo +import org.jitsi.utils.mins +import org.jitsi.utils.ms +import org.jitsi.utils.time.FakeClock +import org.jitsi.xmpp.extensions.health.HealthStatusPacketExt +import org.jitsi.xmpp.extensions.health.HealthStatusPacketExt.Health.HEALTHY +import org.jitsi.xmpp.extensions.health.HealthStatusPacketExt.Health.UNHEALTHY +import org.jitsi.xmpp.extensions.jibri.JibriBusyStatusPacketExt +import org.jitsi.xmpp.extensions.jibri.JibriStatusPacketExt +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.packet.Presence +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.Jid +import org.jxmpp.jid.impl.JidCreate + +class JibriDetectorTest : ShouldSpec({ + isolationMode = IsolationMode.InstancePerLeaf + + val mockXmppConnection = mockk() + val mockXmppProvider = mockk().apply { every { xmppConnection } returns mockXmppConnection } + val clock = FakeClock().apply { elapse(10.mins) } + val detector = JibriDetector(mockXmppProvider, JidCreate.entityBareFrom("brewery_name@example.com"), false, clock) + val jibriJids = listOf( + JidCreate.entityFullFrom("jibribrewery@bar.com/jibri1"), + JidCreate.entityFullFrom("jibribrewery@bar.com/jibri2"), + JidCreate.entityFullFrom("jibribrewery@bar.com/jibri3") + ) + + val jibriMembers = jibriJids.map { JibriChatRoomMember(it, detector) } + + context("Selecting a Jibri") { + context("When none of the instances are healthy") { + jibriMembers.forEach { it.setStatus(idle = true, healthy = false) } + detector.selectJibri() shouldBe null + } + context("When none of the instances are idle") { + jibriMembers.forEach { it.setStatus(idle = false, healthy = true) } + detector.selectJibri() shouldBe null + } + context("Select an idle and healthy jibri if one is available") { + jibriMembers[0].setStatus(idle = true, healthy = false) + jibriMembers[1].setStatus(idle = false, healthy = true) + jibriMembers[2].setStatus(idle = true, healthy = true) + + detector.selectJibri() shouldBe jibriJids[2] + } + context("Select timeout") { + val selection1 = detector.selectJibri() + selection1 shouldNotBe null + + val selection2 = detector.selectJibri() + selection2 shouldNotBe null + selection2 shouldNotBe selection1 + + val selection3 = detector.selectJibri() + selection3 shouldNotBe null + selection3 shouldNotBe selection1 + selection3 shouldNotBe selection2 + + val selection4 = detector.selectJibri() + selection4 shouldBe null + + clock.elapse(SELECT_TIMEOUT + 10.ms) + detector.selectJibri() shouldNotBe null + } + context("Instances failure") { + jibriMembers[0].setStatus(idle = false, healthy = true) + jibriMembers[1].setStatus(idle = true, healthy = true) + jibriMembers[2].setStatus(idle = true, healthy = true) + + detector.instanceFailed(jibriJids[1]) + detector.selectJibri() shouldBe jibriJids[2] + detector.selectJibri() shouldBe null // select timeout + + clock.elapse(SELECT_TIMEOUT + 100.ms) + detector.instanceFailed(jibriJids[2]) + // All idle instances failed recently. + detector.selectJibri() shouldBe null + + clock.elapse(FAILURE_TIMEOUT + 1.mins) + // jibri1 failed less recently + detector.selectJibri() shouldBe jibriJids[1] + + clock.elapse(FAILURE_TIMEOUT + 1.mins) + detector.instanceFailed(jibriJids[1]) + clock.elapse(SELECT_TIMEOUT + 100.ms) + detector.instanceFailed(jibriJids[2]) + clock.elapse(SELECT_TIMEOUT + 100.ms) + jibriMembers[1].setStatus(idle = true, healthy = true) + // Updated presence should clear the failure indication. + detector.selectJibri() shouldBe jibriJids[1] + jibriMembers[2].setStatus(idle = true, healthy = true) + // jibri1 is still in select timeout + detector.selectJibri() shouldBe jibriJids[2] + } + } +}) + +class JibriChatRoomMember( + override val occupantJid: EntityFullJid, + val detector: JibriDetector +) : ChatRoomMember { + override val role: MemberRole get() = TODO("Not yet implemented") + override val chatRoom: ChatRoom get() = TODO("Not yet implemented") + override val name: String get() = TODO("Not yet implemented") + override val jid: Jid? get() = TODO("Not yet implemented") + override val sourceInfos: Set get() = TODO("Not yet implemented") + override val isJigasi: Boolean get() = TODO("Not yet implemented") + override val isTranscriber: Boolean get() = TODO("Not yet implemented") + override val isJibri: Boolean get() = TODO("Not yet implemented") + override val isAudioMuted: Boolean get() = TODO("Not yet implemented") + override val isVideoMuted: Boolean get() = TODO("Not yet implemented") + override val diarize: Boolean get() = TODO("Not yet implemented") + override val translationLanguage: String? get() = TODO("Not yet implemented") + override val region: String? get() = TODO("Not yet implemented") + override val statsId: String? get() = TODO("Not yet implemented") + override val videoCodecs: List? get() = TODO("Not yet implemented") + override val features: Set get() = TODO("Not yet implemented") + override val featuresDiscovered: Boolean get() = TODO("Not yet implemented") + override val debugState: ObjectNode get() = TODO("Not yet implemented") + + var idle: Boolean = true + var healthy: Boolean = true + + init { + detector.processMemberPresence(this) + } + + fun setStatus(idle: Boolean, healthy: Boolean) { + this.idle = idle + this.healthy = healthy + detector.processMemberPresence(this) + } + + override val presence: Presence + get() = mockk { + every { + getExtensionElement(JibriStatusPacketExt.ELEMENT, JibriStatusPacketExt.NAMESPACE) + } answers { + JibriStatusPacketExt().apply { + healthStatus = HealthStatusPacketExt().apply { + status = if (healthy) HEALTHY else UNHEALTHY + } + busyStatus = JibriBusyStatusPacketExt().apply { + setAttribute("status", if (idle) "idle" else "busy") + } + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/jibri/JibriSessionTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/jibri/JibriSessionTest.kt new file mode 100644 index 0000000..124fcd0 --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/jibri/JibriSessionTest.kt @@ -0,0 +1,147 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2020 - Present, 8x8 Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.jibri + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.verify +import org.jitsi.jicofo.xmpp.sendIqAndGetResponse +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.LoggerImpl +import org.jitsi.xmpp.extensions.jibri.JibriIq +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import org.jxmpp.jid.impl.JidCreate + +class JibriSessionTest : ShouldSpec({ + isolationMode = IsolationMode.InstancePerLeaf + + val stateListener: JibriSession.StateListener = mockk(relaxed = true) + val roomName = JidCreate.entityBareFrom("room@bar.com/baz") + val initiator = JidCreate.bareFrom("foo@bar.com/baz") + val pendingTimeout = 60L + val maxNumRetries = 2 + // Mock the AbstractXMPPConnection.sendPacketAndGetReply extension function. + mockkStatic("org.jitsi.jicofo.xmpp.UtilKt") + val mockXmppConnection: AbstractXMPPConnection = mockk() + val jibriList = mutableListOf( + JidCreate.bareFrom("jibri1@bar.com"), + JidCreate.bareFrom("jibri2@bar.com"), + JidCreate.bareFrom("jibri3@bar.com") + ) + val detector: JibriDetector = mockk { + every { selectJibri() } returnsMany (jibriList) + every { isAnyInstanceConnected } returns true + every { xmppConnection } returns mockXmppConnection + every { addHandler(any()) } returns Unit + every { removeHandler(any()) } returns Unit + every { instanceFailed(any()) } returns Unit + } + val logger: Logger = LoggerImpl("JibriSessionTest") + + val jibriSession = JibriSession( + stateListener, + roomName, + initiator, + pendingTimeout, + maxNumRetries, + detector, + // isSIP + false, + // sipAddress + null, + "displayName", + "streamID", + "youTubeBroadcastId", + "sessionId", + "applicationData", + true, + logger + ) + + context("When sending a request to a Jibri to start a session throws an error") { + val iqRequests = mutableListOf() + every { mockXmppConnection.sendIqAndGetResponse(capture(iqRequests)) } answers { + // First return error + IQ.createErrorResponse(arg(1), StanzaError.Condition.service_unavailable) + } andThenAnswer { + // Then return a successful response + JibriIq().apply { + status = JibriIq.Status.PENDING + from = (arg(1) as IQ).to + } + } + context("Trying to start a Jibri session") { + should("retry with another jibri") { + jibriSession.start() + verify(exactly = 2) { mockXmppConnection.sendIqAndGetResponse(any()) } + iqRequests shouldHaveSize 2 + iqRequests[0].to shouldNotBe iqRequests[1].to + } + } + context("and that's the only Jibri") { + every { detector.selectJibri() } returns JidCreate.bareFrom("solo@bar.com") + every { mockXmppConnection.sendIqAndGetResponse(capture(iqRequests)) } answers { + // First return error + IQ.createErrorResponse(arg(1), StanzaError.Condition.service_unavailable) + } + context("trying to start a jibri session") { + should("give up after exceeding the retry count") { + shouldThrow { + jibriSession.start() + } + verify(exactly = maxNumRetries + 1) { mockXmppConnection.sendIqAndGetResponse(any()) } + } + } + } + } + context("Trying to start a session with a Jibri that is busy") { + val iq = slot() + // First return busy, then pending + every { mockXmppConnection.sendIqAndGetResponse(capture(iq)) } answers { + JibriIq().apply { + type = IQ.Type.result + from = iq.captured.to + to = iq.captured.from + shouldRetry = true + status = JibriIq.Status.OFF + failureReason = JibriIq.FailureReason.BUSY + } + } andThenAnswer { + JibriIq().apply { + type = IQ.Type.result + from = iq.captured.to + to = iq.captured.from + shouldRetry = true + status = JibriIq.Status.PENDING + } + } + jibriSession.start() + should("retry with another jibri") { + verify(exactly = 2) { mockXmppConnection.sendIqAndGetResponse(any()) } + } + } +}) diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/jigasi/JigasiSelectorTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/jigasi/JigasiSelectorTest.kt new file mode 100644 index 0000000..358aa7a --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/jigasi/JigasiSelectorTest.kt @@ -0,0 +1,143 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.mockk +import org.jitsi.jicofo.jigasi.JigasiDetector +import org.jitsi.xmpp.extensions.colibri.ColibriStatsExtension +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.impl.JidCreate + +class JigasiSelectorTest : ShouldSpec() { + init { + val r1 = "region1" + val r2 = "region2" + val r3 = "region3" + val selector = JigasiTestDetector(localRegion = r2) + var numberOfInstances = 1 + fun nextJid() = JidCreate.entityFullFrom("brewery@example.com/jigasi-${numberOfInstances++}") + + context("Jigasi selection") { + selector.selectSipJigasi() shouldBe null + selector.selectTranscriber() shouldBe null + val sipJigasi1 = nextJid().also { selector.updateStats(it, participants = 1) } + val sipJigasi2 = nextJid().also { selector.updateStats(it, participants = 2) } + selector.selectTranscriber() shouldBe null + selector.selectSipJigasi() shouldNotBe null + val transcriber1 = nextJid().also { + selector.updateStats(it, participants = 1, region = r1, sip = false, transcriber = true) + } + val transcriber2 = nextJid().also { + selector.updateStats(it, participants = 2, region = r2, sip = false, transcriber = true) + } + selector.selectTranscriber() shouldNotBe null + + // graceful shutdown + selector.updateStats(sipJigasi1, participants = 1, inGracefulShutdown = true) + selector.selectSipJigasi() shouldBe sipJigasi2 + + // select by preferred regions + // should select based on participant as no region reported by instances + selector.updateStats(sipJigasi1, participants = 1) + selector.updateStats(sipJigasi2, participants = 2) + selector.selectSipJigasi(preferredRegions = listOf(r2, r3)) shouldBe sipJigasi1 + + selector.updateStats(sipJigasi1, participants = 1, region = r1) + selector.updateStats(sipJigasi2, participants = 2, region = r2) + + // Select from a preferred region + selector.selectSipJigasi(preferredRegions = listOf(r2, r3)) shouldBe sipJigasi2 + // With no jigasi in the preferred region, select from the local region + selector.selectSipJigasi(preferredRegions = listOf(r3)) shouldBe sipJigasi2 + // With no jigasi in the preferred region, select based on participant count + selector.updateStats(sipJigasi1, participants = 1, region = r2) + selector.selectSipJigasi(preferredRegions = listOf(r3)) shouldBe sipJigasi1 + + selector.updateStats(sipJigasi1, participants = 1, region = r1) + + // select by local region + // no matching region, selects based on local region + selector.selectSipJigasi(preferredRegions = listOf(r3)) shouldBe sipJigasi2 + selector.selectSipJigasi() shouldBe sipJigasi2 + + // filter + // should select from region2, but that is filtered so will select + // based on participants -> jid1 + selector.selectSipJigasi( + exclude = listOf(sipJigasi2), + preferredRegions = listOf(r2, r3) + ) shouldBe sipJigasi1 + + // Both sip and transcriber should be selected based on local region over participant count + selector.selectSipJigasi() shouldBe sipJigasi2 + selector.selectTranscriber() shouldBe transcriber2 + selector.selectTranscriber(preferredRegions = listOf(r2, r3)) shouldBe transcriber2 + + // Move jid3 to the local region + selector.updateStats(transcriber1, participants = 1, region = r2, sip = false, transcriber = true) + selector.selectTranscriber() shouldBe transcriber1 + selector.selectTranscriber(preferredRegions = listOf(r2, r3)) shouldBe transcriber1 + + // transcriber no matching region, select based on participants + selector.selectTranscriber(preferredRegions = listOf(r3)) shouldBe transcriber1 + + // transcriber no matching region, select based on participants, but + // with filtered jid3(which has lowest number of participants) + selector.selectTranscriber( + exclude = listOf(transcriber1), + preferredRegions = listOf(r3) + ) shouldBe transcriber2 + } + } +} + +private class JigasiTestDetector(localRegion: String) : JigasiDetector( + mockk(relaxed = true), + mockk(relaxed = true), + localRegion +) { + /** Update stats for the instance with [jid]. Creates a new instance if necessary */ + fun updateStats( + jid: EntityFullJid, + participants: Int? = null, + region: String? = null, + inGracefulShutdown: Boolean? = null, + transcriber: Boolean = false, + sip: Boolean = true + ) = processInstanceStatusChanged( + jid, + ColibriStatsExtension().apply { + participants?.let { + addStat(ColibriStatsExtension.Stat(ColibriStatsExtension.PARTICIPANTS, it)) + } + region?.let { + addStat(ColibriStatsExtension.Stat(ColibriStatsExtension.REGION, it)) + } + inGracefulShutdown?.let { + addStat(ColibriStatsExtension.Stat(ColibriStatsExtension.SHUTDOWN_IN_PROGRESS, it)) + } + if (transcriber) { + addStat(ColibriStatsExtension.Stat(ColibriStatsExtension.SUPPORTS_TRANSCRIPTION, true)) + } + if (sip) { + addStat(ColibriStatsExtension.Stat(ColibriStatsExtension.SUPPORTS_SIP, true)) + } + } + ) +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/mock/ColibriAndJingleXmppConnection.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/mock/ColibriAndJingleXmppConnection.kt new file mode 100644 index 0000000..d2c719a --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/mock/ColibriAndJingleXmppConnection.kt @@ -0,0 +1,138 @@ +/* + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.mock + +import org.jitsi.jicofo.conference.source.EndpointSourceSet +import org.jitsi.jicofo.conference.source.Source +import org.jitsi.utils.MediaType +import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ +import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension +import org.jitsi.xmpp.extensions.jingle.DtlsFingerprintPacketExtension +import org.jitsi.xmpp.extensions.jingle.IceRtcpmuxPacketExtension +import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension +import org.jitsi.xmpp.extensions.jingle.JingleAction +import org.jitsi.xmpp.extensions.jingle.JingleIQ +import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension +import org.jivesoftware.smack.packet.IQ +import org.jxmpp.jid.Jid + +/** + * Mocks an [AbstractXMPPConnection] which responds to colibri2 and Jingle IQs. Creates [RemoteParticipant]s that model + * the remote side of a Jingle session. + */ +class ColibriAndJingleXmppConnection : MockXmppConnection() { + var ssrcs = 1L + val colibri2Server = TestColibri2Server() + val remoteParticipants = mutableMapOf() + + // IQs sent by jicofo + val requests = mutableListOf() + + override fun handleIq(iq: IQ): IQ? = when (iq) { + is ConferenceModifyIQ -> colibri2Server.handleConferenceModifyIq(iq) + + is JingleIQ -> remoteParticipants.computeIfAbsent(iq.to) { RemoteParticipant(iq.to) }.handleJingleIq(iq) + + else -> { + println("Not handling ${iq.toXML()}") + null + } + }.also { + requests.add(iq) + } + + private fun nextSource(mediaType: MediaType) = Source(ssrcs++, mediaType) + + /** + * Model the remote side of a [Participant], i.e. the entity that would respond to Jingle requests sent from + * jicofo. + */ + inner class RemoteParticipant(jid: Jid) { + var sources = EndpointSourceSet(setOf(nextSource(MediaType.AUDIO), nextSource(MediaType.VIDEO))) + val requests = mutableListOf() + fun handleJingleIq(iq: JingleIQ) = IQ.createResultIQ(iq).also { requests.add(iq) } + + val sessionInitiate: JingleIQ + get() = requests.find { it.action == JingleAction.SESSION_INITIATE } + ?: throw IllegalStateException("session-initiate not received") + + fun createSourceAdd(sources: EndpointSourceSet) = JingleIQ(JingleAction.SOURCEADD, sessionInitiate.sid).apply { + from = sessionInitiate.to + type = IQ.Type.set + to = sessionInitiate.from + sources.toJingle().forEach { addContent(it) } + } + fun createSourceRemove(sources: EndpointSourceSet) = + JingleIQ(JingleAction.SOURCEREMOVE, sessionInitiate.sid).apply { + from = sessionInitiate.to + type = IQ.Type.set + to = sessionInitiate.from + sources.toJingle().forEach { addContent(it) } + } + + fun nextSource(mediaType: MediaType) = this@ColibriAndJingleXmppConnection.nextSource(mediaType) + + fun createSessionAccept(): JingleIQ { + val accept = JingleIQ(JingleAction.SESSION_ACCEPT, sessionInitiate.sid).apply { + type = IQ.Type.set + from = sessionInitiate.to + to = sessionInitiate.from + } + + val audioContent = ContentPacketExtension().apply { + name = "audio" + creator = ContentPacketExtension.CreatorEnum.responder // xxx + addChildExtension(RtpDescriptionPacketExtension().apply { media = "audio" }) + sources.sources.filter { it.mediaType == MediaType.AUDIO }.forEach { + addChildExtension(it.toPacketExtension()) + } + } + + val videoContent = ContentPacketExtension().apply { + name = "video" + creator = ContentPacketExtension.CreatorEnum.responder // xxx + addChildExtension(RtpDescriptionPacketExtension().apply { media = "video" }) + sources.sources.filter { it.mediaType == MediaType.VIDEO }.forEach { + addChildExtension(it.toPacketExtension()) + } + } + + videoContent.addChildExtension(createTransport(sessionInitiate)) + + accept.addContent(audioContent) + accept.addContent(videoContent) + + return accept + } + } +} + +private fun createTransport(sessionInitiate: JingleIQ) = IceUdpTransportPacketExtension().apply { + addChildExtension(IceRtcpmuxPacketExtension()) + addChildExtension( + DtlsFingerprintPacketExtension().apply { + val offerFp = sessionInitiate.contentList.firstNotNullOf { + it.getFirstChildOfType(IceUdpTransportPacketExtension::class.java) + }.getFirstChildOfType(DtlsFingerprintPacketExtension::class.java) + + hash = offerFp.hash + fingerprint = offerFp.fingerprint + .replace("A", "B") + .replace("1", "2") + .replace("C", "D") + } + ) +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/mock/ConferenceHarness.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/mock/ConferenceHarness.kt new file mode 100644 index 0000000..67abc9b --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/mock/ConferenceHarness.kt @@ -0,0 +1,136 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.mock + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.conference.JitsiMeetConferenceImpl +import org.jitsi.jicofo.conference.Participant +import org.jitsi.jicofo.xmpp.VisitorsManager +import org.jitsi.jicofo.xmpp.jingle.JingleSession +import org.jitsi.jicofo.xmpp.muc.ChatRoomMember +import org.jitsi.xmpp.extensions.visitors.ConnectVnodePacketExtension +import org.jitsi.xmpp.extensions.visitors.DisconnectVnodePacketExtension +import org.jivesoftware.smack.packet.ExtensionElement +import org.jxmpp.jid.impl.JidCreate +import java.util.logging.Level + +/** + * Wires up a real [JitsiMeetConferenceImpl] against mock XMPP (colibri2 and Jingle responders, a mock chat room) so + * conference-level behavior can be tested without a real XMPP connection or bridge. + */ +class ConferenceHarness( + roomNameString: String = "test@example.com", + /** The XMPP domain of the main connection. It must match the domain of [roomNameString]. */ + val xmppDomain: String = "example.com", + /** The names of the visitor nodes to make available to the conference. */ + visitorNodeNames: List = emptyList() +) { + val roomName = JidCreate.entityBareFrom(roomNameString) + val xmppConnection = ColibriAndJingleXmppConnection() + val jingleSessions = mutableListOf() + val xmppProvider = MockXmppProvider(xmppConnection.xmppConnection, "client", xmppDomain) + val chatRoom = xmppProvider.getRoom(roomName) + + /** The mock XMPP connections to the visitor nodes, mapped by node name. */ + val visitorProviders: Map = visitorNodeNames.associateWith { + MockXmppProvider(xmppConnection.xmppConnection, it, "$it.$xmppDomain") + } + + /** The extensions of the VisitorsIqs that the conference sent to the visitors component. */ + val visitorsIqExtensions = mutableListOf>() + + val visitorsManager: VisitorsManager = mockk(relaxed = true) { + every { sendIqToComponent(any(), capture(visitorsIqExtensions)) } returns Unit + every { sendIqToComponentAndGetResponse(any(), capture(visitorsIqExtensions)) } returns null + } + + /** + * The visitor room on the node [node] for this conference. The conference joins this same room, because it looks + * it up by JID with [MockXmppProvider.getRoom]. + */ + fun visitorRoom(node: String): MockChatRoom = visitorProviders[node]!!.getRoom( + JidCreate.entityBareFrom(roomName.toString().replace(xmppDomain, "$node.$xmppDomain")) + ) + + /** The names of the visitor nodes that the conference asked the visitors component to disconnect. */ + fun disconnectedVnodes(): List = visitorsIqExtensions.flatten() + .filterIsInstance().map { it.vnode } + + /** The names of the visitor nodes that the conference asked the visitors component to connect. */ + fun connectedVnodes(): List = visitorsIqExtensions.flatten() + .filterIsInstance().map { it.vnode } + + /** Whether the conference has ended (fired conferenceEnded on its listener). */ + var ended = false + private set + + private var memberCounter = 1 + private fun nextMemberId() = "member-${memberCounter++}" + + val conference: JitsiMeetConferenceImpl = JitsiMeetConferenceImpl( + roomName, + mockk { + every { conferenceEnded(any()) } answers { ended = true } + every { meetingIdSet(any(), any()) } returns true + }, + HashMap(), + Level.INFO, + null, + false, + mockk(relaxed = true) { + every { clientConnection } returns xmppProvider.xmppProvider + every { serviceConnection } returns xmppProvider.xmppProvider + every { jingleHandler } returns mockk(relaxed = true) { + every { registerSession(capture(jingleSessions)) } returns Unit + } + every { visitorConnections } returns visitorProviders.values.map { it.xmppProvider } + every { getXmppVisitorConnectionByName(any()) } answers { + visitorProviders[arg(0)]?.xmppProvider + } + every { visitorsManager } returns this@ConferenceHarness.visitorsManager + }, + mockk(relaxed = true) { + every { selectBridge(any(), any(), any()) } returns mockk(relaxed = true) { + every { jid } returns JidCreate.from("jvb@example.com/jvb1") + every { debugState } returns JsonNodeFactory.instance.objectNode() + } + }, + null, + null, + null, + mockk(relaxed = true) + ).apply { start() } + + /** + * Add [n] members to the chat room and accept the Jingle sessions that jicofo initiates towards them. + */ + fun addParticipants(n: Int): List { + val members = buildList { repeat(n) { add(chatRoom.addMember(nextMemberId())) } } + + members.forEach { member -> + val remoteParticipant = xmppConnection.remoteParticipants[member.occupantJid]!! + val jingleSession = jingleSessions.find { it.sid == remoteParticipant.sessionInitiate.sid }!! + jingleSession.processIq(remoteParticipant.createSessionAccept()) + } + return members + } + + fun getParticipant(member: ChatRoomMember): Participant? = conference.getParticipant(member.occupantJid) + fun getRemoteParticipant(member: ChatRoomMember): ColibriAndJingleXmppConnection.RemoteParticipant? = + xmppConnection.remoteParticipants[member.occupantJid] +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/util/ListConferenceStore.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/util/ListConferenceStore.kt new file mode 100644 index 0000000..0b302bf --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/util/ListConferenceStore.kt @@ -0,0 +1,32 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import org.jitsi.jicofo.ConferenceStore +import org.jitsi.jicofo.PinnedConference +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jxmpp.jid.EntityBareJid +import java.time.Duration + +class ListConferenceStore : ConferenceStore, MutableList by ArrayList() { + override fun getAllConferences() = this + override fun getConference(jid: EntityBareJid) = find { it.roomName == jid } + override fun getPinnedConferences(): List = listOf() + override fun pinConference(roomName: EntityBareJid, jvbVersion: String, duration: Duration) { } + override fun unpinConference(roomName: EntityBareJid) {} +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/util/PreferenceAggregatorTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/util/PreferenceAggregatorTest.kt new file mode 100644 index 0000000..acce3ba --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/util/PreferenceAggregatorTest.kt @@ -0,0 +1,109 @@ +package org.jitsi.jicofo.util + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import org.jitsi.utils.logging2.createLogger + +class PreferenceAggregatorTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val logger = createLogger() + + private val calledWith = mutableListOf>() + private val aggregator = PreferenceAggregator(logger) { + calledWith.add(it) + } + + init { + context("An aggregator with no values added") { + should("Not call its callback") { + calledWith shouldBe emptyList() + } + } + context("An aggregator called once") { + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + should("Call the callback exactly once with that set of values") { + calledWith shouldContainExactly listOf(listOf("vp9", "vp8", "h264")) + } + } + context("An aggregator called twice with the same values") { + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + should("Call the callback exactly once") { + calledWith shouldContainExactly listOf(listOf("vp9", "vp8", "h264")) + } + } + context("An aggregator with all preferences removed") { + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + aggregator.removePreference(listOf("vp9", "vp8", "h264")) + should("Have its final output be the empty set") { + calledWith.last() shouldBe emptyList() + } + } + context("Aggregating preferences with disparate values (subset)") { + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + aggregator.addPreference(listOf("vp8", "h264")) + should("Output the minimal agreed set") { + calledWith.last().shouldContainExactly("vp8", "h264") + } + } + context("Aggregating preferences with disparate values (non-subset)") { + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + aggregator.addPreference(listOf("vp8", "h264")) + aggregator.addPreference(listOf("vp9", "vp8")) + should("Output the minimal agreed set") { + calledWith.last().shouldContainExactly("vp8") + } + } + context("Aggregating a new superset") { + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + aggregator.addPreference(listOf("av1", "vp9", "vp8", "h264")) + should("Not call the callback a second time") { + calledWith shouldContainExactly listOf(listOf("vp9", "vp8", "h264")) + } + } + context("Removing the only preference that does not support a value") { + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + aggregator.addPreference(listOf("vp8", "h264")) + aggregator.addPreference(listOf("vp9", "vp8")) + + aggregator.removePreference(listOf("vp8", "h264")) + should("Return that value to the set of preferences") { + calledWith.last().shouldContainExactly(listOf("vp9", "vp8")) + } + } + context("Preferences that express different orders") { + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + aggregator.addPreference(listOf("vp9", "h264", "vp8")) + + should("Reflect the majority preference") { + calledWith shouldContainExactly listOf(listOf("vp9", "vp8", "h264")) + } + } + context("Ties in preference order") { + aggregator.addPreference(listOf("vp9", "vp8", "h264")) + aggregator.addPreference(listOf("vp9", "h264", "vp8")) + + should("Result in the correct set, in some order, with consensus where it exists") { + calledWith.last().shouldContainExactlyInAnyOrder("h264", "vp9", "vp8") + calledWith.last().first() shouldBe "vp9" + } + } + context("Repeated values in preferences") { + aggregator.addPreference(listOf("vp9", "vp8")) + aggregator.addPreference(listOf("vp9", "vp8", "vp9")) + should("not confuse things") { + calledWith shouldContainExactly listOf(listOf("vp9", "vp8")) + } + aggregator.removePreference(listOf("vp9", "vp8", "vp9")) + aggregator.removePreference(listOf("vp9", "vp8")) + should("not confuse things on removal") { + calledWith.last().shouldContainExactly(emptyList()) + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/util/RateLimitedStatTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/util/RateLimitedStatTest.kt new file mode 100644 index 0000000..17387ac --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/util/RateLimitedStatTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.util + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.TaskPools +import org.jitsi.utils.time.FakeClock +import java.time.Duration +import java.util.concurrent.ScheduledExecutorService + +class RateLimitedStatTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val clock = FakeClock() + private val reported = mutableListOf() + + /** Tasks scheduled for later execution, run them with [runPending]. */ + private val pendingTasks = mutableListOf() + private val capturingScheduler: ScheduledExecutorService = mockk { + every { schedule(any(), any(), any()) } answers { + pendingTasks.add(firstArg()) + mockk(relaxed = true) + } + } + + private fun runPending() { + val tasks = pendingTasks.toList() + pendingTasks.clear() + tasks.forEach { it.run() } + } + + private val interval = Duration.ofSeconds(10) + private val stat = RateLimitedStat(interval, { reported.add(it) }, initialValue = 0, clock = clock) + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.scheduledPool = capturingScheduler + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetScheduledPool() + } + + init { + context("The first change is reported immediately") { + stat.value = 1 + reported shouldBe listOf(1) + pendingTasks.size shouldBe 0 + } + context("Changes within the interval are batched") { + stat.value = 1 + reported shouldBe listOf(1) + + clock.elapse(Duration.ofSeconds(1)) + stat.adjustValue(1) + stat.adjustValue(1) + reported shouldBe listOf(1) + // Only one update task should have been scheduled. + pendingTasks.size shouldBe 1 + + runPending() + reported shouldBe listOf(1, 3) + stat.value shouldBe 3 + } + context("A change after the interval has passed is reported immediately") { + stat.value = 1 + reported shouldBe listOf(1) + + clock.elapse(interval.plusSeconds(1)) + stat.value = 2 + reported shouldBe listOf(1, 2) + pendingTasks.size shouldBe 0 + } + context("adjustValue accumulates") { + stat.adjustValue(5) + stat.value shouldBe 5 + clock.elapse(Duration.ofSeconds(1)) + stat.adjustValue(-2) + stat.value shouldBe 3 + runPending() + reported shouldBe listOf(5, 3) + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/AvModerationHandlerTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/AvModerationHandlerTest.kt new file mode 100644 index 0000000..7c67a5b --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/AvModerationHandlerTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.jitsi.jicofo.MediaType +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jitsi.jicofo.util.ListConferenceStore +import org.jitsi.jicofo.xmpp.muc.ChatRoom +import org.jitsi.xmpp.extensions.jitsimeet.JsonMessageExtension +import org.jivesoftware.smack.packet.StanzaBuilder +import org.jxmpp.jid.impl.JidCreate + +class AvModerationHandlerTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val roomJid = JidCreate.entityBareFrom("conf1@conference.example.com") + private val componentAddress = "avmoderation.example.com" + + private val chatRoom: ChatRoom = mockk(relaxed = true) { + every { queueXmppTask(any()) } answers { firstArg<() -> Unit>().invoke() } + every { isAvModerationEnabled(any()) } returns false + } + private val conference: JitsiMeetConference = mockk(relaxed = true) { + every { roomName } returns roomJid + every { chatRoom } returns this@AvModerationHandlerTest.chatRoom + } + private val conferenceStore = ListConferenceStore().apply { add(conference) } + + private val xmppProvider: XmppProvider = mockk(relaxed = true) { + every { components } returns setOf(XmppProvider.Component("av_moderation", componentAddress)) + } + + private val handler = AvModerationHandler(xmppProvider, conferenceStore) + + private fun process(json: String, from: String = componentAddress) = handler.processStanza( + StanzaBuilder.buildMessage() + .from(JidCreate.from(from)) + .addExtension(JsonMessageExtension(json)) + .build() + ) + + init { + context("Enabling moderation") { + process( + """ + {"type":"av_moderation", "room":"$roomJid", "enabled":true, "mediaType":"audio", + "actor":"$roomJid/actor"} + """.trimIndent() + ) + verify(exactly = 1) { chatRoom.setAvModerationEnabled(MediaType.AUDIO, true) } + verify(exactly = 1) { + conference.muteAllParticipants(MediaType.AUDIO, JidCreate.entityFullFrom("$roomJid/actor")) + } + } + context("Enabling moderation when it is already enabled") { + every { chatRoom.isAvModerationEnabled(MediaType.AUDIO) } returns true + process("""{"type":"av_moderation", "room":"$roomJid", "enabled":true, "mediaType":"audio"}""") + verify(exactly = 1) { chatRoom.setAvModerationEnabled(MediaType.AUDIO, true) } + verify(exactly = 0) { conference.muteAllParticipants(any(), any()) } + } + context("Disabling moderation") { + process("""{"type":"av_moderation", "room":"$roomJid", "enabled":false, "mediaType":"video"}""") + verify(exactly = 1) { chatRoom.setAvModerationEnabled(MediaType.VIDEO, false) } + verify(exactly = 0) { conference.muteAllParticipants(any(), any()) } + } + context("Setting whitelists") { + process("""{"type":"av_moderation", "room":"$roomJid", "whitelists":{"audio":["a","b"],"video":["c"]}}""") + verify(exactly = 1) { chatRoom.setAvModerationWhitelist(MediaType.AUDIO, listOf("a", "b")) } + verify(exactly = 1) { chatRoom.setAvModerationWhitelist(MediaType.VIDEO, listOf("c")) } + verify(exactly = 0) { chatRoom.setAvModerationEnabled(any(), any()) } + } + context("Messages that should be ignored") { + should("ignore messages from another address") { + process( + """{"type":"av_moderation", "room":"$roomJid", "enabled":true, "mediaType":"audio"}""", + from = "attacker.example.com" + ) + verify(exactly = 0) { chatRoom.setAvModerationEnabled(any(), any()) } + } + should("ignore invalid JSON") { + process("not json") + process("""{"type":"unknown_type"}""") + verify(exactly = 0) { chatRoom.setAvModerationEnabled(any(), any()) } + } + should("ignore messages for an unknown room") { + process( + """ + {"type":"av_moderation", "room":"other@conference.example.com", "enabled":true, + "mediaType":"audio"} + """.trimIndent() + ) + verify(exactly = 0) { chatRoom.setAvModerationEnabled(any(), any()) } + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/MuteIqHandlerTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/MuteIqHandlerTest.kt new file mode 100644 index 0000000..c86861d --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/MuteIqHandlerTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.MediaType +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jitsi.jicofo.conference.MuteResult +import org.jitsi.jicofo.mock.MockXmppConnection +import org.jitsi.jicofo.util.ListConferenceStore +import org.jitsi.jicofo.xmpp.IqProcessingResult.AcceptedWithNoResponse +import org.jitsi.jicofo.xmpp.IqProcessingResult.RejectedWithError +import org.jitsi.jicofo.xmpp.muc.ChatRoom +import org.jitsi.xmpp.extensions.jitsimeet.MuteIq +import org.jitsi.xmpp.extensions.jitsimeet.MuteVideoIq +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.Stanza +import org.jivesoftware.smack.packet.StanzaError +import org.jxmpp.jid.Jid +import org.jxmpp.jid.impl.JidCreate + +class MuteIqHandlerTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val roomJid = JidCreate.entityBareFrom("conf1@conference.example.com") + private val muter: Jid = JidCreate.from("$roomJid/muter") + private val mutee: Jid = JidCreate.from("$roomJid/mutee") + + /** Stanzas sent by the handler (results, errors, and mute notifications). */ + private val sentStanzas = mutableListOf() + private val xmppConnection = object : MockXmppConnection() { + override fun handleIq(iq: IQ): IQ? = null.also { sentStanzas.add(iq) } + } + + private var muteResult = MuteResult.SUCCESS + private val chatRoom: ChatRoom = mockk(relaxed = true) { + every { queueXmppTask(any()) } answers { firstArg<() -> Unit>().invoke() } + } + private val conference: JitsiMeetConference = mockk(relaxed = true) { + every { roomName } returns roomJid + every { chatRoom } returns this@MuteIqHandlerTest.chatRoom + every { handleMuteRequest(any(), any(), any(), any()) } answers { muteResult } + } + private val conferenceStore = ListConferenceStore().apply { add(conference) } + + private val handler = AudioMuteIqHandler(setOf(xmppConnection.xmppConnection), conferenceStore) + + private fun request(jidToMute: Jid? = mutee, doMute: Boolean? = true, from: Jid = muter): IqProcessingResult { + val iq = MuteIq().apply { + this.from = from + this.type = IQ.Type.set + jidToMute?.let { this.jid = it } + doMute?.let { this.mute = it } + } + return handler.handleRequest(IqRequest(iq, xmppConnection.xmppConnection)) + } + + init { + context("Missing fields") { + should("reject a request without a jid") { + request(jidToMute = null).shouldBeInstanceOf().response.error.condition shouldBe + StanzaError.Condition.bad_request + } + should("reject a request without mute") { + request(doMute = null).shouldBeInstanceOf().response.error.condition shouldBe + StanzaError.Condition.bad_request + } + } + context("Unknown conference") { + request(from = JidCreate.from("other@conference.example.com/muter")) + .shouldBeInstanceOf().response.error.condition shouldBe + StanzaError.Condition.item_not_found + } + context("A successful remote mute") { + request().shouldBeInstanceOf() + + sentStanzas.size shouldBe 2 + (sentStanzas[0] as IQ).type shouldBe IQ.Type.result + + // The muted participant should be notified, with the actor set. + val muteIq = sentStanzas[1].shouldBeInstanceOf() + muteIq.to shouldBe mutee + muteIq.mute shouldBe true + muteIq.actor shouldBe muter + } + context("A successful self mute") { + request(jidToMute = muter, from = muter).shouldBeInstanceOf() + + // No notification should be sent for a self-mute, just the result. + sentStanzas.size shouldBe 1 + (sentStanzas[0] as IQ).type shouldBe IQ.Type.result + } + context("A mute that is not allowed") { + muteResult = MuteResult.NOT_ALLOWED + request().shouldBeInstanceOf() + + sentStanzas.size shouldBe 1 + val response = sentStanzas[0].shouldBeInstanceOf() + response.type shouldBe IQ.Type.error + response.error.condition shouldBe StanzaError.Condition.not_allowed + } + context("A mute that fails") { + muteResult = MuteResult.ERROR + request().shouldBeInstanceOf() + + sentStanzas.size shouldBe 1 + val response = sentStanzas[0].shouldBeInstanceOf() + response.type shouldBe IQ.Type.error + response.error.condition shouldBe StanzaError.Condition.internal_server_error + } + context("Video mute") { + val videoHandler = VideoMuteIqHandler(setOf(xmppConnection.xmppConnection), conferenceStore) + val iq = MuteVideoIq().apply { + from = muter + type = IQ.Type.set + jid = mutee + mute = true + } + videoHandler.handleRequest(IqRequest(iq, xmppConnection.xmppConnection)) + .shouldBeInstanceOf() + + io.mockk.verify { conference.handleMuteRequest(muter, mutee, true, MediaType.VIDEO) } + val muteIq = sentStanzas[1].shouldBeInstanceOf() + muteIq.to shouldBe mutee + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/RoomMetadataHandlerTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/RoomMetadataHandlerTest.kt new file mode 100644 index 0000000..fb32815 --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/RoomMetadataHandlerTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jitsi.jicofo.util.ListConferenceStore +import org.jitsi.jicofo.xmpp.muc.ChatRoom +import org.jitsi.xmpp.extensions.jitsimeet.JsonMessageExtension +import org.jivesoftware.smack.packet.StanzaBuilder +import org.jxmpp.jid.impl.JidCreate + +class RoomMetadataHandlerTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val roomJid = JidCreate.entityBareFrom("conf1@conference.example.com") + private val componentAddress = "metadata.example.com" + + private val chatRoom: ChatRoom = mockk(relaxed = true) + private val conference: JitsiMeetConference = mockk(relaxed = true) { + every { roomName } returns roomJid + every { chatRoom } returns this@RoomMetadataHandlerTest.chatRoom + } + private val conferenceStore = ListConferenceStore().apply { add(conference) } + + private val xmppProvider: XmppProvider = mockk(relaxed = true) { + every { components } returns setOf(XmppProvider.Component("room_metadata", componentAddress)) + } + + private val handler = RoomMetadataHandler(xmppProvider, conferenceStore) + + private fun process(json: String, room: String? = roomJid.toString(), from: String = componentAddress) = + handler.processStanza( + StanzaBuilder.buildMessage() + .from(JidCreate.from(from)) + .addExtension(JsonMessageExtension(json).apply { room?.let { setAttribute("room", it) } }) + .build() + ) + + init { + context("A valid message") { + val metadata = slot() + every { chatRoom.setRoomMetadata(capture(metadata)) } returns Unit + + process( + """ + {"type":"room_metadata", "metadata": {"visitors": {"live": true}, "participantsSoftLimit": 50, + "moderators": ["m1"]}} + """.trimIndent() + ) + + metadata.captured.metadata?.visitors?.live shouldBe true + metadata.captured.metadata?.participantsSoftLimit shouldBe 50 + metadata.captured.metadata?.moderators shouldBe listOf("m1") + } + context("Messages that should be ignored") { + should("ignore messages from another address") { + process("""{"type":"room_metadata", "metadata": {}}""", from = "attacker.example.com") + verify(exactly = 0) { chatRoom.setRoomMetadata(any()) } + } + should("ignore messages without a room attribute") { + process("""{"type":"room_metadata", "metadata": {}}""", room = null) + verify(exactly = 0) { chatRoom.setRoomMetadata(any()) } + } + should("ignore messages for an unknown room") { + process("""{"type":"room_metadata", "metadata": {}}""", room = "other@conference.example.com") + verify(exactly = 0) { chatRoom.setRoomMetadata(any()) } + } + should("ignore invalid JSON") { + process("not json") + process("""{"type":"av_moderation", "room":"$roomJid"}""") + verify(exactly = 0) { chatRoom.setRoomMetadata(any()) } + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/VisitorConnectionMonitorTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/VisitorConnectionMonitorTest.kt new file mode 100644 index 0000000..5ac866b --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/VisitorConnectionMonitorTest.kt @@ -0,0 +1,82 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.mock.MockXmppConnection +import org.jitsi.jicofo.mock.MockXmppProvider +import org.jitsi.jicofo.mock.inPlaceExecutor + +class VisitorConnectionMonitorTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.ioPool = inPlaceExecutor + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetIoPool() + } + + init { + val v1 = MockXmppProvider(MockXmppConnection().xmppConnection, "v1") + val v2 = MockXmppProvider(MockXmppConnection().xmppConnection, "v2") + val resetNodes = mutableListOf() + val monitor = VisitorConnectionMonitor(listOf(v1.xmppProvider, v2.xmppProvider)) { resetNodes.add(it) } + + context("When a visitor connection authenticates") { + should("not report a reset if the stream was resumed") { + v1.authenticated(resumed = true) + + resetNodes.shouldBeEmpty() + } + should("report a reset if the stream was not resumed") { + v1.authenticated(resumed = false) + + resetNodes shouldContainExactly listOf("v1") + } + should("report a reset for the node that reconnected only") { + v2.authenticated(resumed = false) + v1.authenticated(resumed = true) + + resetNodes shouldContainExactly listOf("v2") + } + should("report every reset") { + v1.authenticated(resumed = false) + v2.authenticated(resumed = false) + v1.authenticated(resumed = false) + + resetNodes shouldContainExactly listOf("v1", "v2", "v1") + } + } + context("After shutdown") { + should("not report a reset") { + monitor.shutdown() + v1.authenticated(resumed = false) + + resetNodes.shouldBeEmpty() + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/jigasi/JigasiIqHandlerTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/jigasi/JigasiIqHandlerTest.kt new file mode 100644 index 0000000..124cb2a --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/jigasi/JigasiIqHandlerTest.kt @@ -0,0 +1,194 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2021-Present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.jigasi + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.Spec +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.conference.JitsiMeetConference +import org.jitsi.jicofo.jigasi.JigasiDetector +import org.jitsi.jicofo.mock.MockXmppConnection +import org.jitsi.jicofo.mock.inPlaceExecutor +import org.jitsi.jicofo.util.ListConferenceStore +import org.jitsi.jicofo.xmpp.IqProcessingResult.AcceptedWithNoResponse +import org.jitsi.jicofo.xmpp.IqProcessingResult.RejectedWithError +import org.jitsi.jicofo.xmpp.IqRequest +import org.jitsi.jicofo.xmpp.JigasiIqHandler +import org.jitsi.xmpp.extensions.rayo.DialIq +import org.jivesoftware.smack.packet.EmptyResultIQ +import org.jivesoftware.smack.packet.ErrorIQ +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.Stanza +import org.jivesoftware.smack.packet.StanzaError.Condition +import org.jxmpp.jid.Jid +import org.jxmpp.jid.impl.JidCreate + +class JigasiIqHandlerTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.SingleInstance + + private val jicofo = JidCreate.from("jicofo@example.com") + private val conferenceJid = JidCreate.entityBareFrom("conference@muc.example.com") + private val participant = JidCreate.from("$conferenceJid/endpoint_id") + private val jigasi1 = JidCreate.from("jigasi1@example.com") + private val jigasi2 = JidCreate.from("jigasi2@example.com") + private val jigasiXmppConnection = JigasiXmppConnection() + private val jigasiDetector: JigasiDetector = mockk { + every { xmppConnection } returns jigasiXmppConnection.xmppConnection + } + + private val conferenceStore = ListConferenceStore() + private val jigasiIqHandler = JigasiIqHandler(setOf(), conferenceStore, jigasiDetector) + + private val dialResponses = mutableListOf() + private val dialRequest = IqRequest( + DialIq().apply { + from = participant + to = jicofo + type = IQ.Type.set + }, + mockk { + every { sendStanza(capture(dialResponses)) } returns Unit + } + ) + + override suspend fun beforeSpec(spec: Spec) = super.beforeSpec(spec).also { + TaskPools.ioPool = inPlaceExecutor + } + + override suspend fun afterSpec(spec: Spec) = super.afterSpec(spec).also { + TaskPools.resetIoPool() + } + + init { + context("When the conference doesn't exist") { + jigasiIqHandler.handleRequest(dialRequest).let { + it.shouldBeInstanceOf() + it.response.shouldBeError(Condition.item_not_found) + } + } + context("When the conference exists") { + val conference: JitsiMeetConference = mockk(relaxed = true) { + every { roomName } returns conferenceJid + } + conferenceStore.add(conference) + + context("And rejects the request") { + every { conference.acceptJigasiRequest(any()) } returns false + + jigasiIqHandler.handleRequest(dialRequest).let { + it.shouldBeInstanceOf() + it.response.shouldBeError(Condition.forbidden) + } + } + context("And accepts the request") { + every { conference.acceptJigasiRequest(any()) } returns true + + context("And there are no Jigasis available") { + every { jigasiDetector.selectSipJigasi(any(), any()) } returns null + + jigasiIqHandler.handleRequest(dialRequest).shouldBeInstanceOf() + dialResponses.last().let { + it.shouldBeInstanceOf() + it.shouldBeError(Condition.service_unavailable) + } + } + context("And the only jigasi instance fails") { + jigasiXmppConnection.responses[jigasi1] = JigasiXmppConnection.Response.Failure + every { jigasiDetector.selectSipJigasi(any(), any()) } answers { jigasi1 } andThen null + + jigasiIqHandler.handleRequest(dialRequest).shouldBeInstanceOf() + dialResponses.last().let { + it.shouldBeInstanceOf() + it.shouldBeError() + } + } + context("And the only jigasi instance times out") { + jigasiXmppConnection.responses[jigasi1] = JigasiXmppConnection.Response.Timeout + every { jigasiDetector.selectSipJigasi(any(), any()) } answers { jigasi1 } andThen null + + jigasiIqHandler.handleRequest(dialRequest).shouldBeInstanceOf() + dialResponses.last().let { + it.shouldBeInstanceOf() + it.shouldBeError() + } + } + context("And the only jigasi instance succeeds") { + jigasiXmppConnection.responses[jigasi1] = JigasiXmppConnection.Response.Success + every { jigasiDetector.selectSipJigasi(any(), any()) } returns jigasi1 + + jigasiIqHandler.handleRequest(dialRequest).shouldBeInstanceOf() + dialResponses.last().let { + it.shouldBeInstanceOf() + it.shouldBeSuccessful() + } + } + context("The first jigasi instance fails, but the second succeeds") { + jigasiXmppConnection.responses[jigasi1] = JigasiXmppConnection.Response.Failure + jigasiXmppConnection.responses[jigasi2] = JigasiXmppConnection.Response.Success + every { jigasiDetector.selectSipJigasi(any(), any()) } answers { + jigasi1 + } andThenAnswer { + jigasi2 + } + + jigasiIqHandler.handleRequest(dialRequest).shouldBeInstanceOf() + dialResponses.last().let { + it.shouldBeInstanceOf() + it.shouldBeSuccessful() + } + } + } + } + } +} + +private fun IQ?.shouldBeError(condition: Condition? = null) { + this.shouldBeInstanceOf() + if (condition != null) { + error.condition shouldBe condition + } +} + +private fun IQ?.shouldBeSuccessful() { + this.shouldBeInstanceOf() +} + +class JigasiXmppConnection : MockXmppConnection() { + val responses = mutableMapOf() + override fun handleIq(iq: IQ): IQ? = when (iq) { + is DialIq -> responses.computeIfAbsent(iq.to) { Response.Success }.let { + when (it) { + Response.Success -> IQ.createResultIQ(iq) + Response.Failure -> IQ.createErrorResponse(iq, Condition.internal_server_error) + Response.Timeout -> null + } + } + + else -> { + println("Not handling ${iq.toXML()}") + null + } + } + + enum class Response { Success, Failure, Timeout } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberDiarizeTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberDiarizeTest.kt new file mode 100644 index 0000000..77a263c --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberDiarizeTest.kt @@ -0,0 +1,63 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import org.jivesoftware.smack.packet.StandardExtensionElement +import org.jivesoftware.smack.packet.StanzaBuilder +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.impl.JidCreate +import java.util.logging.Level + +/** + * Tests that the "diarize" participant property is parsed from presence into [ChatRoomMember.diarize]. + */ +class ChatRoomMemberDiarizeTest : ShouldSpec() { + private val occupantJid: EntityFullJid = JidCreate.entityFullFrom("conference@example.com/member") + private val conferenceJid = JidCreate.entityBareFrom("conference@example.com") + private val chatRoom = ChatRoomImpl(mockk(relaxed = true), conferenceJid, Level.INFO) { } + + private fun member() = ChatRoomMemberImpl(occupantJid, chatRoom, mockk(relaxed = true)) + + private fun presenceWithDiarize(value: String?) = StanzaBuilder.buildPresence().from(occupantJid).apply { + value?.let { + addExtension( + StandardExtensionElement.builder("jitsi_participant_diarize", "jabber:client").setText(it).build() + ) + } + }.build() + + init { + context("Parsing the diarize participant property") { + should("default to false when the element is absent") { + member().apply { processPresence(presenceWithDiarize(null)) }.diarize shouldBe false + } + should("parse 'true' as true") { + member().apply { processPresence(presenceWithDiarize("true")) }.diarize shouldBe true + } + should("parse 'false' as false") { + member().apply { processPresence(presenceWithDiarize("false")) }.diarize shouldBe false + } + should("parse a non-boolean value as false") { + member().apply { processPresence(presenceWithDiarize("garbage")) }.diarize shouldBe false + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberTranslationLanguageTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberTranslationLanguageTest.kt new file mode 100644 index 0000000..0d69a2e --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomMemberTranslationLanguageTest.kt @@ -0,0 +1,125 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import org.jivesoftware.smack.packet.StandardExtensionElement +import org.jivesoftware.smack.packet.StanzaBuilder +import org.jxmpp.jid.EntityFullJid +import org.jxmpp.jid.impl.JidCreate +import java.util.logging.Level + +/** + * Tests that the "translation_language" participant property is parsed from presence into + * [ChatRoomMember.translationLanguage], and that a participant cannot put an arbitrary string there. + */ +class ChatRoomMemberTranslationLanguageTest : ShouldSpec() { + private val occupantJid: EntityFullJid = JidCreate.entityFullFrom("conference@example.com/member") + private val conferenceJid = JidCreate.entityBareFrom("conference@example.com") + private val chatRoom = ChatRoomImpl(mockk(relaxed = true), conferenceJid, Level.INFO) { } + + private fun member() = ChatRoomMemberImpl(occupantJid, chatRoom, mockk(relaxed = true)) + + private fun presence(language: String?) = StanzaBuilder.buildPresence().from(occupantJid).apply { + language?.let { + addExtension( + StandardExtensionElement + .builder("jitsi_participant_translation_language", "jabber:client") + .setText(it) + .build() + ) + } + }.build() + + private fun languageFor(language: String?) = + member().apply { processPresence(presence(language)) }.translationLanguage + + init { + context("Parsing the translation_language participant property") { + should("be null when the element is absent") { + languageFor(null) shouldBe null + } + should("read a two-letter language") { + languageFor("fr") shouldBe "fr" + } + should("read a three-letter language") { + languageFor("ceb") shouldBe "ceb" + } + should("keep a region subtag verbatim") { + languageFor("zh-CN") shouldBe "zh-CN" + } + should("be null for a blank language") { + languageFor(" ") shouldBe null + } + should("trim surrounding whitespace") { + languageFor(" de ") shouldBe "de" + } + } + + context("Rejecting a value that is not a language code") { + // The value is set by the participant. It reaches a colibri2 attribute, the bridge's logs and (later) a + // translation provider, so anything that does not look like a language code is dropped here. + should("reject a single letter") { + languageFor("f") shouldBe null + } + should("reject an over-long primary subtag") { + languageFor("abcd") shouldBe null + } + should("reject a very long value") { + languageFor("a".repeat(4096)) shouldBe null + } + should("reject a value with a newline, which would otherwise forge a log line") { + languageFor("fr\nINFO: forged log line") shouldBe null + } + should("reject a value with markup") { + languageFor("fr\"/>") shouldBe null + } + should("reject a value with a space") { + languageFor("fr de") shouldBe null + } + should("reject a non-language string") { + languageFor("../../etc/passwd") shouldBe null + } + } + + context("Updating on a later presence") { + should("follow the language from one presence to the next") { + val member = member() + member.processPresence(presence("fr")) + member.translationLanguage shouldBe "fr" + member.processPresence(presence("de")) + member.translationLanguage shouldBe "de" + } + should("clear the language when the client clears the property") { + // The client sets the property to the empty string when the user goes back to the original language. + val member = member() + member.processPresence(presence("fr")) + member.processPresence(presence("")) + member.translationLanguage shouldBe null + } + should("clear the language when the element goes away") { + val member = member() + member.processPresence(presence("fr")) + member.processPresence(presence(null)) + member.translationLanguage shouldBe null + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomRoleManagerTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomRoleManagerTest.kt new file mode 100644 index 0000000..57235e3 --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomRoleManagerTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright @ 2026 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.jitsi.jicofo.TaskPools +import org.jitsi.jicofo.auth.AuthenticationAuthority +import org.jitsi.jicofo.auth.AuthenticationListener +import org.jitsi.jicofo.mock.MockChatRoom +import org.jitsi.jicofo.mock.MockXmppProvider +import org.jitsi.jicofo.mock.inPlaceExecutor +import org.jxmpp.jid.Jid +import org.jxmpp.jid.impl.JidCreate + +class ChatRoomRoleManagerTest : ShouldSpec() { + override fun isolationMode() = IsolationMode.InstancePerLeaf + + private val mockChatRoom = MockChatRoom(MockXmppProvider().xmppProvider) + private val chatRoom = mockChatRoom.chatRoom + + override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also { + TaskPools.ioPool = inPlaceExecutor + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also { + TaskPools.resetIoPool() + } + + init { + context("AutoOwnerRoleManager") { + val roleManager = AutoOwnerRoleManager(chatRoom) + chatRoom.addListener(roleManager) + + should("elect the first participant that joins") { + val member = mockChatRoom.addMember("member1") + verify(exactly = 1) { chatRoom.grantOwnership(member) } + } + should("not elect a second owner while one exists") { + val member1 = mockChatRoom.addMember("member1") + val member2 = mockChatRoom.addMember("member2") + verify(exactly = 1) { chatRoom.grantOwnership(member1) } + verify(exactly = 0) { chatRoom.grantOwnership(member2) } + } + should("not elect an owner when a member already has owner rights") { + mockChatRoom.addMember("owner", MemberRole.OWNER) + verify(exactly = 0) { chatRoom.grantOwnership(any()) } + } + should("skip jibri, jigasi and visitors") { + mockChatRoom.addMember("jibri", jibri = true) + mockChatRoom.addMember("jigasi", jigasi = true) + mockChatRoom.addMember("visitor", MemberRole.VISITOR) + + verify(exactly = 0) { chatRoom.grantOwnership(any()) } + + val participant = mockChatRoom.addMember("participant") + verify(exactly = 1) { chatRoom.grantOwnership(participant) } + } + should("elect a new owner when the owner leaves") { + val member1 = mockChatRoom.addMember("member1") + val member2 = mockChatRoom.addMember("member2") + verify(exactly = 1) { chatRoom.grantOwnership(member1) } + + mockChatRoom.removeMember(member1) + verify(exactly = 1) { chatRoom.grantOwnership(member2) } + } + should("not elect a new owner when a non-owner leaves") { + val member1 = mockChatRoom.addMember("member1") + val member2 = mockChatRoom.addMember("member2") + mockChatRoom.removeMember(member2) + verify(exactly = 1) { chatRoom.grantOwnership(member1) } + verify(exactly = 0) { chatRoom.grantOwnership(member2) } + } + } + + context("AuthenticationRoleManager") { + val sessions = mutableMapOf() + val authenticationListeners = mutableListOf() + val authenticationAuthority: AuthenticationAuthority = mockk(relaxed = true) { + every { addAuthenticationListener(capture(authenticationListeners)) } returns Unit + every { getSessionForJid(any()) } answers { sessions[firstArg()] } + } + val roleManager = AuthenticationRoleManager(chatRoom, authenticationAuthority) + chatRoom.addListener(roleManager) + + val authenticatedJid = JidCreate.from("authenticated@example.com") + + should("grant ownership to an authenticated user that joins") { + sessions[authenticatedJid] = "session-id" + val member = mockChatRoom.addMember("member1") + every { member.jid } returns authenticatedJid + // Re-fire the join now that the jid is set up. + roleManager.memberJoined(member) + + verify(exactly = 1) { chatRoom.grantOwnership(member) } + } + should("not grant ownership to an unauthenticated user") { + val member = mockChatRoom.addMember("member1") + roleManager.memberJoined(member) + verify(exactly = 0) { chatRoom.grantOwnership(any()) } + } + should("grant ownership when a user authenticates") { + val member = mockChatRoom.addMember("member1") + every { member.jid } returns authenticatedJid + + authenticationListeners.forEach { it.jidAuthenticated(authenticatedJid, "session-id", "machine-uid") } + verify(exactly = 1) { chatRoom.grantOwnership(member) } + } + should("grant ownership to authenticated users with grantOwnership") { + val member1 = mockChatRoom.addMember("member1") + every { member1.jid } returns authenticatedJid + val member2 = mockChatRoom.addMember("member2") + every { member2.jid } returns JidCreate.from("other@example.com") + sessions[authenticatedJid] = "session-id" + + roleManager.grantOwnership() + + verify(exactly = 1) { chatRoom.grantOwnership(member1) } + verify(exactly = 0) { chatRoom.grantOwnership(member2) } + } + should("remove the authentication listener when stopped") { + roleManager.stop() + verify { authenticationAuthority.removeAuthenticationListener(any()) } + } + } + } +} diff --git a/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomTranslationLanguagesTest.kt b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomTranslationLanguagesTest.kt new file mode 100644 index 0000000..8668d1a --- /dev/null +++ b/jicofo/src/test/kotlin/org/jitsi/jicofo/xmpp/muc/ChatRoomTranslationLanguagesTest.kt @@ -0,0 +1,65 @@ +/* + * Jicofo, the Jitsi Conference Focus. + * + * Copyright @ 2026 - present 8x8, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.jicofo.xmpp.muc + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + +/** + * Tests the union behind [ChatRoom.getTranslationLanguages]: the languages the members request, together with the + * visitors' (which reach jicofo through room metadata, because visitor presence is not visible in this room). + */ +class ChatRoomTranslationLanguagesTest : ShouldSpec() { + private fun member(language: String?): ChatRoomMember = mockk { + every { translationLanguage } returns language + } + + private fun languages(members: List, visitorLanguages: Set) = + ChatRoom.translationLanguages(members, visitorLanguages) + + init { + context("Combining the members' and the visitors' languages") { + should("be empty when nobody requested a language") { + languages(listOf(member(null), member(null)), emptySet()) shouldBe emptySet() + } + should("collect the languages the members requested") { + languages(listOf(member("fr"), member("de")), emptySet()) shouldBe setOf("fr", "de") + } + should("de-duplicate a language two members both requested") { + languages(listOf(member("fr"), member("fr")), emptySet()) shouldBe setOf("fr") + } + should("ignore members that requested none") { + languages(listOf(member("fr"), member(null)), emptySet()) shouldBe setOf("fr") + } + should("include the visitors' languages") { + languages(listOf(member("fr")), setOf("es")) shouldBe setOf("fr", "es") + } + should("de-duplicate a language a member and a visitor both requested") { + languages(listOf(member("fr")), setOf("fr")) shouldBe setOf("fr") + } + should("include the visitors' languages when no member requested one") { + languages(listOf(member(null)), setOf("es")) shouldBe setOf("es") + } + should("be empty when there are no members and no visitor languages") { + languages(emptyList(), emptySet()) shouldBe emptySet() + } + } + } +} diff --git a/lib/logging.properties b/lib/logging.properties new file mode 100644 index 0000000..bd316a4 --- /dev/null +++ b/lib/logging.properties @@ -0,0 +1,28 @@ + +handlers= java.util.logging.ConsoleHandler + +# Handlers with XMPP debug enabled: +#handlers= java.util.logging.ConsoleHandler, org.jitsi.impl.protocol.xmpp.log.XmppPacketsFileHandler + +# Handlers with sentry enabled: +#handlers= java.util.logging.ConsoleHandler, io.sentry.jul.SentryHandler + +java.util.logging.ConsoleHandler.level = ALL +java.util.logging.ConsoleHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter +java.util.logging.ConsoleHandler.filter = org.jitsi.impl.protocol.xmpp.log.ExcludeXmppPackets + +org.jitsi.utils.logging2.JitsiLogFormatter.programname=Jicofo +.level=INFO + +# To enable XMPP packets logging add XmppPacketsFileHandler to the handlers property +org.jitsi.impl.protocol.xmpp.log.PacketDebugger.level=ALL +org.jitsi.impl.protocol.xmpp.log.XmppPacketsFileHandler.pattern=/var/log/jitsi/jicofo-xmpp.log +org.jitsi.impl.protocol.xmpp.log.XmppPacketsFileHandler.append=true +org.jitsi.impl.protocol.xmpp.log.XmppPacketsFileHandler.limit=200000000 +org.jitsi.impl.protocol.xmpp.log.XmppPacketsFileHandler.count=3 + +# Sentry (uncomment handler to use) +io.sentry.jul.SentryHandler.level=WARNING + +# uncomment to see how Jicofo talks to the JVB +#org.jitsi.impl.protocol.xmpp.colibri.level=ALL diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..87c7f2f --- /dev/null +++ b/pom.xml @@ -0,0 +1,300 @@ + + + 4.0.0 + org.jitsi + jicofo-parent + 1.1-SNAPSHOT + pom + jicofo-parent + JItsi COnference FOcus is a server side focus component used in Jitsi Meet conferences. + https://github.com/jitsi/jicofo + + jitsi.org + https://jitsi.org + + + + org.jitsi + Jitsi Team + dev@jitsi.org + + + + https://github.com/jitsi/jicofo + scm:git:https://github.com/jitsi/jicofo + scm:git:https://github.com/jicofo + HEAD + + + + Apache-2.0 + https://github.com/jitsi/jicofo/blob/master/LICENSE + repo + + + + false + UTF-8 + + 1.0.3 + 4.4.8-jitsi-4 + 2.4.20 + 3.0.0 + 5.9.1 + 1.7.32 + 2.21.5 + 1.0-153-gd5c0102 + 1.1-179-g52cf6ef + 1.8.0 + 4.8.6 + 4.8.6.6 + 5.14.4 + 1.14.4 + 1.14.11 + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0-M3 + + + enforce-maven + + enforce + + + + + 3.5.0 + + + + + + + + + + jicofo-common + jicofo-selector + jicofo + + + + + org.apache.commons + commons-lang3 + 3.12.0 + + + org.igniterealtime.smack + smack-core + ${smack.version} + + + org.igniterealtime.smack + smack-extensions + ${smack.version} + + + org.igniterealtime.smack + smack-java8 + ${smack.version} + + + org.igniterealtime.smack + smack-xmlparser-stax + ${smack.version} + + + org.igniterealtime.smack + smack-tcp + ${smack.version} + + + org.jxmpp + jxmpp-stringprep-rocksxmppprecis + ${jxmpp.version} + + + + org.jetbrains + annotations + 23.0.0 + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + compile + + + + ${project.groupId} + jicoco-config + ${jicoco.version} + + + ${project.groupId} + jicoco-health-checker + ${jicoco.version} + + + ${project.groupId} + jicoco-jwt + ${jicoco.version} + + + ${project.groupId} + jicoco-metrics + ${jicoco.version} + + + ${project.groupId} + jicoco-tracing + ${jicoco.version} + + + ${project.groupId} + jitsi-metaconfig + 1.0-18-ge06bc45 + + + ${project.groupId} + jitsi-utils + ${jitsi.utils.version} + + + ${project.groupId} + jitsi-xmpp-extensions + 1.0-124-g024f15d + + + org.slf4j + slf4j-jdk14 + ${slf4j.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + com.fasterxml.jackson.module + jackson-module-kotlin + ${jackson.version} + + + + com.github.spotbugs + spotbugs-annotations + ${spotbugs.version} + + + + io.sentry + sentry + 5.4.0 + runtime + + + + org.xmlunit + xmlunit-core + 2.10.0 + test + + + org.mockito + mockito-core + 4.0.0 + test + + + io.kotest + kotest-runner-junit5-jvm + ${kotest.version} + test + + + io.kotest + kotest-assertions-core-jvm + ${kotest.version} + test + + + io.kotest + kotest-property-jvm + ${kotest.version} + test + + + org.junit.platform + junit-platform-launcher + ${junit.platform.version} + test + + + org.junit.platform + junit-platform-engine + ${junit.platform.version} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + ${project.groupId} + jicoco-test-kotlin + ${jicoco.version} + test + + + io.mockk + mockk-jvm + ${mockk.version} + test + + + + + + coverage + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + + report + test + + report + + + + + + + + + diff --git a/resources/build_deb_package.sh b/resources/build_deb_package.sh new file mode 100755 index 0000000..836e265 --- /dev/null +++ b/resources/build_deb_package.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -x +set -e + +# Make sure you have the following environment variables set, example: +# export DEBFULLNAME="Jitsi Team" +# export DEBEMAIL="dev@jitsi.org" +# You need package devscripts installed (command dch). + +echo "===================================================================" +echo " Building DEB packages... " +echo "===================================================================" + +SCRIPT_FOLDER=$(dirname "$0") +cd "$SCRIPT_FOLDER/.." + +# We are still using old version numbers +VERSIONMAJ="1.0" +VERSION=$VERSIONMAJ-$BUILD_NUMBER + +# TODO: uncoment the following and move to the new numbering scheme +## Let's get version from maven +#MVNVER=$(xmllint --xpath "/*[local-name()='project']/*[local-name()='version']/text()" pom.xml) +#TAG_NAME="v${MVNVER/-SNAPSHOT/}" + +#echo "Current tag name: $TAG_NAME" + +#if ! git rev-parse "$TAG_NAME" >/dev/null 2>&1 +#then +# git tag -a "$TAG_NAME" -m "Tagged automatically by Jenkins" +# git push origin "$TAG_NAME" +#else +# echo "Tag: $TAG_NAME already exists." +#fi + +#VERSION_FULL=$(git describe --match "v[0-9\.]*" --long) +#echo "Full version: ${VERSION_FULL}" + +#VERSION=${VERSION_FULL:1} +echo "Package version: ${VERSION}" + +REV=$(git log --pretty=format:'%h' -n 1) +dch -v "$VERSION-1" "Build from git. $REV" +dch -D unstable -r "" + +# sets the version in the pom file so it will propagte to resulting jar +mvn versions:set -DnewVersion="${VERSION}" + +# Install jicofo-common and jicofo-selector to the local maven repo because they +# are currently not published anywhere else. +mvn install + +# We need to make sure all dependencies are downloaded before start building +# the debian package +mvn dependency:resolve + +# now build the deb +dpkg-buildpackage -tc -us -uc -A + +# clean the current changes as dch had changed the change log +git checkout debian/changelog +git checkout pom.xml + +echo "Here are the resulting files in $(pwd ..)" +echo "-----" +ls -l ../{*.changes,*.deb,*.buildinfo} +echo "-----" + +# Let's try deploying +cd .. +([ ! -x deploy.sh ] || ./deploy.sh "jicofo" ${BUILD_NUMBER} ) diff --git a/resources/collect-dump-logs.sh b/resources/collect-dump-logs.sh new file mode 100755 index 0000000..605a292 --- /dev/null +++ b/resources/collect-dump-logs.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# script that creates an archive in current folder +# containing the heap and thread dump and the current log file + +JAVA_HEAPDUMP_PATH="/tmp/java_*.hprof" +STAMP=`date +%Y-%m-%d-%H%M` +PID_PATH="/var/run/jicofo.pid" +JICOFO_UID=`id -u jicofo` +RUNNING="" +unset PID + +#Find any crashes in /var/crash from our user in the past 20 minutes, if they exist +CRASH_FILES=$(find /var/crash -name '*.crash' -uid $JICOFO_UID -mmin -20 -type f) + +[ -e $PID_PATH ] && PID=$(cat $PID_PATH) +if [ ! -z $PID ]; then + ps -p $PID | grep -q java + [ $? -eq 0 ] && RUNNING="true" +fi +if [ ! -z $RUNNING ]; then + echo "Jicofo pid $PID" + THREADS_FILE="/tmp/stack-${STAMP}-${PID}.threads" + HEAP_FILE="/tmp/heap-${STAMP}-${PID}.bin" + sudo -u jicofo jstack ${PID} > ${THREADS_FILE} + sudo -u jicofo jmap -dump:live,format=b,file=${HEAP_FILE} ${PID} + tar zcvf jicofo-dumps-${STAMP}-${PID}.tgz ${THREADS_FILE} ${HEAP_FILE} ${CRASH_FILES} /var/log/jitsi/jicofo* /tmp/hs_err_* + rm ${HEAP_FILE} ${THREADS_FILE} +else + ls $JAVA_HEAPDUMP_PATH >/dev/null 2>&1 + if [ $? -eq 0 ]; then + echo "Jicofo not running, but previous heap dump found." + tar zcvf jicofo-dumps-${STAMP}-crash.tgz $JAVA_HEAPDUMP_PATH ${CRASH_FILES} /var/log/jitsi/jicofo* /tmp/hs_err_* + rm ${JAVA_HEAPDUMP_PATH} + else + echo "Jicofo not running, no previous dump found. Including logs only." + tar zcvf jicofo-dumps-${STAMP}-crash.tgz ${CRASH_FILES} /var/log/jitsi/jicofo* /tmp/hs_err_* + fi +fi diff --git a/resources/config/jicofo-logrotate.d b/resources/config/jicofo-logrotate.d new file mode 100644 index 0000000..991c6bd --- /dev/null +++ b/resources/config/jicofo-logrotate.d @@ -0,0 +1,10 @@ +/var/log/jitsi/jicofo.log { + daily + missingok + rotate 7 + compress + delaycompress + notifempty + copytruncate + su jicofo jitsi +} diff --git a/resources/jicofo.bat b/resources/jicofo.bat new file mode 100644 index 0000000..0beb9ba --- /dev/null +++ b/resources/jicofo.bat @@ -0,0 +1,22 @@ +@echo off + +if "%1"=="/h" goto usage +if "%1"=="/?" goto usage +goto :begin + +:usage +echo Usage: %0 +exit /B 1 + +:begin + +:: needed to overcome weird loop behavior in conjunction with variable expansion +SETLOCAL enabledelayedexpansion + +set mainClass=org.jitsi.jicofo.Main +set cp=jicofo.jar +FOR %%F IN (lib/*.jar) DO ( + SET cp=!cp!;lib/%%F% +) + +java -Djava.util.logging.config.file=lib/logging.properties -Djdk.tls.ephemeralDHKeySize=2048 -cp %cp% %mainClass% %* diff --git a/resources/jicofo.sh b/resources/jicofo.sh new file mode 100755 index 0000000..09fa154 --- /dev/null +++ b/resources/jicofo.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +if [[ "$1" == "--help" ]]; then + echo -e "Usage: $0" + echo + echo -e "Supported environment variables: JICOFO_MAX_MEMORY, JAVA_SYS_PROPS." + echo + exit 1 +fi + +if [[ ! "$JAVA_SYS_PROPS" == *"-Dconfig.file="* ]]; then + if [[ -f /etc/jitsi/jicofo/jicofo.conf ]]; then + JAVA_SYS_PROPS="$JAVA_SYS_PROPS -Dconfig.file=/etc/jitsi/jicofo/jicofo.conf" + else + echo + echo "To run jicofo you need a configuration file. Use environment variable JAVA_SYS_PROPS." + echo "e.g. export JAVA_SYS_PROPS=\"-Dconfig.file=/etc/jitsi/jicofo/jicofo.conf\"" + echo + exit 2 + fi +fi + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" + +mainClass="org.jitsi.jicofo.Main" +cp=$(JARS=($SCRIPT_DIR/jicofo*.jar $SCRIPT_DIR/lib/*.jar); IFS=:; echo "${JARS[*]}") +logging_config="$SCRIPT_DIR/lib/logging.properties" + +# if there is a logging config file in lib folder use it (running from source) +if [ -f $logging_config ]; then + LOGGING_CONFIG_PARAM="-Djava.util.logging.config.file=$logging_config" +fi + +if [ -z "$JICOFO_MAX_MEMORY" ]; then JICOFO_MAX_MEMORY=3072m; fi + +exec java -Xmx$JICOFO_MAX_MEMORY -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp -Djdk.tls.ephemeralDHKeySize=2048 $LOGGING_CONFIG_PARAM $JAVA_SYS_PROPS -cp $cp $mainClass $@ diff --git a/resources/mvn-exec.sh b/resources/mvn-exec.sh new file mode 100755 index 0000000..340bf7d --- /dev/null +++ b/resources/mvn-exec.sh @@ -0,0 +1,11 @@ +#!/bin/bash -e + +if [[ ! "$JAVA_SYS_PROPS" == *"-Dconfig.file="* ]]; then + echo + echo "To run jicofo you need a configuration file. Use environment variable JAVA_SYS_PROPS." + echo "e.g. export JAVA_SYS_PROPS=\"-Dconfig.file=/etc/jitsi/jicofo/jicofo.conf\"" + echo + exit 2 +fi + +exec mvn ${JAVA_SYS_PROPS} compile exec:java -pl jicofo -Dexec.mainClass=org.jitsi.jicofo.Main diff --git a/script/graceful_shutdown.py b/script/graceful_shutdown.py new file mode 100644 index 0000000..c9d0e06 --- /dev/null +++ b/script/graceful_shutdown.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from optparse import OptionParser + +import sys +import logging +import time + +import sleekxmpp +from sleekxmpp import Iq +from sleekxmpp.exceptions import StanzaError +from sleekxmpp.xmlstream import register_stanza_plugin, ElementBase + +# Global variable for storing exit code: +# 0 - focus shutdown OK +# 1 - unexpected error occurred +exitCode = 0 + +# Python versions before 3.0 do not use UTF-8 encoding +# by default. To ensure that Unicode is handled properly +# throughout SleekXMPP, we will set the default encoding +# ourselves to UTF-8. +if sys.version_info < (3, 0): + from sleekxmpp.util.misc_ops import setdefaultencoding + setdefaultencoding('utf8') +else: + raw_input = input + + +def get_conferences(stats_xml): + for stat in stats_xml.findall('{http://jitsi.org/protocol/colibri}stat'): + name = stat.get('name') + value = stat.get('value') + if name == 'conferences': + return value + return -1 + + +class ColibriStats(ElementBase): + + name = 'stats' + + namespace = 'http://jitsi.org/protocol/colibri' + + plugin_attrib = 'colibri-stats' + + +class GracefulShutdown(ElementBase): + + name = 'graceful-shutdown' + + namespace = 'http://jitsi.org/protocol/colibri' + + plugin_attrib = 'graceful-shutdown' + + +class FocusShutdownUserBot(sleekxmpp.ClientXMPP): + + def __init__(self, jid, password, focus): + sleekxmpp.ClientXMPP.__init__(self, jid, password) + + self.focus = focus + + self.add_event_handler( + "session_start", self.shutdown_focus, threaded=True) + + register_stanza_plugin(Iq, GracefulShutdown) + register_stanza_plugin(Iq, ColibriStats) + + def create_request_stats_iq(self): + statsIq = self.Iq() + statsIq['to'] = self.focus + statsIq['type'] = 'get' + statsIq.set_payload(ColibriStats()) + return statsIq + + def shutdown_focus(self, event): + + shutdown_accepted = False + + iq = self.Iq() + iq['to'] = self.focus + iq['type'] = 'set' + iq.set_payload(GracefulShutdown()) + + try: + resp = iq.send() + if resp['type'] == 'result': + logging.info('Shutdown accepted') + shutdown_accepted = True + while True: + stats_iq = self.create_request_stats_iq() + resp = stats_iq.send() + stats = resp['colibri-stats'] + conf_count = get_conferences(stats) + logging.info( + 'There are ' + str(conf_count) + ' conferences in ' + 'progress') + if conf_count == '0': + break + else: + time.sleep(10) + logging.info('End of shutdown procedure') + # The wait=True delays the disconnect until the queue + # of stanzas to be sent becomes empty. + self.disconnect(wait=True) + except StanzaError as error: + if not shutdown_accepted and\ + error.etype != 'wait' and\ + error.condition != 'service-unavailable': + global exitCode + exitCode = 1 + logging.error('There was an error sending shutdown request.') + self.disconnect(wait=True) + + +if __name__ == '__main__': + # Setup the command line arguments. + optp = OptionParser() + + # Output verbosity options. + optp.add_option('-q', '--quiet', help='set logging to ERROR', + action='store_const', dest='loglevel', + const=logging.ERROR, default=logging.INFO) + optp.add_option('-d', '--debug', help='set logging to DEBUG', + action='store_const', dest='loglevel', + const=logging.DEBUG, default=logging.INFO) + optp.add_option('-v', '--verbose', help='set logging to COMM', + action='store_const', dest='loglevel', + const=5, default=logging.INFO) + # JID and password options. + optp.add_option("-s", "--server", dest="server", + help="JID to use") + optp.add_option("-j", "--jid", dest="jid", + help="JID to use") + optp.add_option("-p", "--password", dest="password", + help="password to use") + optp.add_option("-f", "--focus", dest="focus", + help="JID of the focus component") + opts, args = optp.parse_args() + + # Setup logging. + logging.basicConfig(level=opts.loglevel, + format='%(levelname)-8s %(message)s') + if opts.server is None: + sys.stderr.write("No XMPP server name specified") + exit(1) + if opts.jid is None: + sys.stderr.write("No user JID specified") + exit(1) + if opts.password is None: + sys.stderr.write("No user password specified") + exit(1) + if opts.focus is None: + sys.stderr.write("No focus JID specified") + exit(1) + + xmpp = FocusShutdownUserBot(opts.jid, opts.password, opts.focus) + + if xmpp.connect(address=(opts.server, 5222)): + xmpp.process(block=True) + logging.info("Done") + else: + logging.error("Unable to connect.") + + exit(exitCode)