snapshot of https://github.com/jitsi/jicofo.git @ 9ab59aa3d348e282da89da2c9f11ecbecd3f7b79 (2026-09-15)
This commit is contained in:
500
jicofo/pom.xml
Normal file
500
jicofo/pom.xml
Normal file
@@ -0,0 +1,500 @@
|
||||
<?xml version="1.0"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.jitsi</groupId>
|
||||
<artifactId>jicofo-parent</artifactId>
|
||||
<version>1.1-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>jicofo</artifactId>
|
||||
<version>1.1-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>jicofo</name>
|
||||
<description>JItsi COnference FOcus is a server side focus component used in Jitsi Meet conferences.</description>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicofo-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicofo-selector</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-server-core-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-server-netty-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-server-content-negotiation-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-serialization-jackson-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-server-status-pages-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.igniterealtime.smack</groupId>
|
||||
<artifactId>smack-tcp</artifactId>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/org.jetbrains/annotations -->
|
||||
<dependency>
|
||||
<groupId>org.jxmpp</groupId>
|
||||
<artifactId>jxmpp-stringprep-rocksxmppprecis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<artifactId>annotations</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<!-- org.jitsi -->
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-health-checker</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-jwt</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jitsi-metaconfig</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-metrics</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-tracing</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jitsi-utils</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jitsi-xmpp-extensions</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-jdk14</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.module</groupId>
|
||||
<artifactId>jackson-module-kotlin</artifactId>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/com.github.spotbugs/spotbugs-annotations -->
|
||||
<dependency>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-annotations</artifactId>
|
||||
</dependency>
|
||||
<!-- runtime -->
|
||||
<dependency>
|
||||
<groupId>io.sentry</groupId>
|
||||
<artifactId>sentry</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- test -->
|
||||
<dependency>
|
||||
<groupId>org.xmlunit</groupId>
|
||||
<artifactId>xmlunit-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.mockk</groupId>
|
||||
<artifactId>mockk-jvm</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.kotest</groupId>
|
||||
<artifactId>kotest-runner-junit5-jvm</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.kotest</groupId>
|
||||
<artifactId>kotest-assertions-core-jvm</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.kotest</groupId>
|
||||
<artifactId>kotest-property-jvm</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.platform</groupId>
|
||||
<artifactId>junit-platform-launcher</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-test-kotlin</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicofo-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<type>test-jar</type>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicofo-selector</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<type>test-jar</type>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-plugin</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>compile</id>
|
||||
<phase>compile</phase>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<sourceDirs>
|
||||
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
|
||||
<sourceDir>${project.basedir}/src/main/java</sourceDir>
|
||||
</sourceDirs>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>test-compile</id>
|
||||
<phase>test-compile</phase>
|
||||
<goals>
|
||||
<goal>test-compile</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<sourceDirs>
|
||||
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
|
||||
<sourceDir>${project.basedir}/src/test/java</sourceDir>
|
||||
</sourceDirs>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<jvmTarget>17</jvmTarget>
|
||||
<args>
|
||||
<arg>-Xallow-result-return-type</arg>
|
||||
</args>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.8.1</version>
|
||||
<executions>
|
||||
<!-- Replacing default-compile as it is treated specially by maven -->
|
||||
<execution>
|
||||
<id>default-compile</id>
|
||||
<phase>none</phase>
|
||||
</execution>
|
||||
<!-- Replacing default-testCompile as it is treated specially by maven -->
|
||||
<execution>
|
||||
<id>default-testCompile</id>
|
||||
<phase>none</phase>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>java-compile</id>
|
||||
<phase>compile</phase>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>java-test-compile</id>
|
||||
<phase>test-compile</phase>
|
||||
<goals>
|
||||
<goal>testCompile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<source>17</source>
|
||||
<target>17</target>
|
||||
<compilerArgs>
|
||||
<arg>-Xlint:all,-serial</arg>
|
||||
</compilerArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>assemble-archive</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>single</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<appendAssemblyId>true</appendAssemblyId>
|
||||
<descriptors>
|
||||
<descriptor>src/assembly/archive.xml</descriptor>
|
||||
</descriptors>
|
||||
<skipAssembly>${assembly.skipAssembly}</skipAssembly>
|
||||
<descriptorRefs>
|
||||
<descriptorRef>jar-with-dependencies</descriptorRef>
|
||||
</descriptorRefs>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>2.22.2</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<java.util.logging.config.file>${project.parent.basedir}/lib/logging.properties</java.util.logging.config.file>
|
||||
<kotest.framework.classpath.scanning.autoscan.disable>true</kotest.framework.classpath.scanning.autoscan.disable>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- ktlint: check runs in the verify phase; auto-format with "mvn exec:exec@ktlint-format" -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.6.3</version>
|
||||
<configuration>
|
||||
<mainClass>org.jitsi.jicofo.Main</mainClass>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>ktlint-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>exec</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<executable>java</executable>
|
||||
<includePluginDependencies>true</includePluginDependencies>
|
||||
<arguments>
|
||||
<argument>-classpath</argument>
|
||||
<classpath>
|
||||
<dependency>com.pinterest.ktlint:ktlint-cli</dependency>
|
||||
</classpath>
|
||||
<argument>com.pinterest.ktlint.Main</argument>
|
||||
<argument>--relative</argument>
|
||||
<argument>src/main/kotlin/**/*.kt</argument>
|
||||
<argument>src/test/kotlin/**/*.kt</argument>
|
||||
</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>ktlint-format</id>
|
||||
<goals>
|
||||
<goal>exec</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<executable>java</executable>
|
||||
<includePluginDependencies>true</includePluginDependencies>
|
||||
<arguments>
|
||||
<argument>-classpath</argument>
|
||||
<classpath>
|
||||
<dependency>com.pinterest.ktlint:ktlint-cli</dependency>
|
||||
</classpath>
|
||||
<argument>com.pinterest.ktlint.Main</argument>
|
||||
<argument>--format</argument>
|
||||
<argument>--relative</argument>
|
||||
<argument>src/main/kotlin/**/*.kt</argument>
|
||||
<argument>src/test/kotlin/**/*.kt</argument>
|
||||
</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.pinterest.ktlint</groupId>
|
||||
<artifactId>ktlint-cli</artifactId>
|
||||
<version>${ktlint.version}</version>
|
||||
<classifier>all</classifier>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<!-- Instructs the plugin to add the current package version in the jar manifest
|
||||
So we can access it runtime with: .class.getPackage().getImplementationVersion()
|
||||
-->
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<configLocation>checkstyle.xml</configLocation>
|
||||
</configuration>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>9.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
<version>${spotbugs-maven-plugin.version}</version>
|
||||
<configuration>
|
||||
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
|
||||
<includeTests>true</includeTests>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs</artifactId>
|
||||
<version>${spotbugs.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>jitsi-maven-repository-releases</id>
|
||||
<layout>default</layout>
|
||||
<name>Jitsi Maven Repository (Releases)</name>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
<url>https://github.com/jitsi/jitsi-maven-repository/raw/master/releases/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>jitsi-maven-repository-snapshots</id>
|
||||
<layout>default</layout>
|
||||
<name>Jitsi Maven Repository (Snapshots)</name>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
<url>https://github.com/jitsi/jitsi-maven-repository/raw/master/snapshots/</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>coverage</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.12</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<!-- attached to Maven test phase -->
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<!-- Aggregate report over all jicofo modules. This is the report to look at: tests in one module
|
||||
exercise classes from the other modules, so the per-module reports under-count coverage.
|
||||
Output: jicofo/target/site/jacoco-aggregate/ -->
|
||||
<execution>
|
||||
<id>report-aggregate</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report-aggregate</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<includeCurrentProject>true</includeCurrentProject>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
66
jicofo/spotbugs-exclude.xml
Normal file
66
jicofo/spotbugs-exclude.xml
Normal file
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<FindBugsFilter>
|
||||
|
||||
<Match>
|
||||
<And>
|
||||
<Source name="~.*\.*.kt" />
|
||||
<Or>
|
||||
<Bug pattern="REC_CATCH_EXCEPTION"/>
|
||||
<!-- This seems to get triggered by certain kotlin constructs though they look safe -->
|
||||
<Bug pattern="BC_BAD_CAST_TO_ABSTRACT_COLLECTION"/>
|
||||
<!-- False positive -->
|
||||
<Bug pattern="BC_VACUOUS_INSTANCEOF"/>
|
||||
<!-- Serialization -->
|
||||
<Bug pattern="SE_BAD_FIELD"/>
|
||||
<!-- Serialization -->
|
||||
<Bug pattern="SE_BAD_FIELD_STORE"/>
|
||||
<!-- False positives with lazy vals -->
|
||||
<Bug pattern="NP_NULL_ON_SOME_PATH"/>
|
||||
<!-- False positives with kotlin 1.5 -->
|
||||
<Bug pattern="NP_PARAMETER_MUST_BE_NONNULL_BUT_MARKED_AS_NULLABLE"/>
|
||||
<!-- False positive -->
|
||||
<Bug pattern="NP_NONNULL_RETURN_VIOLATION"/>
|
||||
<!-- False positive with kotlin's "when" -->
|
||||
<Bug pattern="SF_SWITCH_NO_DEFAULT"/>
|
||||
<!-- False positives with ?. chains. -->
|
||||
<Bug pattern="RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE"/>
|
||||
<And>
|
||||
<!-- Spotbugs doesn't recognize that a value can't be null, and using @SuppressFBWarnings doesn't work. -->
|
||||
<Bug pattern="NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE"/>
|
||||
<Source name="MuteIqHandler.kt"/>
|
||||
</And>
|
||||
<!-- False positives with kotlin 1.6. -->
|
||||
<Bug pattern="NP_NONNULL_PARAM_VIOLATION"/>
|
||||
<Bug pattern="SA_LOCAL_SELF_ASSIGNMENT"/>
|
||||
<!-- False positive with kotlin lazy initialization. -->
|
||||
<Bug pattern="BC_IMPOSSIBLE_INSTANCEOF"/>
|
||||
<Bug pattern="MS_EXPOSE_REP"/>
|
||||
</Or>
|
||||
</And>
|
||||
</Match>
|
||||
|
||||
<Match>
|
||||
<Or>
|
||||
<!-- Exposure of internal representation is not a concern. -->
|
||||
<Bug pattern="EI_EXPOSE_REP"/>
|
||||
<Bug pattern="EI_EXPOSE_REP2"/>
|
||||
<!-- False positives in java code for an unknown reason. -->
|
||||
<Bug pattern="NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE"/>
|
||||
</Or>
|
||||
</Match>
|
||||
|
||||
<Match>
|
||||
<!-- Tests have a valid reason to ignore return values (expecting exceptions, etc). -->
|
||||
<And>
|
||||
<Or>
|
||||
<Bug pattern="RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"/>
|
||||
</Or>
|
||||
<Or>
|
||||
<Source name="~.*\.*Test.kt" />
|
||||
<Source name="~.*\.*Test.java" />
|
||||
</Or>
|
||||
</And>
|
||||
</Match>
|
||||
|
||||
|
||||
</FindBugsFilter>
|
||||
55
jicofo/src/assembly/archive.xml
Normal file
55
jicofo/src/assembly/archive.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<assembly
|
||||
xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
|
||||
<id>archive</id>
|
||||
<formats>
|
||||
<format>zip</format>
|
||||
</formats>
|
||||
<includeBaseDirectory>true</includeBaseDirectory>
|
||||
<baseDirectory>${project.artifactId}-${project.version}</baseDirectory>
|
||||
<dependencySets>
|
||||
<dependencySet>
|
||||
<outputDirectory>lib</outputDirectory>
|
||||
<scope>runtime</scope>
|
||||
<useProjectArtifact>false</useProjectArtifact>
|
||||
<unpack>false</unpack>
|
||||
</dependencySet>
|
||||
</dependencySets>
|
||||
<files>
|
||||
<file>
|
||||
<source>${project.basedir}/target/${project.artifactId}-${project.version}.jar</source>
|
||||
<outputDirectory>${file.separator}</outputDirectory>
|
||||
<destName>jicofo.jar</destName>
|
||||
</file>
|
||||
</files>
|
||||
<fileSets>
|
||||
<fileSet>
|
||||
<directory>${project.build.directory}</directory>
|
||||
<outputDirectory>${file.separator}</outputDirectory>
|
||||
<includes>
|
||||
<include>*.jar</include>
|
||||
</includes>
|
||||
<excludes>
|
||||
<exclude>*-with-dependencies.jar</exclude>
|
||||
<exclude>${project.artifactId}-${project.version}.jar</exclude>
|
||||
</excludes>
|
||||
</fileSet>
|
||||
<fileSet>
|
||||
<directory>${project.basedir}/lib</directory>
|
||||
<outputDirectory>lib</outputDirectory>
|
||||
<excludes>
|
||||
<exclude>**/*.jar</exclude>
|
||||
</excludes>
|
||||
</fileSet>
|
||||
<fileSet>
|
||||
<directory>${project.parent.basedir}/resources/</directory>
|
||||
<outputDirectory>${file.separator}</outputDirectory>
|
||||
<fileMode>755</fileMode>
|
||||
<includes>
|
||||
<include>jicofo.sh</include>
|
||||
<include>jicofo.bat</include>
|
||||
</includes>
|
||||
</fileSet>
|
||||
</fileSets>
|
||||
</assembly>
|
||||
147
jicofo/src/main/java/org/jitsi/jicofo/Main.java
Normal file
147
jicofo/src/main/java/org/jitsi/jicofo/Main.java
Normal file
@@ -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 <tt>main</tt> 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<String> function0)
|
||||
{
|
||||
configLogger.warn(function0::invoke);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(@NotNull Function0<String> function0)
|
||||
{
|
||||
configLogger.error(function0::invoke);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(@NotNull Function0<String> function0)
|
||||
{
|
||||
configLogger.debug(function0::invoke);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 <tt>true</tt> 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<String, AuthenticationSession> authenticationSessions
|
||||
= new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* The list of registered {@link AuthenticationListener}s.
|
||||
*/
|
||||
private final List<AuthenticationListener> authenticationListeners
|
||||
= new CopyOnWriteArrayList<>();
|
||||
|
||||
/**
|
||||
* Creates new instance of <tt>AbstractAuthAuthority</tt>.
|
||||
*
|
||||
* @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 <tt>true</tt> when a match is found.
|
||||
* @return the first {@link AuthenticationSession} that matches given
|
||||
* predicate or <tt>null</tt>.
|
||||
*/
|
||||
protected AuthenticationSession findSession(
|
||||
Predicate<AuthenticationSession> selector)
|
||||
{
|
||||
ArrayList<AuthenticationSession> sessions
|
||||
= new ArrayList<>(authenticationSessions.values());
|
||||
|
||||
return sessions
|
||||
.stream()
|
||||
.filter(selector)
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new <tt>AuthenticationSession</tt> 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 <tt>AuthenticationSession</tt> 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 <tt>UUID</tt> 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 <tt>AuthenticationSession</tt> 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 <tt>AuthenticationSession</tt> 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 <tt>AuthenticationSession</tt> for given Jabber ID.
|
||||
*
|
||||
* @param jabberId Jabber ID for which authentication session will be
|
||||
* searched.
|
||||
*
|
||||
* @return <tt>AuthenticationSession</tt> for given Jabber ID or
|
||||
* <tt>null</tt> if not found.
|
||||
*/
|
||||
protected AuthenticationSession findSessionForJabberId(Jid jabberId)
|
||||
{
|
||||
if (jabberId == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return findSession(
|
||||
session -> jabberId.equals(session.getUserJabberId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns <tt>AuthenticationSession</tt> for given session identifier or
|
||||
* <tt>null</tt> 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 <tt>AuthenticationSession</tt> 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<AuthenticationSession> 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 <tt>AuthenticationListener</tt>s.
|
||||
* @param l the <tt>AuthenticationListener</tt> to be added to listeners
|
||||
* list.
|
||||
*/
|
||||
@Override
|
||||
public void addAuthenticationListener(AuthenticationListener l)
|
||||
{
|
||||
if (!authenticationListeners.contains(l))
|
||||
{
|
||||
authenticationListeners.add(l);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters from the list of <tt>AuthenticationListener</tt>s.
|
||||
* @param l the <tt>AuthenticationListener</tt> 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 <tt>ConferenceIq</tt> that contains(or not) session id
|
||||
* for the verification.
|
||||
*
|
||||
* @return <tt>null</tt> if no problems where discovered during
|
||||
* verification process or XMPP error that should be returned as response
|
||||
* to given <tt>query</tt>.
|
||||
*/
|
||||
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 <tt>AuthenticationSession</tt>(based on supplied session-id).
|
||||
* When called it means that the request sent by <tt>peerJid</tt> is
|
||||
* valid and has been authenticated for given <tt>session</tt>.
|
||||
* Authentication process is specific to {@link AuthenticationAuthority}
|
||||
* implementation.
|
||||
*
|
||||
* @param session <tt>AuthenticationSession</tt> which corresponds to the
|
||||
* session id supplied in the request.
|
||||
* @param peerJid user's JID that will be associated with
|
||||
* <tt>AuthenticationSession</tt>.
|
||||
* @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 <tt>null</tt>
|
||||
* 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<AuthenticationSession> sessionsIter
|
||||
= authenticationSessions.values().iterator();
|
||||
while (sessionsIter.hasNext())
|
||||
{
|
||||
AuthenticationSession session = sessionsIter.next();
|
||||
if (System.currentTimeMillis()
|
||||
- session.getActivityTimestamp()
|
||||
> authenticationLifetime)
|
||||
{
|
||||
logger.info("Expiring session:" + session);
|
||||
sessionsIter.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* <room_name}@{muc_address}.
|
||||
* @param popup <tt>true</tt> 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 <tt>ConferenceIq</tt> query in order to verify user's
|
||||
* authentication session and eventual permissions for creating new room.
|
||||
*
|
||||
* @param query the <tt>ConferenceIq</tt> requested that should be
|
||||
* authenticated.
|
||||
* @param response the <tt>ConferenceIq</tt> response that will be
|
||||
* returned to the user. Implementing classes can fill
|
||||
* some information in order to describe authentication
|
||||
* session.
|
||||
* @return XMPP error if <tt>query</tt> has failed authentication process.
|
||||
*/
|
||||
IQ processAuthentication(ConferenceIq query, ConferenceIq response);
|
||||
|
||||
|
||||
/**
|
||||
* Process given <tt>LogoutIq</tt> and eventually destroy
|
||||
* <tt>AuthenticationSession</tt> described in the request.
|
||||
*
|
||||
* @param iq the <tt>LogoutIq</tt> 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 <tt>AuthenticationListener</tt>s.
|
||||
* @param l the <tt>AuthenticationListener</tt> to be added to listeners
|
||||
* list.
|
||||
*/
|
||||
void addAuthenticationListener(AuthenticationListener l);
|
||||
|
||||
/**
|
||||
* Unregisters from the list of <tt>AuthenticationListener</tt>s.
|
||||
* @param l the <tt>AuthenticationListener</tt> that will be removed from
|
||||
* authentication listeners list.
|
||||
*/
|
||||
void removeAuthenticationListener(AuthenticationListener l);
|
||||
|
||||
/**
|
||||
* Returns authentication session ID string for given <tt>jabberId</tt> 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 <tt>jabberId</tt>.
|
||||
* @param jabberId the Jabber ID of the connection for which we want to
|
||||
* get user's identity.
|
||||
* @return user login associated with given <tt>jabberId</tt> or
|
||||
* <tt>null</tt>
|
||||
*/
|
||||
String getUserIdentity(Jid jabberId);
|
||||
|
||||
/**
|
||||
* Returns <tt>true</tt> if this is external authentication method that
|
||||
* requires user to visit authentication URL.
|
||||
*/
|
||||
boolean isExternal();
|
||||
|
||||
void start();
|
||||
|
||||
void shutdown();
|
||||
}
|
||||
@@ -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 <tt>userJid</tt> 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);
|
||||
}
|
||||
@@ -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 <tt>AuthenticationSession</tt>.
|
||||
* @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 <tt>AuthenticationSession</tt>.
|
||||
* @return <tt>String</tt> 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();
|
||||
}
|
||||
}
|
||||
88
jicofo/src/main/java/org/jitsi/jicofo/auth/ErrorFactory.java
Normal file
88
jicofo/src/main/java/org/jitsi/jicofo/auth/ErrorFactory.java
Normal file
@@ -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 <tt>query</tt>.
|
||||
*
|
||||
* @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 <tt>query</tt>.
|
||||
*/
|
||||
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 <tt>query</tt>
|
||||
* 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 <tt>query</tt>.
|
||||
*
|
||||
* @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 <tt>query</tt> with
|
||||
* included <tt>errorMessage</tt>.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 <tt>XMPPDomainAuthAuthority</tt> 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 <tt>true</tt>.
|
||||
*
|
||||
* @author Pawel Domas
|
||||
*/
|
||||
public class ExternalJWTAuthority
|
||||
extends XMPPDomainAuthAuthority
|
||||
{
|
||||
/**
|
||||
* Creates new instance of <tt>{@link ExternalJWTAuthority}</tt>.
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -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 <tt>XMPPDomainAuthAuthority</tt>.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -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<ContentPacketExtension> createOffer(OfferOptions options)
|
||||
{
|
||||
List<ContentPacketExtension> 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 <tt>ContentPacketExtension</tt> 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 <tt>ContentPacketExtension</tt> 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 <tt>ContentPacketExtension</tt> 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 <tt>ContentPacketExtension</tt> 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 <tt>ContentPacketExtension</tt>.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<EntityBareJid> 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 <tt>null</tt> if not found.
|
||||
*/
|
||||
Participant getParticipant(Jid mucJid);
|
||||
|
||||
/**
|
||||
* @return the set of regions of the bridges currently in the conference.
|
||||
*/
|
||||
@NotNull Set<String> getBridgeRegions();
|
||||
|
||||
/**
|
||||
* Returns the name of conference multi-user chat room.
|
||||
*/
|
||||
EntityBareJid getRoomName();
|
||||
|
||||
/**
|
||||
* Returns <tt>ChatRoom2</tt> instance for the MUC this instance is
|
||||
* currently in or <tt>null</tt> 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 <tt>null</tt> 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<JibriIq> 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<Bridge, ConferenceBridgeProperties> 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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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<Media> 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<ExtensionElement> 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;
|
||||
}
|
||||
}
|
||||
966
jicofo/src/main/java/org/jitsi/jicofo/jibri/JibriSession.java
Normal file
966
jicofo/src/main/java/org/jitsi/jicofo/jibri/JibriSession.java
Normal file
@@ -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 <tt>true</tt> if given <tt>status</tt> 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
|
||||
* <tt>null</tt> 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 (<tt>true</tt>) or for
|
||||
* regular Jibri (<tt>false</tt>).
|
||||
*/
|
||||
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<ScheduledFuture<?>> 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
|
||||
* <tt>true</tt>.
|
||||
*/
|
||||
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
|
||||
* <tt>true</tt>}.
|
||||
*/
|
||||
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 <tt>true</tt> if it's a SIP session or <tt>false</tt> 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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Features, Override> by config {
|
||||
"jicofo.conference.client-requirements.requirements".from(newConfig)
|
||||
.convertFrom<ConfigObject> { 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<ClientRequirement> 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<Features, Override> {
|
||||
val result = mutableMapOf<Features, Override>()
|
||||
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<ClientRequirement> = emptyList()
|
||||
}
|
||||
}
|
||||
147
jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceConfig.kt
Normal file
147
jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceConfig.kt
Normal file
@@ -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<Int, Int> by config {
|
||||
"jicofo.conference.source-signaling-delays".from(newConfig)
|
||||
.convertFrom<ConfigObject> { 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 `<bridge-session ice-restart="true"/>`).
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
94
jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceRequest.kt
Normal file
94
jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceRequest.kt
Normal file
@@ -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<String, String> = 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)
|
||||
}
|
||||
}
|
||||
47
jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceStore.kt
Normal file
47
jicofo/src/main/kotlin/org/jitsi/jicofo/ConferenceStore.kt
Normal file
@@ -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<JitsiMeetConference>
|
||||
|
||||
/** Get a conference for a specific [Jid] (i.e. name). */
|
||||
fun getConference(jid: EntityBareJid): JitsiMeetConference?
|
||||
|
||||
fun getPinnedConferences(): List<PinnedConference>
|
||||
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
|
||||
)
|
||||
399
jicofo/src/main/kotlin/org/jitsi/jicofo/FocusManager.kt
Normal file
399
jicofo/src/main/kotlin/org/jitsi/jicofo/FocusManager.kt
Normal file
@@ -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<EntityBareJid, JitsiMeetConferenceImpl> = ConcurrentHashMap()
|
||||
private val conferencesCache: MutableList<JitsiMeetConference> = CopyOnWriteArrayList()
|
||||
private val conferencesByMeetingId: MutableMap<String, JitsiMeetConferenceImpl> = ConcurrentHashMap()
|
||||
|
||||
/** The object used to synchronize access to [.conferences]. */
|
||||
private val conferencesSyncRoot = Any()
|
||||
private val listeners: MutableList<ConferenceStore.Listener> = ArrayList()
|
||||
|
||||
/** Holds the conferences that are currently pinned to a specific bridge version. */
|
||||
private val pinnedConferences: MutableMap<EntityBareJid, PinnedConferenceState> = 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 <tt>true</tt> 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<String, String>,
|
||||
/** 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<String, String>,
|
||||
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<ConferenceStore.Listener>
|
||||
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<JibriSession> = 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<ObjectNode>("bridge_failures", bridgeFailures)
|
||||
val participantNotifications = JsonNodeFactory.instance.objectNode()
|
||||
participantNotifications.put("ice_failed", ConferenceMetrics.participantsIceFailed.get())
|
||||
participantNotifications.put("request_restart", ConferenceMetrics.participantsRequestedRestart.get())
|
||||
stats.set<ObjectNode>("participant_notifications", participantNotifications)
|
||||
stats.put("largest_conference", ConferenceMetrics.largestConference.get())
|
||||
stats.put("participants", ConferenceMetrics.currentParticipants.get())
|
||||
stats.put("visitors", ConferenceMetrics.currentVisitors.get())
|
||||
stats.set<ObjectNode>("conference_sizes", ConferenceMetrics.conferenceSizes.toJson())
|
||||
stats.put("endpoint_pairs", ConferenceMetrics.participantPairs.get())
|
||||
stats.set<ObjectNode>(
|
||||
"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<ObjectNode>("queues", getStatistics())
|
||||
return stats
|
||||
}
|
||||
|
||||
fun getDebugState(full: Boolean): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
|
||||
for (conference in getConferences()) {
|
||||
if (full) {
|
||||
set<ObjectNode>(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<PinnedConference> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
267
jicofo/src/main/kotlin/org/jitsi/jicofo/JicofoServices.kt
Normal file
267
jicofo/src/main/kotlin/org/jitsi/jicofo/JicofoServices.kt
Normal file
@@ -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<ObjectNode>(focusManager.stats)
|
||||
|
||||
set<ObjectNode>("bridge_selector", bridgeSelector.stats)
|
||||
JibriDetectorMetrics.appendStats(this)
|
||||
xmppServices.jigasiDetector?.let { set<ObjectNode>("jigasi_detector", it.stats) }
|
||||
set<ObjectNode>("jigasi", xmppServices.jigasiStats)
|
||||
put("threads", GlobalMetrics.threadCount.get())
|
||||
set<ObjectNode>("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<ObjectNode>(
|
||||
"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<ObjectNode>("focus_manager", focusManager.getDebugState(full))
|
||||
set<ObjectNode>("bridge_selector", bridgeSelector.debugState)
|
||||
jibriDetector?.let { set<ObjectNode>("jibri_detector", it.debugState) } ?: putNull("jibri_detector")
|
||||
sipJibriDetector?.let { set<ObjectNode>("sip_jibri_detector", it.debugState) } ?: putNull("sip_jibri_detector")
|
||||
xmppServices.jigasiDetector?.let { set<ObjectNode>("jigasi_detector", it.debugState) }
|
||||
?: putNull("jigasi_detector")
|
||||
set<ObjectNode>("av_moderation", xmppServices.avModerationHandler.debugState)
|
||||
set<ObjectNode>("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()
|
||||
)
|
||||
}
|
||||
}
|
||||
27
jicofo/src/main/kotlin/org/jitsi/jicofo/Offer.kt
Normal file
27
jicofo/src/main/kotlin/org/jitsi/jicofo/Offer.kt
Normal file
@@ -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<ContentPacketExtension>)
|
||||
20
jicofo/src/main/kotlin/org/jitsi/jicofo/ReinviteMethod.kt
Normal file
20
jicofo/src/main/kotlin/org/jitsi/jicofo/ReinviteMethod.kt
Normal file
@@ -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 }
|
||||
92
jicofo/src/main/kotlin/org/jitsi/jicofo/auth/AuthConfig.kt
Normal file
92
jicofo/src/main/kotlin/org/jitsi/jicofo/auth/AuthConfig.kt
Normal file
@@ -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<String> { true }
|
||||
// Read the legacy key from newConfig in case it was set as a System property.
|
||||
LEGACY_LOGIN_URL_PROPERTY_NAME.from(newConfig).convertFrom<String> { true }
|
||||
"jicofo.authentication.enabled".from(newConfig)
|
||||
}
|
||||
|
||||
val loginUrl: String by config {
|
||||
LEGACY_LOGIN_URL_PROPERTY_NAME.from(legacyConfig).convertFrom<String> { 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<String> { it.stripType() }
|
||||
"jicofo.authentication.login-url".from(newConfig)
|
||||
}
|
||||
|
||||
val authenticationLifetime: Duration by config {
|
||||
"org.jitsi.jicofo.auth.AUTH_LIFETIME".from(legacyConfig).convertFrom<Long> {
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
@@ -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<Bridge, Instant> = 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<JitsiMeetConference, Int>): 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 <T> List<Pair<T, Int>>.select(n: Int): Map<T, Int> {
|
||||
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")
|
||||
@@ -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<EntityFullJid, Verdict>()
|
||||
|
||||
/**
|
||||
* 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<ObjectNode>(
|
||||
"requirements",
|
||||
JsonNodeFactory.instance.arrayNode().apply {
|
||||
config.requirements.forEach { add("${it.feature.name}/${it.level.value}") }
|
||||
}
|
||||
)
|
||||
set<ObjectNode>(
|
||||
"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<ClientRequirement>,
|
||||
/** 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]"
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ContentPacketExtension>.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<String, ChatRoom>, allNodes: List<XmppProvider>): 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))
|
||||
}
|
||||
@@ -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<String, String>) {
|
||||
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<String, String>.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<String, String>.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"
|
||||
@@ -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> = 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 <tt>true</tt> 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 <tt>Participant</tt>. */
|
||||
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<SourcesToAddOrRemove>
|
||||
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<ObjectNode>("source_signaling", sourceSignaling.debugState)
|
||||
}
|
||||
statId?.let { put("stats_id", it) }
|
||||
put("invite_runnable", if (inviteRunnable != null) "Running" else "Not running")
|
||||
jingleSession?.let { set<ObjectNode>("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<ContentPacketExtension>): 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<ContentPacketExtension>
|
||||
): 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<ContentPacketExtension>) =
|
||||
onSessionOrTransportAccept(jingleSession, contents, JingleAction.SESSION_ACCEPT)
|
||||
|
||||
private fun onSessionOrTransportAccept(
|
||||
jingleSession: JingleSession,
|
||||
contents: List<ContentPacketExtension>,
|
||||
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
|
||||
* `<bridge-session ice-restart="true"/>`. 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<ContentPacketExtension>
|
||||
): 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<ContentPacketExtension>) =
|
||||
onSessionOrTransportAccept(jingleSession, contents, JingleAction.TRANSPORT_ACCEPT)
|
||||
|
||||
override fun onTransportReject(jingleSession: JingleSession, iq: JingleIQ) {
|
||||
checkJingleSession(jingleSession)?.let { return }
|
||||
|
||||
logger.warn("Received transport-reject: ${iq.toXML()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MediaType> = 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<SourcesToAddOrRemove> {
|
||||
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<ObjectNode>("signaled_sources", signaledSources.toJson())
|
||||
set<ObjectNode>("sources", updatedSources.toJson())
|
||||
set<ArrayNode>(
|
||||
"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()
|
||||
}
|
||||
}
|
||||
@@ -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<ObjectNode>("sources", sources.toJson())
|
||||
}
|
||||
}
|
||||
@@ -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 `<connect>`.
|
||||
*
|
||||
* 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<String, List<String>> = 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<String, String>? = null
|
||||
|
||||
/** Update the aggregated request map (and per-room connect headers) and re-apply. */
|
||||
@Synchronized
|
||||
fun setRequests(
|
||||
requests: Map<String, List<String>>,
|
||||
customHeaders: Map<String, String>?,
|
||||
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<String>()
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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 "<baseName>.<lang>" 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<String, Set<Source>>,
|
||||
val requestNames: Set<String>,
|
||||
val exportNames: Set<String>
|
||||
) {
|
||||
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 `<sender, language>`, named `<baseName>.<language>` 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
|
||||
* `<sender, language>` 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<String, MutableMap<String, Source>>()
|
||||
|
||||
/**
|
||||
* 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<String, List<String>>,
|
||||
baseNameResolver: (String) -> String?,
|
||||
ssrcInUse: (Long) -> Boolean
|
||||
): TranslationSources {
|
||||
val next = mutableMapOf<String, MutableMap<String, Source>>()
|
||||
|
||||
for ((sender, languages) in requests) {
|
||||
if (languages.isEmpty()) continue
|
||||
val baseName = baseNameResolver(sender) ?: continue
|
||||
val existingForSender = allocated[sender]
|
||||
val nextForSender = mutableMapOf<String, Source>()
|
||||
|
||||
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<Long> = allocated.values.flatMap { it.values }.map { it.ssrc }.toSet()
|
||||
|
||||
private fun mintSsrc(ssrcInUse: (Long) -> Boolean, next: Map<String, Map<String, Source>>): 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: "<baseName>.<language>". */
|
||||
fun nameFor(baseName: String, language: String) = "$baseName.$language"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<XmppProvider>
|
||||
) : 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()}")
|
||||
215
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/BaseJibri.kt
Normal file
215
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/BaseJibri.kt
Normal file
@@ -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<JibriSession>
|
||||
|
||||
/**
|
||||
* 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<JibriIq>
|
||||
70
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriConfig.kt
Normal file
70
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriConfig.kt
Normal file
@@ -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<String> {
|
||||
JidCreate.entityBareFrom(it)
|
||||
}
|
||||
"jicofo.jibri.brewery-jid".from(newConfig).convertFrom<String> {
|
||||
JidCreate.entityBareFrom(it)
|
||||
}
|
||||
}
|
||||
|
||||
val sipBreweryJid: EntityBareJid? by optionalconfig {
|
||||
"org.jitsi.jicofo.jibri.SIP_BREWERY".from(legacyConfig).convertFrom<String> {
|
||||
JidCreate.entityBareFrom(it)
|
||||
}
|
||||
"jicofo.jibri-sip.brewery-jid".from(newConfig).convertFrom<String> {
|
||||
JidCreate.entityBareFrom(it)
|
||||
}
|
||||
}
|
||||
|
||||
val pendingTimeout: Duration by config {
|
||||
"org.jitsi.jicofo.jibri.PENDING_TIMEOUT".from(legacyConfig).convertFrom<Long> { 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()
|
||||
}
|
||||
}
|
||||
194
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriDetector.kt
Normal file
194
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriDetector.kt
Normal file
@@ -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
|
||||
|
||||
/**
|
||||
* <tt>JibriDetector</tt> 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<JibriStatusPacketExt>(
|
||||
xmppProvider,
|
||||
breweryJid,
|
||||
JibriStatusPacketExt.ELEMENT,
|
||||
JibriStatusPacketExt.NAMESPACE,
|
||||
createLogger().apply { addContext("type", if (isSip) "sip_jibri" else "jibri") }
|
||||
) {
|
||||
private val eventEmitter = AsyncEventEmitter<EventHandler>(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<Jid, JibriInstance> = 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<ObjectNode>(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
|
||||
}
|
||||
}
|
||||
@@ -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<ObjectNode>(
|
||||
"jibri_detector",
|
||||
JsonNodeFactory.instance.objectNode().apply {
|
||||
put("count", jibriInstanceCount.get())
|
||||
put("available", jibriInstanceAvailableCount.get())
|
||||
}
|
||||
)
|
||||
o.set<ObjectNode>(
|
||||
"sip_jibri_detector",
|
||||
JsonNodeFactory.instance.objectNode().apply {
|
||||
put("count", sipJibriInstanceCount.get())
|
||||
put("available", sipJibriInstanceAvailableCount.get())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
193
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriRecorder.kt
Normal file
193
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriRecorder.kt
Normal file
@@ -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 <tt>null</tt>.
|
||||
*/
|
||||
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<JibriSession>
|
||||
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)
|
||||
}
|
||||
}
|
||||
149
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriSipGateway.kt
Normal file
149
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriSipGateway.kt
Normal file
@@ -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<String, JibriSession> = 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<JibriSession>
|
||||
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")
|
||||
}
|
||||
}
|
||||
78
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriStats.kt
Normal file
78
jicofo/src/main/kotlin/org/jitsi/jicofo/jibri/JibriStats.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
JidCreate.entityBareFrom(it)
|
||||
}
|
||||
"jicofo.jigasi.brewery-jid".from(newConfig).convertFrom<String> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
167
jicofo/src/main/kotlin/org/jitsi/jicofo/jigasi/JigasiDetector.kt
Normal file
167
jicofo/src/main/kotlin/org/jitsi/jicofo/jigasi/JigasiDetector.kt
Normal file
@@ -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<ColibriStatsExtension>(
|
||||
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<Jid> = emptyList(), preferredRegions: Collection<String> = 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<Jid> = emptyList(), preferredRegions: Collection<String> = 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<ObjectNode>(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<BaseBrewery<ColibriStatsExtension>.BrewInstance>,
|
||||
exclude: List<Jid> = emptyList(),
|
||||
preferredRegions: Collection<String> = 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<ColibriStatsExtension>.BrewInstance.isInGracefulShutdown(): Boolean =
|
||||
java.lang.Boolean.parseBoolean(status.getValueAsString(ColibriStatsExtension.SHUTDOWN_IN_PROGRESS))
|
||||
private fun BaseBrewery<ColibriStatsExtension>.BrewInstance.supportsTranscription(): Boolean =
|
||||
java.lang.Boolean.parseBoolean(status.getValueAsString(ColibriStatsExtension.SUPPORTS_TRANSCRIPTION))
|
||||
private fun BaseBrewery<ColibriStatsExtension>.BrewInstance.supportsSip(): Boolean =
|
||||
java.lang.Boolean.parseBoolean(status.getValueAsString(ColibriStatsExtension.SUPPORTS_SIP))
|
||||
private fun BaseBrewery<ColibriStatsExtension>.BrewInstance.isInRegion(vararg regions: String?): Boolean =
|
||||
regions.contains(this.getRegion())
|
||||
private fun BaseBrewery<ColibriStatsExtension>.BrewInstance.getParticipantCount(): Int =
|
||||
status.getValueAsInt(ColibriStatsExtension.PARTICIPANTS) ?: 0
|
||||
private fun BaseBrewery<ColibriStatsExtension>.BrewInstance.getRegion(): String? =
|
||||
status.getValueAsString(ColibriStatsExtension.REGION)
|
||||
private fun List<BaseBrewery<ColibriStatsExtension>.BrewInstance>.leastLoaded() =
|
||||
minByOrNull { it.getParticipantCount() }
|
||||
354
jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/Application.kt
Normal file
354
jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/Application.kt
Normal file
@@ -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<NettyApplicationEngine, NettyApplicationEngine.Configuration> {
|
||||
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<Throwable> { 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<ConferenceRequest>()
|
||||
} 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<ObjectNode>(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<ObjectNode>(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<PinJson>()
|
||||
} 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<UnpinJson>()
|
||||
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
|
||||
}
|
||||
}
|
||||
62
jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/RestConfig.kt
Normal file
62
jicofo/src/main/kotlin/org/jitsi/jicofo/ktor/RestConfig.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
22
jicofo/src/main/kotlin/org/jitsi/jicofo/util/Cancelable.kt
Normal file
22
jicofo/src/main/kotlin/org/jitsi/jicofo/util/Cancelable.kt
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<String>) -> Unit
|
||||
) {
|
||||
private val logger = createChildLogger(parentLogger)
|
||||
private val lock = Any()
|
||||
|
||||
var aggregate: List<String> = emptyList()
|
||||
private set
|
||||
|
||||
var count = 0
|
||||
private set
|
||||
|
||||
private val values = mutableMapOf<String, ValueInfo>()
|
||||
|
||||
/**
|
||||
* Add a preference to the aggregator.
|
||||
*/
|
||||
fun addPreference(prefs: List<String>) {
|
||||
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<String>) {
|
||||
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<ObjectNode>(
|
||||
"ranks",
|
||||
JsonNodeFactory.instance.objectNode().apply {
|
||||
this@PreferenceAggregator.values.asSequence()
|
||||
.sortedBy { it.value.rankAggregate }
|
||||
.forEach { set<ObjectNode>(it.key, it.value.debugState()) }
|
||||
}
|
||||
)
|
||||
set<ArrayNode>("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
107
jicofo/src/main/kotlin/org/jitsi/jicofo/util/RateLimitedStat.kt
Normal file
107
jicofo/src/main/kotlin/org/jitsi/jicofo/util/RateLimitedStat.kt
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<XmppProvider.Component>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 <tt>ConferenceIq</tt> query
|
||||
* @param response <tt>ConferenceIq</tt> response which can be modified during this processing.
|
||||
* @param roomExists <tt>true</tt> if room mentioned in the <tt>query</tt> already exists.
|
||||
*
|
||||
* @return <tt>null</tt> 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<XmppProvider.Component>) {
|
||||
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
|
||||
}
|
||||
@@ -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<AbstractXMPPConnection>,
|
||||
private val conferenceStore: ConferenceStore,
|
||||
/** Whether any jibri detectors are configured (used only for logging). */
|
||||
private val hasJibriDetector: () -> Boolean = { false }
|
||||
) :
|
||||
AbstractIqHandler<JibriIq>(
|
||||
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<JibriIq>): 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)
|
||||
}
|
||||
}
|
||||
272
jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/JigasiIqHandler.kt
Normal file
272
jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/JigasiIqHandler.kt
Normal file
@@ -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<AbstractXMPPConnection>,
|
||||
private val conferenceStore: ConferenceStore,
|
||||
private val jigasiDetector: JigasiDetector
|
||||
) : AbstractIqHandler<DialIq>(
|
||||
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<DialIq>): 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<DialIq>,
|
||||
conference: JitsiMeetConference,
|
||||
retryCount: Int = 2,
|
||||
exclude: List<Jid> = 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
|
||||
184
jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/MuteIqHandler.kt
Normal file
184
jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/MuteIqHandler.kt
Normal file
@@ -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<AbstractXMPPConnection>,
|
||||
private val conferenceStore: ConferenceStore
|
||||
) :
|
||||
AbstractIqHandler<MuteIq>(
|
||||
connections,
|
||||
AbstractMuteIq.ELEMENT,
|
||||
MuteIq.NAMESPACE,
|
||||
setOf(IQ.Type.set),
|
||||
IQRequestHandler.Mode.sync
|
||||
) {
|
||||
override fun handleRequest(request: IqRequest<MuteIq>): IqProcessingResult = handleRequest(
|
||||
MuteRequest(
|
||||
request.iq,
|
||||
request.connection,
|
||||
conferenceStore,
|
||||
request.iq.mute,
|
||||
request.iq.jid,
|
||||
MediaType.AUDIO
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class VideoMuteIqHandler(
|
||||
connections: Set<AbstractXMPPConnection>,
|
||||
private val conferenceStore: ConferenceStore
|
||||
) :
|
||||
AbstractIqHandler<MuteVideoIq>(
|
||||
connections,
|
||||
AbstractMuteIq.ELEMENT,
|
||||
MuteVideoIq.NAMESPACE,
|
||||
setOf(IQ.Type.set),
|
||||
IQRequestHandler.Mode.sync
|
||||
) {
|
||||
override fun handleRequest(request: IqRequest<MuteVideoIq>): IqProcessingResult = handleRequest(
|
||||
MuteRequest(
|
||||
request.iq,
|
||||
request.connection,
|
||||
conferenceStore,
|
||||
request.iq.mute,
|
||||
request.iq.jid,
|
||||
MediaType.VIDEO
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class DesktopMuteIqHandler(
|
||||
connections: Set<AbstractXMPPConnection>,
|
||||
private val conferenceStore: ConferenceStore
|
||||
) :
|
||||
AbstractIqHandler<MuteDesktopIq>(
|
||||
connections,
|
||||
AbstractMuteIq.ELEMENT,
|
||||
MuteDesktopIq.NAMESPACE,
|
||||
setOf(IQ.Type.set),
|
||||
IQRequestHandler.Mode.sync
|
||||
) {
|
||||
override fun handleRequest(request: IqRequest<MuteDesktopIq>): 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")
|
||||
}
|
||||
@@ -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<XmppProvider.Component>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
169
jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/Smack.kt
Normal file
169
jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/Smack.kt
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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<XmppProvider>,
|
||||
/** 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<Pair<XmppProvider, ConnectionListener>> = 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)
|
||||
}
|
||||
}
|
||||
119
jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/VisitorsManager.kt
Normal file
119
jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/VisitorsManager.kt
Normal file
@@ -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<XmppProvider.Component>) {
|
||||
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<ExtensionElement>): 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<ExtensionElement>): 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<ExtensionElement>) {
|
||||
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<XmppProvider.Component>) {
|
||||
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
|
||||
}
|
||||
158
jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppServices.kt
Normal file
158
jicofo/src/main/kotlin/org/jitsi/jicofo/xmpp/XmppServices.kt
Normal file
@@ -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<XmppProvider> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Runnable>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
254
jicofo/src/main/resources/reference.conf
Normal file
254
jicofo/src/main/resources/reference.conf
Normal file
@@ -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 <bridge-session ice-restart="true"/>.
|
||||
// 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 <bridge-session restart="true"/>).
|
||||
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 <broadcast enabled=true> 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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<JitsiMeetConference> {
|
||||
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<ConferenceIq>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
102
jicofo/src/test/kotlin/org/jitsi/jicofo/DebugStateTest.kt
Normal file
102
jicofo/src/test/kotlin/org/jitsi/jicofo/DebugStateTest.kt
Normal file
@@ -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<ChatRoomMember> {
|
||||
every { occupantJid } returns jid
|
||||
every { presence } returns mockk {
|
||||
every {
|
||||
getExtensionElement(ColibriStatsExtension.ELEMENT, ColibriStatsExtension.NAMESPACE)
|
||||
} answers {
|
||||
ColibriStatsExtension()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Exception> { 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FocusManager>(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<ConferenceIq>()
|
||||
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<ConferenceIq>()
|
||||
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<ConferenceIq>()
|
||||
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<ConferenceIq>()
|
||||
it.sessionId shouldNotBe null
|
||||
it.sessionId shouldNotBe user1SessionId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
jicofo/src/test/kotlin/org/jitsi/jicofo/bridge/BridgePinTest.kt
Normal file
114
jicofo/src/test/kotlin/org/jitsi/jicofo/bridge/BridgePinTest.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BridgeSelector>().also { selector ->
|
||||
every { selector.get(any()) } answers { if (firstArg<Any>() == 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<Int>()
|
||||
val conference = mockk<JitsiMeetConference>(relaxed = true) {
|
||||
every { roomName } returns JidCreate.entityBareFrom(name)
|
||||
every { bridges } returns mapOf(
|
||||
bridge to ConferenceBridgeProperties(participantCount)
|
||||
)
|
||||
every { moveEndpoints(bridge, any()) } answers { secondArg<Int>().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<MissingParameterException> { loadRedistributor.moveEndpoint(null, "ep1", null) }
|
||||
shouldThrow<MissingParameterException> {
|
||||
loadRedistributor.moveEndpoint("conf1@conference.example.com", null, null)
|
||||
}
|
||||
}
|
||||
should("fail with an invalid or unknown conference") {
|
||||
shouldThrow<InvalidParameterException> { loadRedistributor.moveEndpoint("invalid jid", "ep1", null) }
|
||||
shouldThrow<ConferenceNotFoundException> {
|
||||
loadRedistributor.moveEndpoint("other@conference.example.com", "ep1", null)
|
||||
}
|
||||
}
|
||||
should("fail with an unknown bridge") {
|
||||
shouldThrow<BridgeNotFoundException> {
|
||||
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<MissingParameterException> { 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<MissingParameterException> { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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<Features>,
|
||||
discovered: Boolean = true,
|
||||
jibri: Boolean = false,
|
||||
jigasi: Boolean = false,
|
||||
transcriber: Boolean = false,
|
||||
id: String = "abcdabcd"
|
||||
) = mockk<ChatRoomMember>(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<Stanza>()
|
||||
|
||||
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<ClientRequirementsIq>()
|
||||
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>()
|
||||
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<ClientRequirementsIq>()
|
||||
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<ClientRequirementsIq>()
|
||||
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<ClientRequirementsIq>()
|
||||
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()
|
||||
@@ -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<ChatRoomMember> = 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<ValidationFailedException> {
|
||||
conference.addSource(mockk(relaxed = true), videoSource)
|
||||
}
|
||||
shouldThrow<ValidationFailedException> {
|
||||
conference.addSource(mockk(relaxed = true), EndpointSourceSet(Source(-1, MediaType.VIDEO)))
|
||||
}
|
||||
|
||||
chatRoom.chatRoom.apply { every { videoSendersCount } returns 10 }
|
||||
shouldThrow<JitsiMeetConferenceImpl.SenderCountExceededException> {
|
||||
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<ValidationFailedException> {
|
||||
conference.addSource(mockk(relaxed = true), audioSource)
|
||||
}
|
||||
shouldThrow<ValidationFailedException> {
|
||||
conference.addSource(mockk(relaxed = true), EndpointSourceSet(Source(-1, MediaType.AUDIO)))
|
||||
}
|
||||
|
||||
chatRoom.chatRoom.apply { every { audioSendersCount } returns 10 }
|
||||
shouldThrow<JitsiMeetConferenceImpl.SenderCountExceededException> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Exception> { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* `<bridge-session ice-restart="true"/>` 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<ConferenceModifyIQ>()
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ColibriSessionManager> {
|
||||
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<List<ContentPacketExtension>>()
|
||||
val sourcesContentsSlot = slot<ConferenceSourceMap>()
|
||||
val conference = mockk<JitsiMeetConferenceImpl> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "<id>-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 <base>.<lang>") {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AbstractXMPPConnection>()
|
||||
val mockXmppProvider = mockk<XmppProvider>().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<SourceInfo> 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<String>? get() = TODO("Not yet implemented")
|
||||
override val features: Set<Features> 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IQ>()
|
||||
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.StartException> {
|
||||
jibriSession.start()
|
||||
}
|
||||
verify(exactly = maxNumRetries + 1) { mockXmppConnection.sendIqAndGetResponse(any()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
context("Trying to start a session with a Jibri that is busy") {
|
||||
val iq = slot<IQ>()
|
||||
// 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()) }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<Jid, RemoteParticipant>()
|
||||
|
||||
// IQs sent by jicofo
|
||||
val requests = mutableListOf<IQ>()
|
||||
|
||||
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<JingleIQ>()
|
||||
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")
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<String> = emptyList()
|
||||
) {
|
||||
val roomName = JidCreate.entityBareFrom(roomNameString)
|
||||
val xmppConnection = ColibriAndJingleXmppConnection()
|
||||
val jingleSessions = mutableListOf<JingleSession>()
|
||||
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<String, MockXmppProvider> = visitorNodeNames.associateWith {
|
||||
MockXmppProvider(xmppConnection.xmppConnection, it, "$it.$xmppDomain")
|
||||
}
|
||||
|
||||
/** The extensions of the VisitorsIqs that the conference sent to the visitors component. */
|
||||
val visitorsIqExtensions = mutableListOf<List<ExtensionElement>>()
|
||||
|
||||
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<String> = visitorsIqExtensions.flatten()
|
||||
.filterIsInstance<DisconnectVnodePacketExtension>().map { it.vnode }
|
||||
|
||||
/** The names of the visitor nodes that the conference asked the visitors component to connect. */
|
||||
fun connectedVnodes(): List<String> = visitorsIqExtensions.flatten()
|
||||
.filterIsInstance<ConnectVnodePacketExtension>().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<String>(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<ChatRoomMember> {
|
||||
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]
|
||||
}
|
||||
@@ -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<JitsiMeetConference> by ArrayList() {
|
||||
override fun getAllConferences() = this
|
||||
override fun getConference(jid: EntityBareJid) = find { it.roomName == jid }
|
||||
override fun getPinnedConferences(): List<PinnedConference> = listOf()
|
||||
override fun pinConference(roomName: EntityBareJid, jvbVersion: String, duration: Duration) { }
|
||||
override fun unpinConference(roomName: EntityBareJid) {}
|
||||
}
|
||||
@@ -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<List<String>>()
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Int>()
|
||||
|
||||
/** Tasks scheduled for later execution, run them with [runPending]. */
|
||||
private val pendingTasks = mutableListOf<Runnable>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Stanza>()
|
||||
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<RejectedWithError>().response.error.condition shouldBe
|
||||
StanzaError.Condition.bad_request
|
||||
}
|
||||
should("reject a request without mute") {
|
||||
request(doMute = null).shouldBeInstanceOf<RejectedWithError>().response.error.condition shouldBe
|
||||
StanzaError.Condition.bad_request
|
||||
}
|
||||
}
|
||||
context("Unknown conference") {
|
||||
request(from = JidCreate.from("other@conference.example.com/muter"))
|
||||
.shouldBeInstanceOf<RejectedWithError>().response.error.condition shouldBe
|
||||
StanzaError.Condition.item_not_found
|
||||
}
|
||||
context("A successful remote mute") {
|
||||
request().shouldBeInstanceOf<AcceptedWithNoResponse>()
|
||||
|
||||
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>()
|
||||
muteIq.to shouldBe mutee
|
||||
muteIq.mute shouldBe true
|
||||
muteIq.actor shouldBe muter
|
||||
}
|
||||
context("A successful self mute") {
|
||||
request(jidToMute = muter, from = muter).shouldBeInstanceOf<AcceptedWithNoResponse>()
|
||||
|
||||
// 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<AcceptedWithNoResponse>()
|
||||
|
||||
sentStanzas.size shouldBe 1
|
||||
val response = sentStanzas[0].shouldBeInstanceOf<IQ>()
|
||||
response.type shouldBe IQ.Type.error
|
||||
response.error.condition shouldBe StanzaError.Condition.not_allowed
|
||||
}
|
||||
context("A mute that fails") {
|
||||
muteResult = MuteResult.ERROR
|
||||
request().shouldBeInstanceOf<AcceptedWithNoResponse>()
|
||||
|
||||
sentStanzas.size shouldBe 1
|
||||
val response = sentStanzas[0].shouldBeInstanceOf<IQ>()
|
||||
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<AcceptedWithNoResponse>()
|
||||
|
||||
io.mockk.verify { conference.handleMuteRequest(muter, mutee, true, MediaType.VIDEO) }
|
||||
val muteIq = sentStanzas[1].shouldBeInstanceOf<MuteVideoIq>()
|
||||
muteIq.to shouldBe mutee
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RoomMetadata>()
|
||||
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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user