snapshot of https://github.com/jitsi/jicofo.git @ 9ab59aa3d348e282da89da2c9f11ecbecd3f7b79 (2026-09-15)
Some checks failed
Java CI with Maven / Java 21 (push) Failing after 4m41s
Java CI with Maven / Java 17 (push) Failing after 4m42s

This commit is contained in:
upstream-mirror
2026-09-15 16:13:53 +05:00
commit fcbc274d01
275 changed files with 41230 additions and 0 deletions

500
jicofo/pom.xml Normal file
View 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>

View 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>

View 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>

View 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);
}
});
}
}

View File

@@ -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();
}
}
}
}
}
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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;
}
}

View 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());
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}

View File

@@ -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()
}
}

View 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()
}
}

View 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)
}
}

View 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
)

View 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)
}
}
}

View 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()
)
}
}

View 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>)

View 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 }

View 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
}

View File

@@ -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")

View File

@@ -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]"
}
}

View File

@@ -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)"
)
}
}

View File

@@ -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))
}

View File

@@ -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"

View File

@@ -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()}")
}
}
}

View File

@@ -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()
}
}

View File

@@ -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())
}
}

View File

@@ -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 }
}
}

View File

@@ -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"
}
}

View File

@@ -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()
}
}

View File

@@ -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()}")

View 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>

View 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()
}
}

View 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
}
}

View File

@@ -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())
}
)
}
}
}

View 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)
}
}

View 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")
}
}

View 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()
}
}

View File

@@ -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()
}
}

View 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() }

View 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
}
}

View 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()
}
}

View File

@@ -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" }
}
}

View 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()
}

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View 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)
}

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View 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

View 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")
}

View File

@@ -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)
}
}

View 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()
}

View File

@@ -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)
}
}

View 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
}

View 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)
}
}
}

View File

@@ -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)
}
}

View 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"
}

View File

@@ -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
}
}
}
}
}
}

View File

@@ -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>()
}
}
}

View File

@@ -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)
}
}
}

View 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()
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}
}
}
}

View 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
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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" }
}
}

View File

@@ -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()

View File

@@ -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()
}
}
}
}

View File

@@ -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
}
}
}
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}
}
}
}
}
})

View File

@@ -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()
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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")
}
}
}
}
}

View 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.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()) }
}
}
})

View File

@@ -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))
}
}
)
}

View File

@@ -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")
}
)
}

View File

@@ -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]
}

View File

@@ -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) {}
}

View File

@@ -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())
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()) }
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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()) }
}
}
}
}

View File

@@ -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