commit 650a143b63c4fbef9ce18dccf17ffca7090c1ea6 Author: upstream-mirror Date: Tue Sep 15 16:13:58 2026 +0500 snapshot of https://github.com/jitsi/jibri.git @ 3b45602c539ec69d128ea152ae587d0b6582191a (2026-09-15) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c9d9750 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +[*.{kt,kts}] +# Keep constructor parameters wrapped one per line, and kotest specs unindented. +ktlint_standard_class-signature = disabled +max_line_length=120 +ktlint_code_style = intellij_idea + +# I find trailing commas annoying +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled + +# This forbids underscores in package names, which we use +ktlint_standard_package-name = disabled diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..80cfdc6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,27 @@ +--- +name: Bug Report +about: Before posting, please make sure you check https://community.jitsi.org + +--- + +*This Issue tracker is only for reporting bugs and tracking code related issues.* + +Before posting, please make sure you check community.jitsi.org to see if the same or similar bugs have already been discussed. General questions, installation help, and feature requests can also be posted to community.jitsi.org. + +## Description +--- + +## Current behavior +--- + +## Expected Behavior +--- + +## Possible Solution +--- + +## Steps to reproduce +--- + +# Environment details +--- diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..c7fa1cb --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,40 @@ +# This workflow will build a Java project with Maven +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven + +name: Java CI with Maven + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + java: [ 17, 21 ] + + name: Java ${{ matrix.java }} + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + + - name: Build with Maven + run: ./resources/build.sh -Pcoverage + + - name: Upload coverage report + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44a4e80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +target/ +.kotlintest +*~ +*.sw? +*.iml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..06724cc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,147 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## About Jibri + +Jibri (JItsi BRoadcasting Infrastructure) provides services for recording or streaming Jitsi Meet conferences. It works by launching a Chrome instance in a virtual framebuffer, capturing and encoding output with ffmpeg. Only one recording at a time is supported per Jibri instance. + +## Build and Test Commands + +### Building +```bash +# Full build with tests and linting +mvn verify + +# Package without running tests +mvn package -DskipTests + +# Build JAR with dependencies +mvn clean package +# Output: target/jibri-8.0-SNAPSHOT-jar-with-dependencies.jar +``` + +### Testing +```bash +# Run all tests +mvn test + +# Run a specific test class +mvn test -Dtest=SeleniumStateMachineTest + +# Run a specific test method +mvn test -Dtest=SeleniumStateMachineTest#testMethodName +``` + +### Linting +```bash +# Run both Java checkstyle and Kotlin ktlint +mvn verify + +# Run only ktlint +mvn exec:exec@ktlint-check + +# Auto-format Kotlin code +mvn exec:exec@ktlint-format +``` + +## Architecture + +### Core Components + +1. **JibriManager** (`JibriManager.kt`) - Central orchestrator that manages Jibri services and tracks busy/idle state. Publishes status updates and enforces single-service-at-a-time constraint. + +2. **Services** (in `service/impl/`) - Three main service types: + - `FileRecordingJibriService` - Records conferences to local files + - `StreamingJibriService` - Streams conferences to RTMP endpoints + - `SipGatewayJibriService` - SIP gateway functionality + + All services extend `StatefulJibriService` and use a state machine pattern. + +3. **APIs** - Three API layers for control: + - `XmppApi` - XMPP-based control via MUC (JibriBrewery) + - `HttpApi` - External HTTP API (port 2222) for start/stop recording + - `InternalHttpApi` - Internal HTTP API (port 3333) for health checks and shutdown + +4. **Selenium Layer** (`selenium/`) - Controls Chrome browser: + - `JibriSelenium` - WebDriver wrapper for browser control + - `SeleniumStateMachine` - Manages browser state transitions + - Status checks in `status_checks/` monitor call health (media received, ICE connection, empty call detection) + +5. **Capture Layer** (`capture/ffmpeg/`) - FFmpeg integration: + - `FfmpegCapturer` - Manages ffmpeg process for audio/video capture + - `FfmpegStatusStateMachine` - Tracks capture state + - Platform-specific capture commands (Linux X11grab, macOS AVFoundation) + +6. **Configuration** (`config/`) - Uses jitsi-metaconfig library: + - `reference.conf` contains all default values + - Override defaults in `/etc/jitsi/jibri/jibri.conf` + - Config hot-reloading triggers graceful restart when idle + +### Control Flow + +1. Request arrives via XMPP or HTTP API +2. JibriManager checks if idle, creates appropriate service +3. Service starts Selenium (Chrome browser joins call) +4. Service starts FFmpeg (captures screen/audio) +5. Status checks monitor call health +6. On completion, service finalizes recording and returns to idle + +### State Management + +Extensive use of state machines via Tinder StateMachine library: +- `JibriServiceStateMachine` - Service lifecycle states +- `SeleniumStateMachine` - Browser states +- `FfmpegStatusStateMachine` - Capture states + +All state transitions are logged and published to metrics. + +## Configuration + +Configuration is layered (priority order): +1. Command-line arguments +2. `jibri.conf` (HOCON format) +3. `reference.conf` (defaults in resources) + +Key config sections: +- `jibri.api.xmpp.environments` - XMPP connection details +- `jibri.ffmpeg.*` - FFmpeg encoding parameters +- `jibri.chrome.flags` - Chrome launch flags +- `jibri.call-status-checks.*` - Timeouts for empty call detection + +## Code Style + +- Primary language: Kotlin (Java only in extreme cases) +- Follow Kotlin style guide (enforced by ktlint) +- Pre-commit hook available: `resources/add_git_pre_commit_script.sh` +- All Kotlin warnings are errors (`-Werror` compiler flag) + +## Testing + +- Test framework: Kotest (JUnit 5 compatible) +- Mocking: MockK for Kotlin +- Test helpers in `src/test/kotlin/org/jitsi/jibri/helpers/` +- Tests use in-memory filesystems for file operations + +## Java Version + +- Source/Target: Java 11 minimum +- Runtime: Java 21 supported (as of recent commits) +- Kotlin JVM target: 11 + +## Dependencies + +Key external dependencies: +- Selenium WebDriver 3.12.0 - Browser automation +- Ktor 3.0 - HTTP server framework +- jicoco libraries - Jitsi common components (XMPP, config, metrics) +- Tinder StateMachine - State machine implementation +- FFmpeg - External binary for capture (not a Maven dependency) + +## Important Notes + +- Jibri requires exclusive access to display and audio devices +- Single-use mode: If `jibri.single-use-mode = true`, Jibri exits after one session +- Health checks exposed at internal HTTP API `/jibri/api/v1.0/health` +- Metrics available via StatsD and optionally Prometheus +- Webhook support for external status notifications diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..088eaa0 --- /dev/null +++ b/README.md @@ -0,0 +1,289 @@ +# Jibri + +JItsi BRoadcasting Infrastructure + +# What is Jibri + +Jibri provides services for recording or streaming a Jitsi Meet conference. + +It works by launching a Chrome instance rendered in a virtual framebuffer and +capturing and encoding the output with ffmpeg. It is intended to be run on a +separate machine (or a VM), with no other applications using the display or +audio devices. Only one recording at a time is supported on a single jibri. + +**NOTE:** Jibri currently only works with a full-fledged Jitsi Meet +installation. Using a different frontend won't work. + +# Installing Jibri + +### Installation notes + +- Jibri was built on Ubuntu 18.04 (Bionic), and has been tested with the + pre-built kernel and extra kernel modules (`linux-image-extra-virtual` + package). Any other distribution or kernel configuration MAY work but has not + been tested. + +## Pre-requisites + +### ALSA and Loopback Device + +- First make sure the ALSA loopback module is available. The extra modules + (including ALSA loopback) can be installed on Ubuntu 16.04 using package name + `linux-image-extra-virtual` +- Perform the following tasks as the root user + - Set up the module to be loaded on boot: `echo "snd_aloop" >> /etc/modules` + - Load the module into the running kernel: `modprobe snd_aloop` + - Check to see that the module is already loaded: `lsmod | grep snd_aloop` +- If the output shows the snd-aloop module loaded, then the ALSA loopback + configuration step is complete. + +### Ffmpeg with X11 capture support + +- Jibri requires a relatively modern ffmpeg install with x11 capture compiled + in. This comes by default in Ubuntu 16.04, by installing the ffmpeg package. +- If building Jibri for Ubuntu 14.04 (trusty), the mc3man repo provides + packages. They can be used by the following in Ubuntu 14.04: + +```bash +sudo add-apt-repository ppa:mc3man/trusty-media +sudo apt-get update +sudo apt-get install ffmpeg +``` + +### Google Chrome stable & Chromedriver + +The latest Google Chrome stable build should be used. It may be able to be +installed direclty via apt, but the manual instructions for installing it are as +follows: + +```bash +sudo su -l +apt-get -y install wget curl gnupg jq unzip + +curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome-keyring.gpg +echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list + +apt-get -y update +apt-get -y install google-chrome-stable +apt-mark hold google-chrome-stable +``` + +Add chrome managed policies file and set +`CommandLineFlagSecurityWarningsEnabled` to `false`. It will hide warnings in +Chrome. You can set it like so: + +```bash +mkdir -p /etc/opt/chrome/policies/managed +echo '{ "CommandLineFlagSecurityWarningsEnabled": false }' >>/etc/opt/chrome/policies/managed/managed_policies.json +``` + +Chromedriver is also required and can be installed like so: + +```bash +CHROME_VER=$(dpkg -s google-chrome-stable | egrep "^Version" | cut -d " " -f2 | cut -d. -f1-3) +CHROMELAB_LINK="https://googlechromelabs.github.io/chrome-for-testing" +CHROMEDRIVER_LINK=$(curl -s $CHROMELAB_LINK/known-good-versions-with-downloads.json | jq -r ".versions[].downloads.chromedriver | select(. != null) | .[].url" | grep linux64 | grep "$CHROME_VER" | tail -1) +wget -O /tmp/chromedriver-linux64.zip $CHROMEDRIVER_LINK + +rm -rf /tmp/chromedriver-linux64 +unzip -o /tmp/chromedriver-linux64.zip -d /tmp +mv /tmp/chromedriver-linux64/chromedriver /usr/local/bin/ +chown root:root /usr/local/bin/chromedriver +chmod 755 /usr/local/bin/chromedriver +``` + +### Jitsi Debian Repository + +The Jibri packages can be found in the stable repository on downloads.jitsi.org. +First install the Jitsi repository key onto your system: + +```bash +curl https://download.jitsi.org/jitsi-key.gpg.key | sudo sh -c 'gpg --dearmor > /usr/share/keyrings/jitsi-keyring.gpg' +``` + +Create a sources.list.d file with the repository: + +```bash +echo 'deb [signed-by=/usr/share/keyrings/jitsi-keyring.gpg] https://download.jitsi.org stable/' | sudo tee /etc/apt/sources.list.d/jitsi-stable.list > /dev/null +``` + +Update your package list: + +```bash +sudo apt-get update +``` + +Install the latest jibri + +```bash +sudo apt-get install jibri +``` + +### User and Group + +- Jibri is currently meant to be run as a regular system user. This example + creatively uses username 'jibri' and group name 'jibri', but any user will do. + This has not been tested with the root user. +- Ensure that the jibri user is in the correct groups to make full access of the + audio and video devices. The example jibri account in Ubuntu 16.04 are: + "adm","audio","video","plugdev". + +```bash +sudo usermod -aG adm,audio,video,plugdev jibri +``` + +### Config files + +- Edit the `jibri.conf` file (installed to `/etc/jitsi/jibri/jibri.conf` by + default) appropriately. You can look at + [reference.conf](src/main/resources/reference.conf) for the default values and + an example of how to set up jibri.conf. Only override the values you want to + change from their defaults in `jibri.conf`. + +``` +jibri { + ..... + api { + xmpp { + environments = [ + { + name = "yourdomain.com" + xmpp-server-hosts = ["1.2.3.4"], + xmpp-domain = "yourdomain.com" + control-login { + domain = "auth.yourdomain.com" + username = "jibri" + password = "jibriauthpass" + port = 5222 + } + control-muc { + domain = "internal.auth.yourdomain.com" + room-name = "JibriBrewery" + nickname = "myjibri-1-2-3-4" + } + call-login { + domain = "recorder.yourdomain.com" + username = "recorder" + password = "jibrirecorderpass" + } + strip-from-room-domain = "conference." + trust-all-xmpp-certs = true + usage-timeout = 0 + } + ] + } + } + ..... +} +``` + +### Logging + +By default, Jibri logs to `/var/log/jitsi/jibri`. If you don't install via the +debian package, you'll need to make sure this directory exists (or change the +location to which Jibri logs by editing the [log config](lib/logging.properties) + +# Configuring a Jitsi Meet environment for Jibri + +Jibri requires some settings to be enabled within a Jitsi Meet configuration. +These changes include virtualhosts and accounts in Prosody, settings for the +jitsi meet web (within config.js) as well as `jicofo.conf`. + +## Prosody + +Create the internal MUC component entry. This is required so that the jibri +clients can be discovered by Jicofo in a MUC that's not externally accessible by +jitsi meet users. Add the following in `/etc/prosody/prosody.cfg.lua`: + +```lua +-- internal muc component, meant to enable pools of jibri and jigasi clients +Component "internal.auth.yourdomain.com" "muc" + modules_enabled = { + "ping"; + } + -- storage should be "none" for prosody 0.10 and "memory" for prosody 0.11 + storage = "memory" + muc_room_cache_size = 1000 +``` + +Create the recorder virtual host entry, to hold the user account for the jibri +chrome session. This is used to restrict only authenticated jibri chrome +sessions to be hidden participants in the conference being recordered. Add the +following in `/etc/prosody/prosody.cfg.lua`: + +```lua +VirtualHost "recorder.yourdomain.com" + modules_enabled = { + "ping"; + } + authentication = "internal_hashed" +``` + +Setup the two accounts jibri will use: + +```bash +prosodyctl register jibri auth.yourdomain.com jibriauthpass +prosodyctl register recorder recorder.yourdomain.com jibrirecorderpass +``` + +The first account is the one Jibri will use to log into the control MUC (where +Jibri will send its status and await commands). The second account is the one +Jibri will use as a client in selenium when it joins the call so that it can be +treated in a special way by the Jitsi Meet web UI. + +## Jicofo + +Edit `/etc/jitsi/jicofo/jicofo.conf`, set the appropriate MUC to look for the +Jibri Controllers. This should be the same MUC as is referenced in jibri's +`jibri.conf` file. Restart Jicofo after setting this property. It's also +suggested to set the pending-timeout to 90 seconds, to allow the Jibri some time +to start up before being marked as failed. + +``` +jicofo { + ... + jibri { + brewery-jid = "JibriBrewery@internal.auth.yourdomain.com" + pending-timeout = 90 seconds + } + ... +} +``` + +## Jitsi Meet + +Edit the `/etc/jitsi/meet/yourdomain-config.js` file, add/set the following +properties: + +```javascript +// recording +config.recordingService = { + enabled: true, + sharingEnabled: true, + hideStorageWarning: false, +}; + +// liveStreaming +config.liveStreaming = { + enabled: true, +}; + +config.hiddenDomain = "recorder.yourdomain.com"; +``` + +Once recording is enabled in `yourdomain-config.js`, the recording button will +become available in the user interface. However, until a valid jibri is seen by +Jicofo, the mesage "Recording currently unavailable" will be displayed when it +is pressed. Once a jibri connects successfully, the user will instead be +prompted to enter a stream key. + +**Note**: Make sure to update Jibri's `jibri.conf` appropriately to match any +config done above. + +## Start Jibri + +Once you have configured `jibri.conf`, start the jibri service: + +```bash +sudo systemctl restart jibri +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d844192 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security + +## Reporting security issuess + +We take security very seriously and develop all Jitsi projects to be secure and safe. + +If you find (or simply suspect) a security issue in any of the Jitsi projects, please send us an email to security@jitsi.org. + +**We encourage responsible disclosure for the sake of our users, so please reach out before posting in a public space.** diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..8b1229c --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..948ad10 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +jibri (0.2.0-1) UNRELEASED; urgency=medium + + * Initial release. (Closes: #XXXXXX) + + -- brian Tue, 27 Mar 2018 14:04:40 -0700 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..f599e28 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +10 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..75ca5a4 --- /dev/null +++ b/debian/control @@ -0,0 +1,12 @@ +Source: jibri +Maintainer: dev@jitsi.org +Section: net +Priority: optional +Standards-Version: 3.9.2 +Build-Depends: openjdk-17-jdk | openjdk-21-jdk, debhelper (>= 9) + +Package: jibri +Architecture: all +Depends: openjdk-17-jre-headless | openjdk-17-jre | openjdk-21-jre-headless | openjdk-21-jre, ffmpeg, curl, alsa-utils, icewm, xserver-xorg-video-dummy, procps, ruby-hocon +Description: Jibri + Jibri can be used to capture data from a Jitsi Meet conference and record it to a file or stream it to a url diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..da04883 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,13 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: Jibri +Upstream-Contact: Emil Ivov +Source: https://github.com/jitsi/jibri + +Files: * +Copyright: 2018 Atlassian Pty Ltd +License: Apache-2.0 + +License: Apache-2.0 +On Debian systems, the full text of the Apache + License version 2 can be found in the file + '/usr/share/common-licenses/Apache-2.0'. diff --git a/debian/jibri.install b/debian/jibri.install new file mode 100644 index 0000000..8174c89 --- /dev/null +++ b/debian/jibri.install @@ -0,0 +1,23 @@ +#!/usr/bin/dh-exec + +resources/debian-package/etc/jitsi/jibri/asoundrc etc/jitsi/jibri +resources/debian-package/etc/jitsi/jibri/jibri.conf etc/jitsi/jibri +resources/debian-package/etc/jitsi/jibri/icewm.preferences etc/jitsi/jibri +resources/debian-package/etc/jitsi/jibri/xorg-video-dummy.conf etc/jitsi/jibri +resources/debian-package/etc/jitsi/jibri/pjsua.config etc/jitsi/jibri + +resources/debian-package/etc/systemd/system/jibri-icewm.service etc/systemd/system +resources/debian-package/etc/systemd/system/jibri.service etc/systemd/system +resources/debian-package/etc/systemd/system/jibri-xorg.service etc/systemd/system + +resources/debian-package/opt/jitsi/jibri/launch.sh opt/jitsi/jibri +resources/debian-package/opt/jitsi/jibri/graceful_shutdown.sh opt/jitsi/jibri +resources/debian-package/opt/jitsi/jibri/wait_graceful_shutdown.sh opt/jitsi/jibri +resources/debian-package/opt/jitsi/jibri/reload.sh opt/jitsi/jibri +resources/debian-package/opt/jitsi/jibri/shutdown.sh opt/jitsi/jibri +resources/debian-package/opt/jitsi/jibri/collect-dump-logs.sh opt/jitsi/jibri +resources/debian-package/opt/jitsi/jibri/pjsua.sh opt/jitsi/jibri +resources/debian-package/opt/jitsi/jibri/finalize_sip.sh opt/jitsi/jibri + +target/jibri.jar opt/jitsi/jibri +lib/logging.properties etc/jitsi/jibri diff --git a/debian/postinst b/debian/postinst new file mode 100755 index 0000000..007406a --- /dev/null +++ b/debian/postinst @@ -0,0 +1,50 @@ +#!/bin/bash + +die () { + echo >&2 "$@" + exit 1 +} + +case "$1" in + configure) + + if ! getent group jitsi > /dev/null ; then + groupadd jitsi + fi + + if ! getent passwd jibri > /dev/null ; then + echo "Creating jibri user and group" + # Create the Jibri user and group + useradd --system --create-home jibri + echo "Done creating jibri user and group" + usermod -a -G jibri,jitsi,audio,video jibri + echo "jibri user added to audio, video and jibri groups" + fi + + # Make the directory for the logs and set the permissions + mkdir -p /var/log/jitsi/jibri + chgrp jitsi /var/log/jitsi/jibri + chmod g+w /var/log/jitsi/jibri + + # Move the asoundrc file to the jibri home directory + if [ -f /etc/jitsi/jibri/asoundrc ] ; then + mv /etc/jitsi/jibri/asoundrc /home/jibri/.asoundrc + fi + + # Make the directory for the icewm preferences and move the preferences file into it + if [ -f /etc/jitsi/jibri/icewm.preferences ] ; then + mkdir -p /home/jibri/.icewm + mv /etc/jitsi/jibri/icewm.preferences /home/jibri/.icewm/preferences + fi + ;; + + abort-upgrade|abort-remove|abort-deconfigure) + ;; + + *) + echo "postinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..b75d55a --- /dev/null +++ b/debian/rules @@ -0,0 +1,7 @@ +#!/usr/bin/make -f + +override_dh_strip_nondeterminism: + # Disables dh_strip_nondeterminism to speed up the debian package creation + +%: + dh $@ diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/doc/apis.md b/doc/apis.md new file mode 100644 index 0000000..52b7a1a --- /dev/null +++ b/doc/apis.md @@ -0,0 +1,15 @@ +# Jibri APIs +Jibri has two types of APIs: +1. One is for 'external' use and is used to control stopping and starting of Jibri services, as well as querying Jibri's health. +1. The other is 'internal' and is used to notify Jibri that there has been a change to its config file. + +There is an HTTP implementation of the 'internal' API which lives in `InternalHttpApi.kt`, documentation for it can be found [here](internal_http_api.md). + +The external API has both XMPP and HTTP implementations, however the HTTP implementation is not fully developed (though it's close to complete functionality). Detailed documentation for the XMPP API can be found [here](xmpp_api.md), and for the HTTP API [here](http_api.md). + +In general, the 'external' APIs boil down to the following available actions: +1. Start a service +1. Stop a service +1. Query the current 'status' of Jibri (is it busy or is it available to start a new service) + +At this time, Jibri only runs a single service at a time, so if it has one running it will consider itself "busy". diff --git a/doc/config_migration_guide.md b/doc/config_migration_guide.md new file mode 100644 index 0000000..24e5c72 --- /dev/null +++ b/doc/config_migration_guide.md @@ -0,0 +1,178 @@ +Jibri is moving to a new config file. The old config file/command-line-arguments will be supported for a while and eventually removed. This doc is a guide on how to migrate from an "old" configuration to a new one. + +The new configuration leverages the [Typesafe config](https://github.com/lightbend/config) library. Default configuration values are now stored in [reference.conf](src/main/resources/reference.conf). Overrides are written to a file and passed via a JVM parameter: +``` +-Dconfig.file=/path/to/config.conf +``` + +### Property migrations +--- +#### Notes +* No default values have been changed, so if you weren't specifying a property before you can continue to leave it unspecified and the value will not change. +* The new configuration library supports different formats and many different features in configuration (substitutions, includes, overrides via environment variables, etc.). The text here describes the 'plainest' way to define a new config file, but other methods are possible. Read about the Typesafe config to learn more. +* The example below assume that you will define a new configuration file called `jibri.conf` + +--- +##### Configuration file +Old method: path to a JSON file via command line argument `--config=/path/to/config.json` +New method: path to a HOCON .conf file passed via JVM arg: `-Dconfig.file=/path/to/config.conf` + +--- +##### Internal and external HTTP API ports +Old method: command line arguments `--http-api-port` and `--internal-http-port` +New method: in `jibri.conf`: +```hocon +jibri { + api { + http { + external-api-port = + internal-api-port = + } + } +} +``` +--- + +##### Single use mode +Old method: in `config.json`: +```json +{ + "single_use_mode": +} +``` +New method: in `jibri.conf`: +```hocon +jibri { + single-use-mode = +} +``` + +--- +##### Recordings directory +Old method: in `config.json`: +```json +{ + "recording_directory": +} +``` +New method: in `jibri.conf`: +```hocon +jibri { + recording { + recordings-directory = + } +} +``` +--- +##### Finalize script path +Old method: in `config.json`: +```json +{ + "finalize_recording_script_path": +} +``` +New method: in `jibri.conf`: +```hocon +jibri { + recording { + finalize-script = + } +} +``` +--- +##### Enabling statsD +Old method: in `config.json`: +```json +{ + "enable_stats_d": +} +``` +New method: in `jibri.conf`: +```hocon +jibri { + stats { + enable-stats-d = + host = + port = + } +} +``` +--- +##### XMPP environment config +Old method: in `config.json`: +```json +{ + "xmpp_environments": [ + { + "name": , + "xmpp_server_hosts": List, + "xmpp_domain": , + "control_login": { + "domain": , + "username": , + "password": + }, + "control_muc": { + "domain": , + "room_name": , + "nickname": + }, + "call_login": { + "domain": , + "username": , + "password": + }, + "room_jid_domain_string_to_strip_from_start": , + "usage_timeout": + }, + { }, + ] +} +``` +New method: in `jibri.conf`: +```hocon +jibri { + api { + xmpp { + environments = [ + { + name = + xmpp-server-hosts = > + xmpp-domain = + + control-muc { + domain = + room-name = + nickname = + } + + control-login { + domain = + username = + password = + } + + sip-control-muc { + domain = + room-name = + nickname = + } + + call-login { + domain = + username = + password = + } + + strip-from-room-domain = + usage-timeout = + + trust-all-xmpp-certs = + }, + { } + ] + } + } +} +``` +An example of a new environments config can be found [here](example_xmpp_envs.conf). diff --git a/doc/debian_package.md b/doc/debian_package.md new file mode 100644 index 0000000..921d409 --- /dev/null +++ b/doc/debian_package.md @@ -0,0 +1,15 @@ +# Building the Jibri debian package +Building the debian package has been tested on Ubuntu Xenial. + +### Steps +* Install the prerequisites: + * `sudo apt install git maven openjdk-8-jdk` + * (`openjdk-8-jdk` specifically is not necessarily required, any java 8 jdk will probably work) +* Clone the repo: + * `git clone https://github.com/jitsi/jibri.git` +* Create the jar: + * `cd jibri` + * `mvn package` +* Call the `create_debian_package_script` and pass it the location of the jar: + * `` export WORKSPACE=`pwd` `` + * `resources/jenkins/release.sh Minor` diff --git a/doc/development.md b/doc/development.md new file mode 100644 index 0000000..ce2d817 --- /dev/null +++ b/doc/development.md @@ -0,0 +1,16 @@ +# Jibri Development +## Style +Code should follow the Kotlin style guide. This style is enforced with ktlint in the project itself, linting can be executed by running `mvn verify` (which will build, run tests and lint) or `mvn exec:exec@ktlint-check` (which will run just the linting; `mvn exec:exec@ktlint-format` auto-formats). Jibri is a Kotlin codebase, so Kotlin should be used for development (save for extreme circumstances where falling back to Java is acceptable). The linter can be run automatically by installing a pre-commit script, you can run [this script](resources/add_git_pre_commit_script.sh) to create/install this hook automatically. + +## Versioning +Jibri uses (annotated) tagged versions and follows [semantic versioning](https://semver.org/). Adding an annotated tag is done as follows: +``` +git tag -a v1.4 -m "my version 1.4" +``` +Tags are not pushed by default when doing `git push`, but a tag can be pushed like a remote branch: +``` +git push origin v1.4 +``` +NOTE: Tagging should not be done as part of a PR, it will be handled separately. + +More can be read about git tagging [here](https://git-scm.com/book/en/v2/Git-Basics-Tagging). diff --git a/doc/example_xmpp_envs.conf b/doc/example_xmpp_envs.conf new file mode 100644 index 0000000..af6ac6f --- /dev/null +++ b/doc/example_xmpp_envs.conf @@ -0,0 +1,65 @@ +// An example file showing the fields for the XMPP environment config. +{ + environments = [ + { + // A user-friendly name for this environment + name = "env name" + + // A list of XMPP server hosts to which we'll connect + xmpp-server-hosts = [ "host1", "host2" ] + + // The base XMPP domain + xmpp-domain = "xmpp-domain" + + // An (optional) base url the Jibri will join if it is set + // base-url = "https://meet.example.com" + + // The MUC we'll join to announce our presence for + // recording and streaming services + control-muc { + domain = "domain" + room-name = "room-name" + nickname = "nickname" + } + + // The login information for the control MUC + control-login { + domain = "domain" + // Optional port, defaults to 5222. + port = 6222 + username = "username" + password = "password" + // Whether to use `username` as is or add a random suffix to it. + randomize-username = false + } + + // An (optional) MUC configuration where we'll + // join to announce SIP gateway services + sip-control-muc { + domain = "domain" + room-name = "room-name" + nickname = "nickname" + } + + // The login information the selenium web client will use + call-login { + domain = "domain" + username = "username" + password = "password" + } + + // The value we'll strip from the room JID domain to derive + // the call URL + strip-from-room-domain = "" + + // How long Jibri sessions will be allowed to last before + // they are stopped. A value of 0 allows them to go on + // indefinitely + usage-timeout = 1 hour + + // Whether or not we'll automatically trust any cert on + // this XMPP domain + trust-all-xmpp-certs = true + } + ] +} diff --git a/doc/http_api.md b/doc/http_api.md new file mode 100644 index 0000000..120d3fb --- /dev/null +++ b/doc/http_api.md @@ -0,0 +1,82 @@ +# Jibri HTTP API + +Jibri has a somewhat-implemented HTTP API to mirror the XMPP API with the following endpoints: + +##### URL +`/jibri/api/v1.0/health` +##### Method +`GET` +##### URL Params +None +##### Data Params +None +##### Response +Code: `200` +Body: +``` +{ + "status":{ + "busyStatus": String // "IDLE", "BUSY" or "EXPIRED" + "health":{ + "healthStatus": String // "HEALTHY" or "UNHEALTHY" + "details": Map // Hash of component -> healthStatus above - only valid is "JibriManager" at present. + } + } +} +``` +This call should always respond with a `200` and the status encoded in the response body. The lack of any response would represent an error of some sort + + +##### URL +`/jibri/api/v1.0/startService` +##### Method +`POST` +##### URL Params +None +##### Data Params +``` +{ + "sessionId": String, // the recording operation session (e.g. RecordTest) + "callParams": { + "callUrlInfo": { + "baseUrl": String, // the base url of the call (e.g. https://meet.jit.si) + "callName": String // the call name to be appended to the base url + } + }, + "callLoginParams": { + "domain": String, // The xmpp domain the Jibri client should log into when joining the call + "username": String, // The username to use for logging in to the above domain + "password": String // The password to use for logging in to the above domain + }, + "sinkType": String, // "stream" for streaming, "file" for recording + "youTubeStreamKey": String // If using "stream" above, this is the YouTube stream key to use +} +``` +##### Success Response +Code: `200` +Body: None +##### Error Response +Code: `412 Precondition Failed` // When the Jibri is already busy +Body: None +Code: `500 Internal Server Error` // When an internal error occurs +Body: None + +##### URL +`/jibri/api/v1.0/stopService` +##### Method +`POST` +##### URL Params +None +##### Data Params +None +##### Response +Code: `200` +Body: None + +This call should always respond with a `200`. The lack of any response would represent an error of some sort. + +Known HTTP API limitations: +* No push status updates. + * No way currently exists for interested parties to be notified of Jibri status changes. Queries must be made to the `health` endpoint. +* No way of passing Jitsi Meet call credentials. + * When Jibri joins a call in order to capture the media, it can use a special set of credentials so that it doesn't show up like a normal participant in the meeting. The HTTP API does not currently have a way of passing-in or configuring these credentials. diff --git a/doc/internal_http_api.md b/doc/internal_http_api.md new file mode 100644 index 0000000..37cbec7 --- /dev/null +++ b/doc/internal_http_api.md @@ -0,0 +1,13 @@ +# Jibri Internal HTTP API +At startup, Jibri reads from a configuration file to determine which (if any) xmpp enviroments to connect to and to read some other configuration data. Jibri takes a simple approach of only reading this file at startup, so if changes are made to the file and you want Jibri to read them, Jibri needs to be restarted. Obviously one would prefer not to restart Jibri while it's busy, so Jibri has an internal API to notify it of config file changes. When the API is called, Jibri will schedule a shutdown for the next time it's idle. It is up for whatever is managing Jibri to restart it. + +##### URL +`/jibri/api/internal/v1.0/notifyConfigChanged` +##### Method +`POST` +##### URL Params +None +##### Data Params +None +##### Response +If Jibri is currently idle, no respose will be sent as Jibri will shutdown immediately. If Jibri is currently busy, it will respond with a `200` diff --git a/doc/local-run/README.md b/doc/local-run/README.md new file mode 100644 index 0000000..f8652eb --- /dev/null +++ b/doc/local-run/README.md @@ -0,0 +1,310 @@ +# Running jibri locally against a docker-jitsi-meet deployment + +This directory contains a script and a docker-compose override that let you +run jibri **from this source tree** while the rest of the Jitsi stack +(prosody, jicofo, jvb, web) runs in docker via +[docker-jitsi-meet](https://github.com/jitsi/docker-jitsi-meet). Useful for +iterating on jibri code changes without rebuilding the docker image. + +## Files + +- `run-jibri-local.sh` — generates `jibri.conf` + `logging.properties` from + your `docker-jitsi-meet/.env`, builds jibri if needed, and launches it. +- `prosody.override.yml` — publishes prosody's port 5222 on `127.0.0.1` so + the host-side jibri can reach it. The default compose only `expose:`s + this port inside the docker network. + +## Prerequisites + +- `docker` + `docker compose` +- `mvn` and Java 17 (to build jibri) +- `ffmpeg` on `PATH` (jibri shells out to it for capture) +- A clone of [docker-jitsi-meet](https://github.com/jitsi/docker-jitsi-meet) + (defaults to `~/dev/docker-jitsi-meet`; override with `DOCKER_JITSI_DIR`) +- A `chromedriver` binary at the jibri repo root, matching your installed + Chrome milestone (download from ) + +## Step by step + +### 1. Set up docker-jitsi-meet + +```sh +cd ~/dev/docker-jitsi-meet +cp env.example .env +./gen-passwords.sh +mkdir -p ~/.jitsi-meet-cfg/{web,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb,jigasi,jibri} +``` + +### 2. Edit `.env`: enable recording and set `PUBLIC_URL` + +Two changes in `~/dev/docker-jitsi-meet/.env`: + +**Enable recording.** This is what tells prosody to provision the `jibri` +and `recorder` accounts (using `JIBRI_XMPP_PASSWORD` / +`JIBRI_RECORDER_PASSWORD` from `gen-passwords.sh`) and what makes the web +UI surface the record button. Without it, jibri can't even log into +prosody. + +```sh +ENABLE_RECORDING=true +``` + +**Point `PUBLIC_URL` at your LAN IP.** Jibri opens the conference URL in +Chrome from the host. `localhost` won't do, because Chrome's WebRTC needs +an address that resolves the same way for jibri *and* the other browsers +joining the call. Use the host's LAN address. + +Find your local IP: + +```sh +# macOS +ipconfig getifaddr en0 +# Linux +hostname -I | awk '{print $1}' +``` + +Then set (HTTP/HTTPS port already exist in env.example — adjust if you +moved them): + +```sh +HTTP_PORT=8000 +HTTPS_PORT=8443 +PUBLIC_URL=https://192.168.1.42:8443 # ← your IP and the HTTPS_PORT +``` + +The script copies `PUBLIC_URL` into jibri's `base-url`, so jibri opens +`https://192.168.1.42:8443/` instead of the default `https://meet.jitsi/`. + +### 3. Bring the stack up with prosody 5222 published + +The host-side jibri talks to prosody over XMPP, so we need 5222 reachable +from the host. Apply the override from this directory: + +```sh +cd ~/dev/docker-jitsi-meet +docker compose \ + -f docker-compose.yml \ + -f ~/dev/jibri/doc/local-run/prosody.override.yml \ + up -d +``` + +Confirm prosody is listening on the host: + +```sh +nc -zv 127.0.0.1 5222 # → "succeeded!" +``` + +### 4. Build jibri + +```sh +cd ~/dev/jibri +mvn -B -DskipTests clean package +``` + +The script will also build automatically if no jar is found in `target/`. + +### 5. Drop a matching `chromedriver` next to the jibri repo + +```sh +# look up your Chrome major version +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version + +# fetch the matching chromedriver and place it at ~/dev/jibri/chromedriver +# (see https://googlechromelabs.github.io/chrome-for-testing/) +chmod +x ~/dev/jibri/chromedriver +``` + +### 6. (Linux only) Set up audio routing for ffmpeg + +Jibri's default ffmpeg config captures audio from the ALSA PCM `plug:bsnoop`, +which is a custom loopback device defined in `/etc/jitsi/jibri/asoundrc` and +backed by the kernel `snd-aloop` module. The .deb postinst sets this up for +the system `jibri` user; if you're running locally as your own user, you +need to do it yourself, or switch to PulseAudio capture. + +**Option A — ALSA loopback (matches the deb):** + +```sh +# 1. Load the kernel loopback module (creates ALSA card "Loopback") +sudo modprobe snd-aloop +# Persist across reboots +echo snd-aloop | sudo tee /etc/modules-load.d/snd-aloop.conf + +# 2. Drop jibri's asoundrc as your user's ALSA config +cp ~/dev/jibri/resources/debian-package/etc/jitsi/jibri/asoundrc ~/.asoundrc + +# Verify +aplay -L | grep bsnoop +arecord -D plug:bsnoop -f cd -d 1 /tmp/t.wav && echo OK +``` + +Caveat: this changes your default ALSA device to a loopback, and on a +modern Ubuntu desktop Chrome routes audio through PulseAudio/PipeWire — so +even with `bsnoop` defined, Chrome won't play into the loopback unless you +also force Chrome to ALSA (e.g. `PULSE_SERVER=/dev/null`). + +**Option B — PulseAudio capture (recommended on a desktop dev box):** + +Skip the asoundrc/snd-aloop setup entirely and override the audio knobs +when invoking the script: + +```sh +JIBRI_AUDIO_SOURCE=pulse \ +JIBRI_AUDIO_DEVICE=default \ +~/dev/jibri/doc/local-run/run-jibri-local.sh +``` + +`default` reads from Pulse's default source. To capture system audio +specifically, find the right monitor source with `pactl list short sources` +and pass it as `JIBRI_AUDIO_DEVICE` (e.g. `alsa_output.pci-…analog-stereo.monitor`). + +### 6b. (macOS only) Capture the screen instead of the camera + +Jibri's default macOS ffmpeg input is `-i 0:0` — whatever avfoundation +device 0 is (usually a camera + the mic). To record the conference you +want a **screen** device for video and a **loopback** device for audio. + +1. **Grant Screen Recording permission** to the terminal app you launch + the script from (System Settings → Privacy & Security → Screen + Recording), then fully quit and relaunch the terminal. Without it + avfoundation silently produces black frames. + +2. **Install a loopback audio device**, e.g. [BlackHole](https://github.com/ExistentialAudio/BlackHole) + (`brew install blackhole-2ch`), and route the call audio into it: + Audio MIDI Setup → `+` → *Create Multi-Output Device* → check both + BlackHole and your speakers, then select that multi-output device as + the system sound output. (Selecting BlackHole alone also works, but + you won't hear anything.) + +3. **Find the device indices**: + + ```sh + ffmpeg -f avfoundation -list_devices true -i "" + # e.g. video: [2] Capture screen 0 audio: [2] BlackHole 2ch + ``` + +4. **Run with the indices**: + + ```sh + JIBRI_MAC_VIDEO_DEVICE=2 JIBRI_MAC_AUDIO_DEVICE=2 \ + ~/dev/jibri/doc/local-run/run-jibri-local.sh + ``` + +Note avfoundation screen devices capture the *whole* display at native +(retina) resolution — there is no per-window capture in ffmpeg on macOS. +The script scales the output down to `JIBRI_RECORDING_RESOLUTION` width, +but whatever else is on that screen ends up in the recording, so keep the +jibri Chrome window in front (or point it at a secondary display, e.g. +`Capture screen 1`). + +### 7. Run jibri + +```sh +~/dev/jibri/doc/local-run/run-jibri-local.sh +``` + +You should see: + +``` +config: /Users/you/dev/jibri/.local/jibri.conf +jar: /Users/you/dev/jibri/target/jibri-*-jar-with-dependencies.jar +chromedriver: /Users/you/dev/jibri/chromedriver +xmpp: 127.0.0.1:5222 (domain=meet.jitsi) + +… INFO: Jibri starting up … +… INFO: Connecting to xmpp environment on 127.0.0.1 … +… INFO: Joined MUC: jibribrewery@internal-muc.meet.jitsi +``` + +Now open `https://192.168.1.42:8443/` in two browsers, click the +record button — jibri will pick up the start IQ and join the call. + +## Where things land + +Generated files live under `~/dev/jibri/.local/`: + +| File | Purpose | +|---|---| +| `jibri.conf` | HOCON config built from `.env` | +| `logging.properties` | JUL config (console + ffmpeg/browser file handlers) | +| `recordings//*.mp4` | recorded files | +| `ffmpeg.0.txt` | ffmpeg stderr (JUL captures it; not on console) | +| `browser.0.txt` | chrome browser logs | +| `chromedriver.log` | chromedriver's own log | + +## Configuration knobs + +All overridable as env vars when invoking the script: + +| Var | Default | Notes | +|---|---|---| +| `JIBRI_DIR` | resolved from script path | jibri repo root | +| `DOCKER_JITSI_DIR` | `$HOME/dev/docker-jitsi-meet` | source of `.env` | +| `XMPP_HOST` | `127.0.0.1` | where prosody is reachable | +| `XMPP_PORT` | `5222` | matches `.env` if set there | +| `JIBRI_INSTANCE_ID` | `jibri-local-$$` | shows up as MUC nickname | +| `CHROMEDRIVER` | `$JIBRI_DIR/chromedriver` | path to chromedriver binary | +| `JIBRI_AUDIO_SOURCE` | `alsa` | Linux: ffmpeg `-f` for audio (`alsa`, `pulse`) | +| `JIBRI_AUDIO_DEVICE` | `plug:bsnoop` | Linux: ffmpeg audio input device (e.g. `default` for pulse) | +| `JIBRI_MAC_VIDEO_DEVICE` | unset | macOS: avfoundation video index (use a `Capture screen N` device) | +| `JIBRI_MAC_AUDIO_DEVICE` | unset | macOS: avfoundation audio index (use a loopback device, e.g. BlackHole) | + +## Troubleshooting + +**`permission denied while trying to connect to the docker API at unix:///var/run/docker.sock`** +— Linux only; your user isn't in the `docker` group. Fix: + +```sh +sudo usermod -aG docker $USER +newgrp docker # apply the new group in this shell, or log out/in +docker ps # verify +``` + +Avoid `sudo docker compose …` — files written to `~/.jitsi-meet-cfg` end up +root-owned and bite you later. + +**`cannot reach prosody at 127.0.0.1:5222`** — you brought up the stack +without `prosody.override.yml`. Re-run step 3. + +**`This version of ChromeDriver only supports Chrome version N`** — your +chromedriver and Chrome milestones don't match. Re-fetch chromedriver for +your Chrome version (step 5). + +**Jibri opens `https://meet.jitsi/` instead of your IP** — `PUBLIC_URL` +isn't set in `.env`, or you didn't restart the script after editing `.env`. + +**`No such file or directory: /var/log/jitsi/jibri/chromedriver.log`** — +older jibri pinned that path unconditionally. Pull a tree where +`webdriver.chrome.logfile` is overridable, or `mkdir -p` the path with +write permissions. + +**ffmpeg quits with `Unknown PCM bsnoop` / `cannot open audio device plug:bsnoop`** +on Linux — you don't have the ALSA loopback set up. Either follow step 6 +Option A (snd-aloop + `~/.asoundrc`) or skip ALSA and use Pulse via +`JIBRI_AUDIO_SOURCE=pulse JIBRI_AUDIO_DEVICE=default`. + +**ffmpeg quits abruptly with "Error opening output files: Invalid argument"** +on macOS — usually an avfoundation arg conflict. Inspect +`~/dev/jibri/.local/ffmpeg.0.txt` for the real ffmpeg stderr. Note that the +default macOS ffmpeg input is `-i 0:0` (camera + system mic), so the +resulting mp4 records your camera, **not** the chrome window — see step 6b +for screen capture via `JIBRI_MAC_VIDEO_DEVICE`/`JIBRI_MAC_AUDIO_DEVICE`. + +**Black video on macOS** — the terminal app that launched the script lacks +the Camera (default `0:0` input) or Screen Recording (screen-capture input) +permission. avfoundation doesn't error on a denied device; it delivers +black frames. Grant the permission in System Settings → Privacy & Security +and fully restart the terminal app. + +**Black video on Linux** — you're on a Wayland session +(`echo $XDG_SESSION_TYPE`), where `x11grab` cannot see the screen and +records black. Either log into an Xorg session, or run a dedicated virtual +display and point both Chrome and ffmpeg at it: + +```sh +Xvfb :1 -screen 0 1280x720x24 & +DISPLAY=:1 ~/dev/jibri/doc/local-run/run-jibri-local.sh +``` + +(the second part — overriding ffmpeg's hardcoded `-i :0.0+0,0` grab target — +currently requires editing `input-linux` in the generated conf). diff --git a/doc/local-run/prosody.override.yml b/doc/local-run/prosody.override.yml new file mode 100644 index 0000000..54ff6ee --- /dev/null +++ b/doc/local-run/prosody.override.yml @@ -0,0 +1,4 @@ +services: + prosody: + ports: + - '127.0.0.1:${XMPP_PORT:-5222}:${XMPP_PORT:-5222}' diff --git a/doc/local-run/run-jibri-local.sh b/doc/local-run/run-jibri-local.sh new file mode 100755 index 0000000..5d3576b --- /dev/null +++ b/doc/local-run/run-jibri-local.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# +# Generate a jibri.conf from a docker-jitsi-meet .env and run jibri from +# this source tree, connecting it to the running docker deployment. +# +# Defaults: +# JIBRI_DIR -> repo root (resolved from this script's location) +# DOCKER_JITSI_DIR -> $HOME/dev/docker-jitsi-meet +# +# Pre-req: prosody port 5222 must be reachable from the host. The default +# docker-jitsi-meet compose only `expose:`s it, so apply prosody.override.yml +# from this directory: +# +# docker compose -f docker-compose.yml \ +# -f /path/to/jibri/doc/local-run/prosody.override.yml up -d +# +# See README.md alongside this script for a full walkthrough. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +JIBRI_DIR="${JIBRI_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd)}" +DOCKER_JITSI_DIR="${DOCKER_JITSI_DIR:-$HOME/dev/docker-jitsi-meet}" +ENV_FILE="$DOCKER_JITSI_DIR/.env" + +[ -f "$ENV_FILE" ] || { echo "missing $ENV_FILE (set DOCKER_JITSI_DIR)" >&2; exit 1; } +[ -d "$JIBRI_DIR" ] || { echo "missing $JIBRI_DIR" >&2; exit 1; } + +set -a +# shellcheck disable=SC1090 +. "$ENV_FILE" +set +a + +# Defaults match jibri/rootfs/defaults/xmpp.conf and jibri.conf templates. +XMPP_DOMAIN="${XMPP_DOMAIN:-meet.jitsi}" +XMPP_AUTH_DOMAIN="${XMPP_AUTH_DOMAIN:-auth.meet.jitsi}" +XMPP_INTERNAL_MUC_DOMAIN="${XMPP_INTERNAL_MUC_DOMAIN:-internal-muc.meet.jitsi}" +XMPP_HIDDEN_DOMAIN="${XMPP_HIDDEN_DOMAIN:-hidden.meet.jitsi}" +XMPP_MUC_DOMAIN="${XMPP_MUC_DOMAIN:-muc.meet.jitsi}" +JIBRI_BREWERY_MUC="${JIBRI_BREWERY_MUC:-jibribrewery}" +JIBRI_RECORDER_USER="${JIBRI_RECORDER_USER:-recorder}" +JIBRI_XMPP_USER="${JIBRI_XMPP_USER:-jibri}" +JIBRI_USAGE_TIMEOUT="${JIBRI_USAGE_TIMEOUT:-0}" +XMPP_PORT="${XMPP_PORT:-5222}" +XMPP_HOST="${XMPP_HOST:-127.0.0.1}" +JIBRI_RECORDING_RESOLUTION="${JIBRI_RECORDING_RESOLUTION:-1280x720}" +JIBRI_AUDIO_SOURCE="${JIBRI_AUDIO_SOURCE:-alsa}" +JIBRI_AUDIO_DEVICE="${JIBRI_AUDIO_DEVICE:-plug:bsnoop}" +# macOS only: avfoundation device indices for screen + audio capture. +# Find them with: ffmpeg -f avfoundation -list_devices true -i "" +# When unset, jibri's default input ("0:0", usually camera + mic) is used. +JIBRI_MAC_VIDEO_DEVICE="${JIBRI_MAC_VIDEO_DEVICE:-}" +JIBRI_MAC_AUDIO_DEVICE="${JIBRI_MAC_AUDIO_DEVICE:-}" +JIBRI_INSTANCE_ID="${JIBRI_INSTANCE_ID:-jibri-local-$$}" +JIBRI_STRIP_DOMAIN_JID="${JIBRI_STRIP_DOMAIN_JID:-${XMPP_MUC_DOMAIN%%.*}}" + +: "${JIBRI_XMPP_PASSWORD:?JIBRI_XMPP_PASSWORD not set in .env (run gen-passwords.sh)}" +: "${JIBRI_RECORDER_PASSWORD:?JIBRI_RECORDER_PASSWORD not set in .env}" + +BASE_URL_LINE="" +[ -n "${PUBLIC_URL:-}" ] && BASE_URL_LINE=" base-url = \"$PUBLIC_URL\"" + +WORKDIR="$JIBRI_DIR/.local" +JIBRI_CONF="$WORKDIR/jibri.conf" +LOGGING="$WORKDIR/logging.properties" +RECORDINGS="$WORKDIR/recordings" +mkdir -p "$WORKDIR" "$RECORDINGS" + +# Probe prosody on the host before generating config / starting jibri. +# Run the /dev/tcp probe in a subshell so neither fd 3 nor stderr leak back here. +if ! (exec 3<>"/dev/tcp/$XMPP_HOST/$XMPP_PORT") 2>/dev/null; then + cat >&2 <&2 + exit 1 + } + FFMPEG_MAC_OVERRIDES=$(cat < "$JIBRI_CONF" < "$LOGGING" </dev/null | head -n1 || true)" +if [ -z "$JAR" ]; then + echo "building jibri..." + (cd "$JIBRI_DIR" && mvn -B -DskipTests clean package) + JAR="$(ls -t "$JIBRI_DIR"/target/jibri-*-jar-with-dependencies.jar | head -n1)" +fi + +CHROMEDRIVER="${CHROMEDRIVER:-$JIBRI_DIR/chromedriver}" +[ -x "$CHROMEDRIVER" ] || { echo "missing chromedriver at $CHROMEDRIVER" >&2; exit 1; } + +echo "config: $JIBRI_CONF" +echo "jar: $JAR" +echo "chromedriver: $CHROMEDRIVER" +echo "xmpp: $XMPP_HOST:$XMPP_PORT (domain=$XMPP_DOMAIN)" +echo + +exec java \ + -Djava.util.logging.config.file="$LOGGING" \ + -Dconfig.file="$JIBRI_CONF" \ + -Dwebdriver.chrome.driver="$CHROMEDRIVER" \ + -Dwebdriver.chrome.logfile="$WORKDIR/chromedriver.log" \ + -jar "$JAR" diff --git a/doc/sip_gateway.md b/doc/sip_gateway.md new file mode 100644 index 0000000..12e8571 --- /dev/null +++ b/doc/sip_gateway.md @@ -0,0 +1,4 @@ +# SIP Gateway +Currently the XMPP API is the only officially supported API, so this doc will discuss how to configure Jibri to allow SIP gateway calls via the XMPP API. + +To use the SIP gateway functionality of Jibri, you'll need to set the `sip_control_muc` field in `config.json`, it can be found within the `XmppEnvironment` field. Jibri will join this MUC to announce itself of being capable of providing SIP gateway services to Jicofo. diff --git a/doc/xmpp_api.md b/doc/xmpp_api.md new file mode 100644 index 0000000..e85f4c1 --- /dev/null +++ b/doc/xmpp_api.md @@ -0,0 +1,9 @@ +# Jibri XMPP API + +Jibri can be configured to join multiple XMPP environments which can be used as a control surface for leveraging Jibri services. The configuration for these environments lives in config.json (a documented sample of config.json can be seen in the code [here](TODO)). For a given XMPP environment, Jibri will: +* Connect to the provided XMPP domain on the provided XMPP host +* Login to a given auth domain with the given credentials +* Join a MUC on the given MUC domain with the given MUC jid using the given MUC nickname +* Publish its status (defined by the status packet extension [here](https://github.com/jitsi/jitsi/blob/master/src/net/java/sip/communicator/impl/protocol/jabber/extensions/jibri/JibriStatusPacketExt.java) + +At this point it will await an IQ message (defined by the custom IQ [here](https://github.com/jitsi/jitsi/blob/master/src/net/java/sip/communicator/impl/protocol/jabber/extensions/jibri/JibriIq.java)) asking it to start or stop a given service. Whenever Jibri's status changes, it will send a new presence to reflect its current state. diff --git a/lib/logging.properties b/lib/logging.properties new file mode 100644 index 0000000..7fc5506 --- /dev/null +++ b/lib/logging.properties @@ -0,0 +1,32 @@ +handlers = java.util.logging.FileHandler + +java.util.logging.FileHandler.level = FINE +java.util.logging.FileHandler.pattern = /var/log/jitsi/jibri/log.%g.txt +java.util.logging.FileHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter +java.util.logging.FileHandler.count = 10 +java.util.logging.FileHandler.limit = 10000000 + +org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.level = FINE +org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.pattern = /var/log/jitsi/jibri/ffmpeg.%g.txt +org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter +org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.count = 10 +org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.limit = 10000000 + +org.jitsi.jibri.sipgateway.pjsua.util.PjsuaFileHandler.level = FINE +org.jitsi.jibri.sipgateway.pjsua.util.PjsuaFileHandler.pattern = /var/log/jitsi/jibri/pjsua.%g.txt +org.jitsi.jibri.sipgateway.pjsua.util.PjsuaFileHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter +org.jitsi.jibri.sipgateway.pjsua.util.PjsuaFileHandler.count = 10 +org.jitsi.jibri.sipgateway.pjsua.util.PjsuaFileHandler.limit = 10000000 + +org.jitsi.jibri.selenium.util.BrowserFileHandler.level = FINE +org.jitsi.jibri.selenium.util.BrowserFileHandler.pattern = /var/log/jitsi/jibri/browser.%g.txt +org.jitsi.jibri.selenium.util.BrowserFileHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter +org.jitsi.jibri.selenium.util.BrowserFileHandler.count = 10 +org.jitsi.jibri.selenium.util.BrowserFileHandler.limit = 10000000 + +org.jitsi.level = FINE +org.jitsi.jibri.config.level = INFO + +org.glassfish.level = INFO +org.osgi.level = INFO +org.jitsi.xmpp.level = INFO diff --git a/lib/test-logging.properties b/lib/test-logging.properties new file mode 100644 index 0000000..35193b0 --- /dev/null +++ b/lib/test-logging.properties @@ -0,0 +1,14 @@ +handlers = java.util.logging.ConsoleHandler + +java.util.logging.ConsoleHandler.level = FINE +java.util.logging.ConsoleHandler.pattern = /var/log/jitsi/jibri/log.%g.txt +java.util.logging.ConsoleHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter +java.util.logging.ConsoleHandler.count = 10 +java.util.logging.ConsoleHandler.limit = 10000000 + +org.jitsi.level = FINE +org.jitsi.jibri.config.level = INFO + +org.glassfish.level = INFO +org.osgi.level = INFO +org.jitsi.xmpp.level = INFO diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3c53b8c --- /dev/null +++ b/pom.xml @@ -0,0 +1,540 @@ + + + 4.0.0 + + org.jitsi + jibri + 8.0-SNAPSHOT + + + 4.43.0 + 2.4.20 + org.jitsi.jibri.MainKt + 17 + 17 + 17 + 2.21.5 + 5.14.4 + 5.9.1 + 1.14.11 + 3.4.3 + 1.1-179-g52cf6ef + 1.0-153-gd5c0102 + 12.0.35 + 1.8.0 + + + + + + + org.eclipse.jetty + jetty-bom + ${jetty.version} + pom + import + + + org.eclipse.jetty.ee10 + jetty-ee10-bom + ${jetty.version} + pom + import + + + + + + + ${project.groupId} + jicoco-mucclient + ${jicoco.version} + + + org.jetbrains.kotlin + kotlin-osgi-bundle + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + + + ${project.groupId} + jicoco-config + ${jicoco.version} + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + + + ${project.groupId} + jicoco-jwt + ${jicoco.version} + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + org.seleniumhq.selenium + selenium-java + ${selenium.version} + + + org.seleniumhq.selenium + selenium-support + ${selenium.version} + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-annotations + 2.21 + + + com.fasterxml.jackson.module + jackson-module-kotlin + ${jackson.version} + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-json-provider + ${jackson.version} + + + net.sourceforge.argparse4j + argparse4j + 0.8.1 + + + ${project.groupId} + jitsi-xmpp-extensions + 1.0-124-g024f15d + + + com.datadoghq + java-dogstatsd-client + 2.5 + + + com.tinder.statemachine + statemachine + 0.2.0 + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + + + io.ktor + ktor-client-apache5-jvm + ${ktor.version} + + + io.ktor + ktor-client-jackson-jvm + ${ktor.version} + + + io.ktor + ktor-server-core-jvm + ${ktor.version} + + + io.ktor + ktor-server-jetty-jakarta-jvm + ${ktor.version} + + + io.ktor + ktor-serialization-jackson-jvm + ${ktor.version} + + + io.ktor + ktor-server-content-negotiation-jvm + ${ktor.version} + + + io.ktor + ktor-client-content-negotiation-jvm + ${ktor.version} + + + ${project.groupId} + jitsi-metaconfig + 1.0-18-ge06bc45 + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + + + ${project.groupId} + jitsi-utils + ${jitsi.utils.version} + + + com.typesafe + config + 1.4.0 + + + + org.jitsi + jicoco-test-kotlin + ${jicoco.version} + + + io.kotest + kotest-runner-junit5-jvm + ${kotest.version} + test + + + io.kotest + kotest-assertions-core-jvm + ${kotest.version} + test + + + io.kotest + kotest-extensions-junitxml-jvm + ${kotest.version} + test + + + org.jdom + jdom2 + 2.0.6 + test + + + io.mockk + mockk-jvm + ${mockk.version} + test + + + io.ktor + ktor-client-mock-jvm + ${ktor.version} + + + io.ktor + ktor-server-test-host-jvm + ${ktor.version} + test + + + io.ktor + ktor-client-jetty-jvm + + + + + + io.ktor + ktor-client-jetty-jakarta-jvm + ${ktor.version} + test + + + + net.bytebuddy + byte-buddy + 1.14.6 + test + + + com.github.marschall + memoryfilesystem + 1.2.1 + test + + + org.jitsi + jicoco-metrics + ${jicoco.version} + compile + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0-M3 + + + enforce-maven + + enforce + + + + + 3.5.0 + + + + + + + + kotlin-maven-plugin + org.jetbrains.kotlin + ${kotlin.version} + + + compile + compile + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + -Werror + -opt-in=kotlin.ExperimentalStdlibApi + -opt-in=kotlin.time.ExperimentalTime + + + + + test-compile + test-compile + + + ${project.basedir}/src/test/kotlin + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + + default-compile + none + + + + default-testCompile + none + + + java-compile + compile + compile + + + java-test-compile + test-compile + testCompile + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + --add-opens java.base/java.lang=ALL-UNNAMED + --add-opens java.base/java.util=ALL-UNNAMED + --add-opens java.base/java.lang.reflect=ALL-UNNAMED + + + + ${project.build.directory}/surefire-reports + ${project.basedir}/lib/test-logging.properties + true + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.1.1 + + + make-assembly + package + single + + + + ${exec.mainClass} + + + + jar-with-dependencies + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.1 + + checkstyle.xml + + + + + check + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + + com.pinterest.ktlint + ktlint-cli + ${ktlint.version} + all + + + + + ktlint-check + verify + + exec + + + java + true + + -classpath + + com.pinterest.ktlint:ktlint-cli + + com.pinterest.ktlint.Main + --relative + src/main/kotlin/**/*.kt + src/test/kotlin/**/*.kt + + + + + ktlint-format + + exec + + + java + true + + -classpath + + com.pinterest.ktlint:ktlint-cli + + com.pinterest.ktlint.Main + --format + --relative + src/main/kotlin/**/*.kt + src/test/kotlin/**/*.kt + + + + + + + + + + + jitsi-maven-repository-releases + default + Jitsi Maven Repository (Releases) + + true + + + false + + https://github.com/jitsi/jitsi-maven-repository/raw/master/releases/ + + + jitsi-maven-repository-snapshots + default + Jitsi Maven Repository (Snapshots) + + false + + + true + + https://github.com/jitsi/jitsi-maven-repository/raw/master/snapshots/ + + + + + + coverage + + 0.8.8 + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + prepare-agent + + + + report + test + + report + + + + + + + + + + diff --git a/resources/add_git_pre_commit_script.sh b/resources/add_git_pre_commit_script.sh new file mode 100755 index 0000000..91c27ee --- /dev/null +++ b/resources/add_git_pre_commit_script.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +PRE_COMMIT_HOOK_FILE=".git/hooks/pre-commit" + +echo "#!/bin/bash" >> $PRE_COMMIT_HOOK_FILE +echo "echo \"Running linter\"" >> $PRE_COMMIT_HOOK_FILE +echo "mvn exec:exec@ktlint-check" >> $PRE_COMMIT_HOOK_FILE + +chmod +x $PRE_COMMIT_HOOK_FILE diff --git a/resources/build.sh b/resources/build.sh new file mode 100755 index 0000000..1cd9ea2 --- /dev/null +++ b/resources/build.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -e + +mvn -B clean verify package "$@" diff --git a/resources/debian-package/etc/jitsi/jibri/asoundrc b/resources/debian-package/etc/jitsi/jibri/asoundrc new file mode 100644 index 0000000..adb3d63 --- /dev/null +++ b/resources/debian-package/etc/jitsi/jibri/asoundrc @@ -0,0 +1,45 @@ +pcm.amix { + type dmix + ipc_key 219345 + slave.pcm "hw:Loopback,0,0" +} + +pcm.asnoop { + type dsnoop + ipc_key 219346 + slave.pcm "hw:Loopback_1,1,0" +} + +pcm.aduplex { + type asym + playback.pcm "amix" + capture.pcm "asnoop" +} + +pcm.bmix { + type dmix + ipc_key 219347 + slave.pcm "hw:Loopback_1,0,0" +} + +pcm.bsnoop { + type dsnoop + ipc_key 219348 + slave.pcm "hw:Loopback,1,0" +} + +pcm.bduplex { + type asym + playback.pcm "bmix" + capture.pcm "bsnoop" +} + +pcm.pjsua { + type plug + slave.pcm "bduplex" +} + +pcm.!default { + type plug + slave.pcm "aduplex" +} diff --git a/resources/debian-package/etc/jitsi/jibri/icewm.preferences b/resources/debian-package/etc/jitsi/jibri/icewm.preferences new file mode 100644 index 0000000..572b90f --- /dev/null +++ b/resources/debian-package/etc/jitsi/jibri/icewm.preferences @@ -0,0 +1 @@ +ShowTaskBar = 0 diff --git a/resources/debian-package/etc/jitsi/jibri/jibri.conf b/resources/debian-package/etc/jitsi/jibri/jibri.conf new file mode 100644 index 0000000..d3a9f78 --- /dev/null +++ b/resources/debian-package/etc/jitsi/jibri/jibri.conf @@ -0,0 +1,2 @@ +jibri { +} diff --git a/resources/debian-package/etc/jitsi/jibri/pjsua.config b/resources/debian-package/etc/jitsi/jibri/pjsua.config new file mode 100644 index 0000000..0ddd0a2 --- /dev/null +++ b/resources/debian-package/etc/jitsi/jibri/pjsua.config @@ -0,0 +1,31 @@ +--capture-dev=11 +--playback-dev=14 +--video +--vcapture-dev=1 +--no-color +--log-level=5 +--app-log-level=5 +--auto-update-nat 0 +--disable-stun +--no-tcp +--dis-codec GSM +--dis-codec H263 +--dis-codec iLBC +--dis-codec G722 +--dis-codec speex +--dis-codec pcmu +--dis-codec pcma +--dis-codec opus +--add-codec pcmu +--add-codec pcma +--add-codec speex +--add-codec G722 +--add-codec opus +--no-vad +--ec-tail 0 +--quality 10 +--max-calls=1 +--auto-keyframe=30 +--no-stderr +--log-file=/var/log/jitsi/jibri/pjsua.log +--id "jibri " diff --git a/resources/debian-package/etc/jitsi/jibri/xorg-video-dummy.conf b/resources/debian-package/etc/jitsi/jibri/xorg-video-dummy.conf new file mode 100644 index 0000000..54bb762 --- /dev/null +++ b/resources/debian-package/etc/jitsi/jibri/xorg-video-dummy.conf @@ -0,0 +1,119 @@ +# This xorg configuration file is meant to be used by xpra +# to start a dummy X11 server. +# For details, please see: +# https://xpra.org/Xdummy.html + +Section "ServerFlags" + Option "DontVTSwitch" "true" + Option "AllowMouseOpenFail" "true" + Option "PciForceNone" "true" + Option "AutoEnableDevices" "false" + Option "AutoAddDevices" "false" +EndSection + +Section "Device" + Identifier "dummy_videocard" + Driver "dummy" + Option "ConstantDPI" "true" + #VideoRam 4096000 + #VideoRam 256000 + VideoRam 192000 +EndSection + +Section "Monitor" + Identifier "dummy_monitor" + HorizSync 5.0 - 1000.0 + VertRefresh 5.0 - 200.0 + #This can be used to get a specific DPI, but only for the default resolution: + #DisplaySize 508 317 + #NOTE: the highest modes will not work without increasing the VideoRam + # for the dummy video card. + Modeline "32768x32768" 15226.50 32768 35800 39488 46208 32768 32771 32781 32953 + Modeline "32768x16384" 7516.25 32768 35544 39192 45616 16384 16387 16397 16478 + Modeline "16384x8192" 2101.93 16384 16416 24400 24432 8192 8390 8403 8602 + Modeline "8192x4096" 424.46 8192 8224 9832 9864 4096 4195 4202 4301 + Modeline "5496x1200" 199.13 5496 5528 6280 6312 1200 1228 1233 1261 + Modeline "5280x1080" 169.96 5280 5312 5952 5984 1080 1105 1110 1135 + Modeline "5280x1200" 191.40 5280 5312 6032 6064 1200 1228 1233 1261 + Modeline "5120x3200" 199.75 5120 5152 5904 5936 3200 3277 3283 3361 + Modeline "4800x1200" 64.42 4800 4832 5072 5104 1200 1229 1231 1261 + Modeline "3840x2880" 133.43 3840 3872 4376 4408 2880 2950 2955 3025 + Modeline "3840x2560" 116.93 3840 3872 4312 4344 2560 2622 2627 2689 + Modeline "3840x2048" 91.45 3840 3872 4216 4248 2048 2097 2101 2151 + Modeline "3840x1080" 100.38 3840 3848 4216 4592 1080 1081 1084 1093 + Modeline "3600x1200" 106.06 3600 3632 3984 4368 1200 1201 1204 1214 + Modeline "3288x1080" 39.76 3288 3320 3464 3496 1080 1106 1108 1135 + Modeline "2048x2048" 49.47 2048 2080 2264 2296 2048 2097 2101 2151 + Modeline "2048x1536" 80.06 2048 2104 2312 2576 1536 1537 1540 1554 + Modeline "2560x1600" 47.12 2560 2592 2768 2800 1600 1639 1642 1681 + Modeline "2560x1440" 42.12 2560 2592 2752 2784 1440 1475 1478 1513 + Modeline "1920x1440" 69.47 1920 1960 2152 2384 1440 1441 1444 1457 + Modeline "1920x1200" 26.28 1920 1952 2048 2080 1200 1229 1231 1261 + Modeline "1920x1080" 23.53 1920 1952 2040 2072 1080 1106 1108 1135 + Modeline "1680x1050" 20.08 1680 1712 1784 1816 1050 1075 1077 1103 + Modeline "1600x1200" 22.04 1600 1632 1712 1744 1200 1229 1231 1261 + Modeline "1600x900" 33.92 1600 1632 1760 1792 900 921 924 946 + Modeline "1440x900" 30.66 1440 1472 1584 1616 900 921 924 946 + ModeLine "1366x768" 72.00 1366 1414 1446 1494 768 771 777 803 + Modeline "1280x1024" 31.50 1280 1312 1424 1456 1024 1048 1052 1076 + Modeline "1280x800" 24.15 1280 1312 1400 1432 800 819 822 841 + Modeline "1280x768" 23.11 1280 1312 1392 1424 768 786 789 807 + Modeline "1360x768" 24.49 1360 1392 1480 1512 768 786 789 807 + Modeline "1024x768" 18.71 1024 1056 1120 1152 768 786 789 807 + Modeline "768x1024" 19.50 768 800 872 904 1024 1048 1052 1076 + + + #common resolutions for android devices (both orientations): + Modeline "800x1280" 25.89 800 832 928 960 1280 1310 1315 1345 + Modeline "1280x800" 24.15 1280 1312 1400 1432 800 819 822 841 + Modeline "720x1280" 30.22 720 752 864 896 1280 1309 1315 1345 + Modeline "1280x720" 27.41 1280 1312 1416 1448 720 737 740 757 + Modeline "768x1024" 24.93 768 800 888 920 1024 1047 1052 1076 + Modeline "1024x768" 23.77 1024 1056 1144 1176 768 785 789 807 + Modeline "600x1024" 19.90 600 632 704 736 1024 1047 1052 1076 + Modeline "1024x600" 18.26 1024 1056 1120 1152 600 614 617 631 + Modeline "536x960" 16.74 536 568 624 656 960 982 986 1009 + Modeline "960x536" 15.23 960 992 1048 1080 536 548 551 563 + Modeline "600x800" 15.17 600 632 688 720 800 818 822 841 + Modeline "800x600" 14.50 800 832 880 912 600 614 617 631 + Modeline "480x854" 13.34 480 512 560 592 854 873 877 897 + Modeline "848x480" 12.09 848 880 920 952 480 491 493 505 + Modeline "480x800" 12.43 480 512 552 584 800 818 822 841 + Modeline "800x480" 11.46 800 832 872 904 480 491 493 505 + #resolutions for android devices (both orientations) + #minus the status bar + #38px status bar (and width rounded up) + Modeline "800x1242" 25.03 800 832 920 952 1242 1271 1275 1305 + Modeline "1280x762" 22.93 1280 1312 1392 1424 762 780 783 801 + Modeline "720x1242" 29.20 720 752 856 888 1242 1271 1276 1305 + Modeline "1280x682" 25.85 1280 1312 1408 1440 682 698 701 717 + Modeline "768x986" 23.90 768 800 888 920 986 1009 1013 1036 + Modeline "1024x730" 22.50 1024 1056 1136 1168 730 747 750 767 + Modeline "600x986" 19.07 600 632 704 736 986 1009 1013 1036 + Modeline "1024x562" 17.03 1024 1056 1120 1152 562 575 578 591 + Modeline "536x922" 16.01 536 568 624 656 922 943 947 969 + Modeline "960x498" 14.09 960 992 1040 1072 498 509 511 523 + Modeline "600x762" 14.39 600 632 680 712 762 779 783 801 + Modeline "800x562" 13.52 800 832 880 912 562 575 578 591 + Modeline "480x810" 12.59 480 512 552 584 810 828 832 851 + Modeline "848x442" 11.09 848 880 920 952 442 452 454 465 + Modeline "480x762" 11.79 480 512 552 584 762 779 783 801 +EndSection + +Section "Screen" + Identifier "dummy_screen" + Device "dummy_videocard" + Monitor "dummy_monitor" + DefaultDepth 24 + SubSection "Display" + Viewport 0 0 + Depth 24 + #Modes "1280x720" + Modes "1920x1080" + EndSubSection +EndSection + +Section "ServerLayout" + Identifier "dummy_layout" + Screen "dummy_screen" +EndSection diff --git a/resources/debian-package/etc/systemd/system/jibri-icewm.service b/resources/debian-package/etc/systemd/system/jibri-icewm.service new file mode 100644 index 0000000..df10e79 --- /dev/null +++ b/resources/debian-package/etc/systemd/system/jibri-icewm.service @@ -0,0 +1,16 @@ +[Unit] +Description=Jibri Window Manager +Requires=jibri-xorg.service +After=jibri-xorg.service + +[Service] +User=jibri +Group=jibri +Environment=DISPLAY=:0 +ExecStart=/usr/bin/icewm-session +Restart=on-failure +RestartPreventExitStatus=255 +Type=simple + +[Install] +WantedBy=jibri.service diff --git a/resources/debian-package/etc/systemd/system/jibri-xorg.service b/resources/debian-package/etc/systemd/system/jibri-xorg.service new file mode 100644 index 0000000..9a45aab --- /dev/null +++ b/resources/debian-package/etc/systemd/system/jibri-xorg.service @@ -0,0 +1,17 @@ +[Unit] +Description=Jibri Xorg Process +After=network.target + +[Service] +User=jibri +Group=jibri +Environment=DISPLAY=:0 +ExecStart=/usr/bin/Xorg -nocursor -noreset +extension RANDR +extension RENDER -logfile /var/log/jitsi/jibri/xorg.log -config /etc/jitsi/jibri/xorg-video-dummy.conf :0 +ExecReload=/bin/kill -HUP $MAINPID +KillMode=process +Restart=on-failure +RestartPreventExitStatus=255 +Type=simple + +[Install] +WantedBy=jibri.service jibri-icewm.service diff --git a/resources/debian-package/etc/systemd/system/jibri.service b/resources/debian-package/etc/systemd/system/jibri.service new file mode 100644 index 0000000..4f80d98 --- /dev/null +++ b/resources/debian-package/etc/systemd/system/jibri.service @@ -0,0 +1,19 @@ +[Unit] +Description=Jibri Process +Requires=jibri-icewm.service jibri-xorg.service +After=network.target + +[Service] +User=jibri +Group=jibri +PermissionsStartOnly=true +ExecStartPre=sudo -u jibri /usr/bin/google-chrome --timeout=1000 --headless about:blank +ExecStart=/opt/jitsi/jibri/launch.sh +ExecStop=/opt/jitsi/jibri/graceful_shutdown.sh +ExecReload=/opt/jitsi/jibri/reload.sh +Restart=always +RestartPreventExitStatus=255 +Type=simple + +[Install] +WantedBy=multi-user.target diff --git a/resources/debian-package/opt/jitsi/jibri/collect-dump-logs.sh b/resources/debian-package/opt/jitsi/jibri/collect-dump-logs.sh new file mode 100755 index 0000000..d913c46 --- /dev/null +++ b/resources/debian-package/opt/jitsi/jibri/collect-dump-logs.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# script that creates an archive in current folder +# containing the heap and thread dump and the current log file + +JAVA_HEAPDUMP_PATH="/tmp/java_*.hprof" +STAMP=`date +%Y-%m-%d-%H%M` +jibri_USER="jibri" +jibri_UID=`id -u $jibri_USER` +RUNNING="" +unset PID + +#Find any crashes in /var/crash from our user in the past 20 minutes, if they exist +CRASH_FILES=$(find /var/crash -name '*.crash' -uid $jibri_UID -mmin -20 -type f) +PID=$(systemctl show -p MainPID jibri 2>/dev/null | cut -d= -f2) + +if [ ! -z $PID ]; then + ps -p $PID | grep -q java + [ $? -eq 0 ] && RUNNING="true" +fi +if [ ! -z $RUNNING ]; then + echo "jibri pid $PID" + THREADS_FILE="/tmp/stack-${STAMP}-${PID}.threads" + HEAP_FILE="/tmp/heap-${STAMP}-${PID}.bin" + sudo -u $jibri_USER jstack ${PID} > ${THREADS_FILE} + sudo -u $jibri_USER jmap -dump:live,format=b,file=${HEAP_FILE} ${PID} + tar zcvf jibri-dumps-${STAMP}-${PID}.tgz ${THREADS_FILE} ${HEAP_FILE} ${CRASH_FILES} /var/log/jitsi/jibri/* /tmp/hs_err_* + rm ${HEAP_FILE} ${THREADS_FILE} +else + ls $JAVA_HEAPDUMP_PATH >/dev/null 2>&1 + if [ $? -eq 0 ]; then + echo "jibri not running, but previous heap dump found." + tar zcvf jibri-dumps-${STAMP}-crash.tgz $JAVA_HEAPDUMP_PATH ${CRASH_FILES} /var/log/jitsi/jibri/* /tmp/hs_err_* + rm ${JAVA_HEAPDUMP_PATH} + else + echo "jibri not running, no previous dump found. Including logs only." + tar zcvf jibri-dumps-${STAMP}-crash.tgz ${CRASH_FILES} /var/log/jitsi/jibri/* /tmp/hs_err_* + fi +fi diff --git a/resources/debian-package/opt/jitsi/jibri/finalize_sip.sh b/resources/debian-package/opt/jitsi/jibri/finalize_sip.sh new file mode 100755 index 0000000..9619009 --- /dev/null +++ b/resources/debian-package/opt/jitsi/jibri/finalize_sip.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +LOG_DIR_PATH="/var/log/jitsi/jibri" +LATEST_PJSUA_LOG_FILE="$LOG_DIR_PATH/pjsua.log" +AGGREGATED_PJSUA_LOG_FILE="$LOG_DIR_PATH/pjsua_all.log" + +if [ -f "$LATEST_PJSUA_LOG_FILE" ]; then + echo "Appending pjsua logs from $LATEST_PJSUA_LOG_FILE, to $AGGREGATED_PJSUA_LOG_FILE" + cat "$LATEST_PJSUA_LOG_FILE" >> "$AGGREGATED_PJSUA_LOG_FILE" +fi diff --git a/resources/debian-package/opt/jitsi/jibri/graceful_shutdown.sh b/resources/debian-package/opt/jitsi/jibri/graceful_shutdown.sh new file mode 100755 index 0000000..7980157 --- /dev/null +++ b/resources/debian-package/opt/jitsi/jibri/graceful_shutdown.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +CONF="/etc/jitsi/jibri/jibri.conf" + +PORT=$(hocon -f $CONF get jibri.api.http.internal-api-port 2>/dev/null || true) +[[ -z "$PORT" ]] && PORT=3333 + +curl -sX POST http://127.0.0.1:$PORT/jibri/api/internal/v1.0/gracefulShutdown diff --git a/resources/debian-package/opt/jitsi/jibri/launch.sh b/resources/debian-package/opt/jitsi/jibri/launch.sh new file mode 100755 index 0000000..e434720 --- /dev/null +++ b/resources/debian-package/opt/jitsi/jibri/launch.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec java -Djava.util.logging.config.file=/etc/jitsi/jibri/logging.properties -Dconfig.file="/etc/jitsi/jibri/jibri.conf" -jar /opt/jitsi/jibri/jibri.jar --config "/etc/jitsi/jibri/config.json" diff --git a/resources/debian-package/opt/jitsi/jibri/pjsua.sh b/resources/debian-package/opt/jitsi/jibri/pjsua.sh new file mode 100755 index 0000000..0e15134 --- /dev/null +++ b/resources/debian-package/opt/jitsi/jibri/pjsua.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec /usr/local/bin/pjsua --config-file /etc/jitsi/jibri/pjsua.config "$@" > /dev/null diff --git a/resources/debian-package/opt/jitsi/jibri/reload.sh b/resources/debian-package/opt/jitsi/jibri/reload.sh new file mode 100755 index 0000000..6861919 --- /dev/null +++ b/resources/debian-package/opt/jitsi/jibri/reload.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +CONF="/etc/jitsi/jibri/jibri.conf" + +PORT=$(hocon -f $CONF get jibri.api.http.internal-api-port 2>/dev/null || true) +[[ -z "$PORT" ]] && PORT=3333 + +curl -sX POST http://127.0.0.1:$PORT/jibri/api/internal/v1.0/notifyConfigChanged diff --git a/resources/debian-package/opt/jitsi/jibri/shutdown.sh b/resources/debian-package/opt/jitsi/jibri/shutdown.sh new file mode 100755 index 0000000..27acdca --- /dev/null +++ b/resources/debian-package/opt/jitsi/jibri/shutdown.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +CONF="/etc/jitsi/jibri/jibri.conf" + +PORT=$(hocon -f $CONF get jibri.api.http.internal-api-port 2>/dev/null || true) +[[ -z "$PORT" ]] && PORT=3333 + +curl -sX POST http://127.0.0.1:$PORT/jibri/api/internal/v1.0/shutdown diff --git a/resources/debian-package/opt/jitsi/jibri/wait_graceful_shutdown.sh b/resources/debian-package/opt/jitsi/jibri/wait_graceful_shutdown.sh new file mode 100755 index 0000000..0b41543 --- /dev/null +++ b/resources/debian-package/opt/jitsi/jibri/wait_graceful_shutdown.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# +# 1. The script issues shutdown command via the graceful shutdown command +# If unsuccessful then it exits with 1. +# 2. If the code is ok then it checks if jibri has exited. +# 3. If not then it polls jibri statistics until recording status is not true +# 4. Gives some time for jibri to shutdown. If it does not quit after that +# time then it kills the process. If the process was successfully killed 0 is +# returned and 1 otherwise. +# +# NOTE: script depends on the tool jq, used to parse json, and curl to access URLs +# + +# Initialize arguments +CURL_BIN="/usr/bin/curl" + +# URL for jibri status +STATUS_URL="http://localhost:2222/jibri/api/v1.0/health" +# URL to POST to signal jibri stop/cleanup +STOP_URL="http://localhost:2222/jibri/api/v1.0/stopService" + +# wait this long before failing stop curl command +STOP_TIMEOUT=3600 +# wait this long before failing status curl command +STATUS_TIMEOUT=30 + +# sleep this long between checking jibri status +SLEEP_TIME=10 +# delay shutdown AT MOST 6 hours = 21600 seconds +TERMINATION_DELAY_TIMEOUT=21600 + +function getJibriStatus() { + $CURL_BIN --max-time $STATUS_TIMEOUT $STATUS_URL 2>/dev/null +} + +function stopJibri() { + $CURL_BIN -H 'Content-Type: application/json' -d '{}' --max-time $STOP_TIMEOUT $STOP_URL 2>/dev/null +} + +function isRecording() { + STATUS=`getJibriStatus` + echo $STATUS | jq -r ".status.busyStatus" +} + +verbose=1 + +# Parse arguments +OPTIND=1 +while getopts "h:t:s" opt; do + case "$opt" in + h) + STATUS_URL=$OPTARG + ;; + t) + STATUS_TIMEOUT=$OPTARG + ;; + s) + verbose=0 + ;; + esac +done +shift "$((OPTIND-1))" + + +# Prints info messages +function printInfo { + if [ "$verbose" == "1" ] + then + echo "$@" + fi +} + +# Prints errors +function printError { + echo "$@" 1>&2 +} + +function gracefulShutdownJibri() { + /opt/jitsi/jibri/graceful_shutdown.sh +} + +pid=$(/bin/systemctl show -p MainPID jibri 2>/dev/null | cut -d= -f2) + +echo -n "Graceful shutdown signal..." +gracefulShutdownJibri +shutdownStatus=$? +echo "sent" +if [ $shutdownStatus == 0 ]; +then + printInfo "Graceful shutdown started" + recordingStatus=`isRecording` + SLEEP_COUNT=0 + while [[ $recordingStatus == "BUSY" ]]; do + if [[ $SLEEP_COUNT -ge $TERMINATION_DELAY_TIMEOUT ]]; then + printInfo "WAITED $TERMINATION_DELAY_TIMEOUT seconds, stopping jibri." + # send signal to stop streaming/recording and upload any recordings pending + stopJibri + # mark jibri service as intentionally stopped to avoid restarts + /usr/sbin/service jibri stop + # stop looping + break; + fi + printInfo "A recording is in progress" + sleep $SLEEP_TIME + SLEEP_COUNT=$(( $SLEEP_COUNT + $SLEEP_TIME )) + recordingStatus=`isRecording` + done + + sleep 5 + + # mark jibri service as intentionally stopped to avoid restarts + /usr/sbin/service jibri stop + + if ps -p $pid > /dev/null 2>&1 + then + printInfo "It is still running, lets give it $STATUS_TIMEOUT seconds" + sleep $STATUS_TIMEOUT + if ps -p $pid > /dev/null 2>&1 + then + printError "Jibri did not exit after $STATUS_TIMEOUT sec - killing $pid" + kill $pid + else + printInfo "Jibri shutdown OK" + exit 0 + fi + else + printInfo "Jibri shutdown OK" + exit 0 + fi + # check for 3 seconds if we managed to kill + for I in 1 2 3 + do + if ps -p $pid > /dev/null 2>&1 + then + sleep 1 + fi + done + if ps -p $pid > /dev/null 2>&1 + then + printError "Failed to kill $pid" + printError "Sending force kill to $pid" + kill -9 $pid + if ps -p $pid > /dev/null 2>&1 + then + printError "Failed to force kill $pid" + exit 1 + fi + fi + printInfo "Jibri shutdown OK" + exit 0 +else + printError "Failed to signal shutdown of Jibri: $shutdownStatus" + exit 1 +fi \ No newline at end of file diff --git a/resources/finalize_recording.sh b/resources/finalize_recording.sh new file mode 100755 index 0000000..70a2318 --- /dev/null +++ b/resources/finalize_recording.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +RECORDINGS_DIR=$1 + +echo "This is a dummy finalize script" > /tmp/finalize.out +echo "The script was invoked with recordings directory $RECORDINGS_DIR." >> /tmp/finalize.out +echo "You should put any finalize logic (renaming, uploading to a service" >> /tmp/finalize.out +echo "or storage provider, etc.) in this script" >> /tmp/finalize.out + +exit 0 diff --git a/resources/jenkins/build.sh b/resources/jenkins/build.sh new file mode 100755 index 0000000..c207037 --- /dev/null +++ b/resources/jenkins/build.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -e + +./resources/build.sh diff --git a/resources/jenkins/release.sh b/resources/jenkins/release.sh new file mode 100755 index 0000000..7ef6374 --- /dev/null +++ b/resources/jenkins/release.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -e + +# This script should be run after the build/tests have successfully completed + +# Prune any local tags that aren't on the remote +git fetch --prune origin "+refs/tags/*:refs/tags/*" + +cd $WORKSPACE + +# Let's get version from maven +MVNVER=`xmllint --xpath "/*[local-name()='project']/*[local-name()='version']/text()" pom.xml` +TAG_NAME="v${MVNVER/-SNAPSHOT/}" +echo "Current tag name: $TAG_NAME" + +if ! git rev-parse $TAG_NAME >/dev/null 2>&1 +then + git tag -a $TAG_NAME -m "Tagged automatically by Jenkins" + git push origin $TAG_NAME +else + echo "Tag: $TAG_NAME already exists." +fi + +VERSION_FULL=`git describe --match "v[0-9\.]*" --long` +echo "Full version: ${VERSION_FULL}" + +VERSION=${VERSION_FULL:1} +echo "Package version: ${VERSION}" + +REV=$(git log --pretty=format:'%h' -n 1) + +# bulding the debian package expects the file target/jibri.jar +mv target/jibri-${MVNVER}-jar-with-dependencies.jar target/jibri.jar + +dch -v "${VERSION}-1" "Built from git. $REV" +dch -D unstable -r "" + +dpkg-buildpackage -A -rfakeroot -us -uc diff --git a/src/main/java/org/jitsi/jibri/RecordingSinkType.java b/src/main/java/org/jitsi/jibri/RecordingSinkType.java new file mode 100644 index 0000000..6365a44 --- /dev/null +++ b/src/main/java/org/jitsi/jibri/RecordingSinkType.java @@ -0,0 +1,47 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author bbaldino + * NOTE: this file had to be in java, I couldn't get it to recognize the + * custom JsonCreator constructor to handle enum-case-agnosticisim in kotlin + */ +public enum RecordingSinkType +{ + STREAM("stream"), + FILE("file"), + //TODO: putting gateway in here doesn't feel great (and isn't what the xmpp uses anyway). + // we need some top-level param that denotes what we're doing (recording a file, + // streaming to youtube, or doing a sipgateway) + GATEWAY("gateway"); + + @SuppressWarnings("FieldCanBeLocal") + private final String recordingSinkType; + + RecordingSinkType(final String recordingMode) { + this.recordingSinkType = recordingMode; + } + + @JsonCreator + public static RecordingSinkType fromString(@JsonProperty("sinkType") final String text) { + return RecordingSinkType.valueOf(text.toUpperCase()); + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/CallUrlInfo.kt b/src/main/kotlin/org/jitsi/jibri/CallUrlInfo.kt new file mode 100644 index 0000000..ddf5313 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/CallUrlInfo.kt @@ -0,0 +1,63 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri + +import com.fasterxml.jackson.annotation.JsonIgnore +import java.util.Objects + +/** + * We assume the 'baseUrl' represents a sort of landing page (on the same + * domain) where we can set the necessary local storage values. The call + * url will be created by joining [baseUrl] and [callName] with a "/". If + * set, a list of [urlParams] will be concatenated after the call name with + * a "#" in between. + */ +data class CallUrlInfo( + val baseUrl: String = "", + val callName: String = "", + val tenant: String = "", + val urlParams: List = listOf() +) { + @get:JsonIgnore + val callUrl: String + get() { + return if (urlParams.isNotEmpty()) { + "$baseUrl/$callName#${urlParams.joinToString("&")}" + } else { + "$baseUrl/$callName" + } + } + + override fun equals(other: Any?): Boolean = when { + other == null -> false + this === other -> true + javaClass != other.javaClass -> false + else -> hashCode() == other.hashCode() + } + + override fun hashCode(): Int { + // Purposefully ignore urlParams here + return Objects.hash(baseUrl.lowercase(), callName.lowercase()) + } + + fun withAdditionalUrlParams(params: List): CallUrlInfo = + this.copy(urlParams = (this.urlParams + params).distinct()) + + override fun toString() = "CallUrlInfo(baseUrl='$baseUrl', callName='$callName', " + + "urlParams=${if (urlParams.isEmpty()) "empty" else "not-empty"})" +} diff --git a/src/main/kotlin/org/jitsi/jibri/JibriManager.kt b/src/main/kotlin/org/jitsi/jibri/JibriManager.kt new file mode 100644 index 0000000..3f8efdc --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/JibriManager.kt @@ -0,0 +1,299 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri + +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.config.XmppCredentials +import org.jitsi.jibri.health.EnvironmentContext +import org.jitsi.jibri.metrics.JibriMetrics +import org.jitsi.jibri.selenium.CallParams +import org.jitsi.jibri.service.JibriService +import org.jitsi.jibri.service.JibriServiceStatusHandler +import org.jitsi.jibri.service.ServiceParams +import org.jitsi.jibri.service.impl.FileRecordingJibriService +import org.jitsi.jibri.service.impl.FileRecordingParams +import org.jitsi.jibri.service.impl.SipGatewayJibriService +import org.jitsi.jibri.service.impl.SipGatewayServiceParams +import org.jitsi.jibri.service.impl.StreamingJibriService +import org.jitsi.jibri.service.impl.StreamingParams +import org.jitsi.jibri.status.ComponentBusyStatus +import org.jitsi.jibri.status.ComponentHealthStatus +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.status.ErrorScope +import org.jitsi.jibri.util.StatusPublisher +import org.jitsi.jibri.util.TaskPools +import org.jitsi.jibri.util.extensions.schedule +import org.jitsi.metaconfig.config +import org.jitsi.utils.logging2.createLogger +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +class JibriBusyException : Exception() + +/** + * Some of the values in [FileRecordingParams] come from the configuration + * file, so the incoming request won't contain all of them. This class + * models the subset of values which will come in the request. + */ +data class FileRecordingRequestParams( + /** + * Which call we'll join + */ + val callParams: CallParams, + /** + * The ID of this session + */ + val sessionId: String, + /** + * The login information needed to appear invisible in + * the call + */ + val callLoginParams: XmppCredentials +) + +/** + * [JibriManager] is responsible for managing the various services Jibri + * provides, as well as providing an API to query the health of this Jibri + * instance. NOTE: currently Jibri only runs a single service at a time, so + * if one is running, the Jibri will describe itself as busy + * + * TODO: we mark 'Any' as the status type we publish because we have 2 different status types we want to publish: + * ComponentBusyStatus and ComponentState and i was unable to think of a better solution for that (yet...) + */ +class JibriManager : StatusPublisher() { + private val logger = createLogger() + private var currentActiveService: JibriService? = null + + /** + * Store some arbitrary context optionally sent in the start service request so that we can report it in our + * status + */ + var currentEnvironmentContext: EnvironmentContext? = null + + /** + * A function which will be executed the next time this Jibri is idle. This can be used to schedule work that + * can't be run while a Jibri session is active + */ + private var pendingIdleFunc: () -> Unit = {} + private var serviceTimeoutTask: ScheduledFuture<*>? = null + + private val singleUseMode: Boolean by config { + "JibriConfig::singleUseMode" { Config.legacyConfigSource.singleUseMode!! } + "jibri.single-use-mode".from(Config.configSource) + } + + val jibriMetrics = JibriMetrics() + + /** + * Note: should only be called if the instance-wide lock is held (i.e. called from + * one of the synchronized methods) + * TODO: instead of the synchronized decorators, use a synchronized(this) block + * which we can also use here + */ + private fun throwIfBusy(sinkType: RecordingSinkType) { + if (busy()) { + logger.info("Jibri is busy, can't start service") + jibriMetrics.requestWhileBusy(sinkType) + throw JibriBusyException() + } + } + + /** + * Starts a [FileRecordingJibriService] to record the call described + * in the params to a file. + */ + @Synchronized + fun startFileRecording( + serviceParams: ServiceParams, + fileRecordingRequestParams: FileRecordingRequestParams, + environmentContext: EnvironmentContext? = null, + serviceStatusHandler: JibriServiceStatusHandler? = null + ) { + throwIfBusy(RecordingSinkType.FILE) + logger.info( + "Starting a file recording, sessionId=${fileRecordingRequestParams.sessionId}, " + + "call=${fileRecordingRequestParams.callParams}" + ) + val service = FileRecordingJibriService( + FileRecordingParams( + fileRecordingRequestParams.callParams, + fileRecordingRequestParams.sessionId, + fileRecordingRequestParams.callLoginParams, + serviceParams.appData?.fileRecordingMetadata + ) + ) + jibriMetrics.start(RecordingSinkType.FILE) + startService(service, serviceParams, environmentContext, serviceStatusHandler) + } + + /** + * Starts a [StreamingJibriService] to capture the call according + * to [streamingParams]. + */ + @Synchronized + fun startStreaming( + serviceParams: ServiceParams, + streamingParams: StreamingParams, + environmentContext: EnvironmentContext? = null, + serviceStatusHandler: JibriServiceStatusHandler? = null + ) { + logger.info("Starting a stream, sessionId=${streamingParams.sessionId}, call=${streamingParams.callParams}") + throwIfBusy(RecordingSinkType.STREAM) + val service = StreamingJibriService(streamingParams) + jibriMetrics.start(RecordingSinkType.STREAM) + startService(service, serviceParams, environmentContext, serviceStatusHandler) + } + + @Synchronized + fun startSipGateway( + serviceParams: ServiceParams, + sipGatewayServiceParams: SipGatewayServiceParams, + environmentContext: EnvironmentContext? = null, + serviceStatusHandler: JibriServiceStatusHandler? = null + ) { + logger.info("Starting a SIP gateway, call=${sipGatewayServiceParams.callParams}") + throwIfBusy(RecordingSinkType.GATEWAY) + val service = SipGatewayJibriService( + SipGatewayServiceParams( + sipGatewayServiceParams.callParams, + sipGatewayServiceParams.callLoginParams, + sipGatewayServiceParams.sipClientParams + ) + ) + jibriMetrics.start(RecordingSinkType.GATEWAY) + return startService(service, serviceParams, environmentContext, serviceStatusHandler) + } + + /** + * Helper method to handle the boilerplate of starting a [JibriService]. + */ + private fun startService( + jibriService: JibriService, + serviceParams: ServiceParams, + environmentContext: EnvironmentContext?, + serviceStatusHandler: JibriServiceStatusHandler? = null + ) { + publishStatus(ComponentBusyStatus.BUSY) + if (serviceStatusHandler != null) { + jibriService.addStatusHandler(serviceStatusHandler) + } + // The manager adds its own status handler so that it can stop + // the error'd service and update presence appropriately + jibriService.addStatusHandler { + when (it) { + is ComponentState.Error -> { + if (it.error.scope == ErrorScope.SYSTEM) { + jibriMetrics.error(jibriService.getSinkType()) + publishStatus(ComponentHealthStatus.UNHEALTHY) + } + stopService() + } + + is ComponentState.Finished -> { + // If a 'stop' was received externally, then this stopService call + // will be redundant, but we need to make it anyway as the service + // can also signal that it has finished (based on its own checks) + // and needs to be stopped (cleaned up) + stopService() + } + + else -> { /* No op */ } + } + } + + currentActiveService = jibriService + currentEnvironmentContext = environmentContext + if (serviceParams.usageTimeoutMinutes != 0) { + logger.info("This service will have a usage timeout of ${serviceParams.usageTimeoutMinutes} minute(s)") + serviceTimeoutTask = + TaskPools.recurringTasksPool.schedule(serviceParams.usageTimeoutMinutes.toLong(), TimeUnit.MINUTES) { + logger.info("The usage timeout has elapsed, stopping the currently active service") + try { + stopService() + } catch (t: Throwable) { + logger.error("Error while stopping service due to usage timeout", t) + } + } + } + TaskPools.ioPool.submit { + jibriService.start() + } + } + + /** + * Stop the currently active [JibriService], if there is one + */ + @Synchronized + fun stopService() { + val currentService = currentActiveService ?: run { + // After an initial call to 'stopService', we'll stop ffmpeg and it will transition + // to 'finished', causing the entire service to transition to 'finished' and trigger + // another call to stopService (see the note above when installing the status handler + // on the jibri service). A more complete fix for this is much larger, so for now + // we'll just check if the currentActiveService has already been cleared to prevent + // doing a double stop (which is mostly harmless, but does fire an extra 'stop' + // statsd event with an empty service tag) + logger.info("No service active, ignoring stop") + return + } + jibriMetrics.stop(currentService.getSinkType()) + logger.info("Stopping the current service") + serviceTimeoutTask?.cancel(false) + // Note that this will block until the service is completely stopped + currentService.stop() + currentActiveService = null + currentEnvironmentContext = null + // Invoke the function we've been told to next time we're idle + // and reset it + pendingIdleFunc() + pendingIdleFunc = {} + if (singleUseMode) { + logger.info("Jibri is in single-use mode, not returning to IDLE") + publishStatus(ComponentBusyStatus.EXPIRED) + } else { + publishStatus(ComponentBusyStatus.IDLE) + } + } + + /** + * Returns whether or not this Jibri is currently "busy". "Busy" is + * is defined as "does not currently have the capacity to spin up another + * service" + */ + @Synchronized + fun busy(): Boolean = currentActiveService != null + + /** + * Execute the given function the next time Jibri is idle + */ + @Synchronized + fun executeWhenIdle(func: () -> Unit) { + if (!busy()) { + func() + } else { + pendingIdleFunc = func + } + } +} + +private fun JibriService.getSinkType() = when (this) { + is FileRecordingJibriService -> RecordingSinkType.FILE + is StreamingJibriService -> RecordingSinkType.GATEWAY + is SipGatewayJibriService -> RecordingSinkType.GATEWAY + else -> throw IllegalArgumentException("JibriService of unsupported type: ${JibriService::class.java.name}") +} diff --git a/src/main/kotlin/org/jitsi/jibri/Main.kt b/src/main/kotlin/org/jitsi/jibri/Main.kt new file mode 100644 index 0000000..9b5cddc --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/Main.kt @@ -0,0 +1,245 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri + +import io.ktor.server.engine.embeddedServer +import io.ktor.server.jetty.jakarta.Jetty +import kotlinx.coroutines.CancellationException +import net.sourceforge.argparse4j.ArgumentParsers +import org.jitsi.jibri.api.http.HttpApi +import org.jitsi.jibri.api.http.internal.InternalHttpApi +import org.jitsi.jibri.api.xmpp.XmppApi +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.config.XmppEnvironmentConfig +import org.jitsi.jibri.config.loadConfigFromFile +import org.jitsi.jibri.config.toXmppEnvironment +import org.jitsi.jibri.status.ComponentBusyStatus +import org.jitsi.jibri.status.ComponentHealthStatus +import org.jitsi.jibri.status.JibriStatusManager +import org.jitsi.jibri.util.TaskPools +import org.jitsi.jibri.util.extensions.scheduleAtFixedRate +import org.jitsi.jibri.webhooks.v1.WebhookClient +import org.jitsi.metaconfig.ConfigException +import org.jitsi.metaconfig.MapConfigSource +import org.jitsi.metaconfig.MetaconfigLogger +import org.jitsi.metaconfig.MetaconfigSettings +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.configSupplier +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.LoggerImpl +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.system.exitProcess + +val logger: Logger = LoggerImpl("org.jitsi.jibri.Main") + +fun main(args: Array) { + setupMetaconfigLogger() + handleCommandLineArgs(args) + + logger.info("Jibri starting up with id ${MainConfig.jibriId}") + + val jibriStatusManager = JibriStatusManager() + val jibriManager = JibriManager() + jibriManager.addStatusHandler { jibriStatus -> + when (jibriStatus) { + is ComponentBusyStatus -> { + jibriStatusManager.busyStatus = jibriStatus + } + + is ComponentHealthStatus -> { + jibriStatusManager.updateHealth("JibriManager", jibriStatus) + } + + else -> { + logger.error("Unrecognized status from JibriManager: ${jibriStatus.javaClass} $jibriStatus") + } + } + } + val webhookSubscribers = configSupplier> { + "jibri.webhook.subscribers".from(Config.configSource) + }.get() + + val webhookClient = WebhookClient(MainConfig.jibriId) + + jibriStatusManager.addStatusHandler { + webhookClient.updateStatus(it) + } + jibriStatusManager.addStatusHandler { + jibriManager.jibriMetrics.updateStatus(it) + } + webhookSubscribers.forEach(webhookClient::addSubscriber) + val statusUpdaterTask = TaskPools.recurringTasksPool.scheduleAtFixedRate( + 1, + TimeUnit.MINUTES + ) { + webhookClient.updateStatus(jibriStatusManager.overallStatus) + } + + val cleanupAndExit = { exitCode: Int -> + statusUpdaterTask.cancel(true) + try { + statusUpdaterTask.get(5, TimeUnit.SECONDS) + } catch (t: Throwable) { + when (t) { + is CancellationException -> {} + else -> logger.error("Error cleaning up status updater task", t) + } + } + exitProcess(exitCode) + } + + val configChangedHandler = { + logger.info("The config file has changed, waiting for Jibri to be idle before exiting") + jibriManager.executeWhenIdle { + logger.info("Jibri is idle and there are config file changes, exiting") + // Exit so we can be restarted and load the new config + cleanupAndExit(0) + } + } + val gracefulShutdownHandler = { + logger.info("Jibri has been told to graceful shutdown, waiting to be idle before exiting") + jibriManager.executeWhenIdle { + logger.info("Jibri is idle and has been told to gracefully shutdown, exiting") + // Exit with code 255 to indicate we do not want process restart + cleanupAndExit(255) + } + } + val shutdownHandler = { + logger.info("Jibri has been told to shutdown, stopping any active service") + jibriManager.stopService() + logger.info("Service stopped") + cleanupAndExit(255) + } + + logger.info("Using ${InternalHttpApi.host}:${InternalHttpApi.port} for internal HTTP API") + + with(InternalHttpApi(configChangedHandler, gracefulShutdownHandler, shutdownHandler)) { + embeddedServer(Jetty, host = InternalHttpApi.host, port = InternalHttpApi.port) { + internalApiModule() + }.start() + } + + val xmppEnvironments = configSupplier> { + "JibriConfig::xmppEnvironments" { + Config.legacyConfigSource.xmppEnvironments.takeIf { it?.isNotEmpty() == true } + ?: throw ConfigException.UnableToRetrieve.NotFound("Considering empty XMPP envs list as not found") + } + "jibri.api.xmpp.environments" + .from(Config.configSource) + .convertFrom> { envConfigs -> envConfigs.map { it.toXmppEnvironment() } } + }.get() + + // XmppApi + val xmppApi = XmppApi( + jibriManager = jibriManager, + xmppConfigs = xmppEnvironments, + jibriStatusManager = jibriStatusManager + ) + xmppApi.start() + + logger.info("Using ${HttpApi.host}:${HttpApi.port} for HTTP API") + + // HttpApi + with(HttpApi(jibriManager, jibriStatusManager, webhookClient)) { + embeddedServer(Jetty, host = HttpApi.host, port = HttpApi.port) { + apiModule() + } + }.start() +} + +class MainConfig { + companion object { + val jibriId: String by config { + "JibriConfig::jibriId" { Config.legacyConfigSource.jibriId!! } + "jibri.id".from(Config.configSource) + } + } +} + +private fun handleCommandLineArgs(args: Array) { + val argParser = ArgumentParsers.newFor("Jibri").build() + .defaultHelp(true) + .description("Start Jibri") + argParser.addArgument("-c", "--config") + .required(false) + .type(String::class.java) + .help("Path to the jibri config file") + argParser.addArgument("--internal-http-port") + .type(Int::class.java) + .help("Port to start the internal HTTP server on") + argParser.addArgument("--http-api-port") + .type(Int::class.java) + .help("Port to start the HTTP API server on") + + logger.info("Jibri run with args ${args.asList()}") + val ns = argParser.parseArgs(args) + val configFilePath = ns.getString("config") + val internalHttpPort: Int? = ns.getInt("internal_http_port") + val httpApiPort: Int? = ns.getInt("http_api_port") + + // Map the command line arguments into a ConfigSource + Config.commandLineArgs = MapConfigSource("command line args") { + internalHttpPort?.let { + put("internal_http_port", it) + } + httpApiPort?.let { + put("http_api_port", it) + } + } + + configFilePath?.let { + setupLegacyConfig(it) + } ?: logger.info("No legacy config file set") +} + +/** + * Parse the legacy config file and set it in [Config] as the legacy config source + */ +private fun setupLegacyConfig(configFilePath: String) { + logger.info("Checking legacy config file $configFilePath") + val jibriConfigFile = File(configFilePath) + if (!jibriConfigFile.exists()) { + logger.info("Legacy config file $configFilePath doesn't exist") + return + } + + val jibriConfig = loadConfigFromFile(jibriConfigFile) ?: run { + logger.info("Parsing legacy config file failed") + return + } + Config.legacyConfigSource = jibriConfig +} + +/** + * Wire the jitsi-metaconfig logger into ours + */ +private fun setupMetaconfigLogger() { + val configLogger = LoggerImpl("org.jitsi.jibri.config") + MetaconfigSettings.logger = object : MetaconfigLogger { + override fun debug(block: () -> String) { + configLogger.debug(block) + } + override fun error(block: () -> String) { + configLogger.error(block()) + } + override fun warn(block: () -> String) { + configLogger.warn(block) + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/api/http/HttpApi.kt b/src/main/kotlin/org/jitsi/jibri/api/http/HttpApi.kt new file mode 100644 index 0000000..f061acd --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/api/http/HttpApi.kt @@ -0,0 +1,264 @@ +/* + * 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.jibri.api.http + +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.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import io.ktor.server.routing.routing +import org.jitsi.jibri.FileRecordingRequestParams +import org.jitsi.jibri.JibriBusyException +import org.jitsi.jibri.JibriManager +import org.jitsi.jibri.RecordingSinkType +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.config.XmppCredentials +import org.jitsi.jibri.health.JibriHealth +import org.jitsi.jibri.metrics.JibriMetricsContainer +import org.jitsi.jibri.metrics.StatsConfig +import org.jitsi.jibri.selenium.CallParams +import org.jitsi.jibri.service.JibriServiceStatusHandler +import org.jitsi.jibri.service.ServiceParams +import org.jitsi.jibri.service.impl.SipGatewayServiceParams +import org.jitsi.jibri.service.impl.StreamingParams +import org.jitsi.jibri.sipgateway.SipClientParams +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.status.JibriFailure +import org.jitsi.jibri.status.JibriSessionStatus +import org.jitsi.jibri.status.JibriStatusManager +import org.jitsi.jibri.webhooks.v1.WebhookClient +import org.jitsi.metaconfig.config +import org.jitsi.utils.logging2.createLogger +import org.jitsi.xmpp.extensions.jibri.JibriIq + +// TODO: this needs to include usageTimeout +data class StartServiceParams( + val sessionId: String, + val callParams: CallParams, + /** + * XMPP login information to be used if [RecordingSinkType] is + * [RecordingSinkType.FILE] or [RecordingSinkType.STREAM] in order + * to make the recorder 'invisible' + */ + val callLoginParams: XmppCredentials? = null, + val sinkType: RecordingSinkType, + val youTubeStreamKey: String? = null, + /** + * Params to be used if [RecordingSinkType] is [RecordingSinkType.GATEWAY] + */ + val sipClientParams: SipClientParams? = null +) + +class HttpApi( + private val jibriManager: JibriManager, + private val jibriStatusManager: JibriStatusManager, + private val webhookClient: WebhookClient +) { + private val logger = createLogger() + + fun Application.apiModule() { + install(ContentNegotiation) { + jackson {} + } + + routing { + route("jibri/api/v1.0") { + /* + * Get the health of this Jibri in the format of a json-encoded + * [JibriHealth] object + */ + get("health") { + logger.debug { "Got health request" } + val health = JibriHealth(jibriStatusManager.overallStatus, jibriManager.currentEnvironmentContext) + logger.debug { "Returning health $health" } + call.respond(health) + } + + /* + * Start a new service using the given [StartServiceParams]. + * Returns a response with [Response.Status.OK] on success, [Response.Status.PRECONDITION_FAILED] + * if this Jibri is already busy or params were missing and [Response.Status.INTERNAL_SERVER_ERROR] on + * error + * NOTE: start service is largely async, so a return of [Response.Status.OK] here just means Jibri + * was able to *try* to start the request. We don't have a way to get ongoing updates about services + * via the HTTP API at this point. + */ + post("startService") { + try { + val startServiceParams = call.receive() + logger.debug { "Got a start service request with params $startServiceParams" } + + val serviceStatusHandler = createServiceStatusHandler(startServiceParams, webhookClient) + handleStartService(startServiceParams, serviceStatusHandler) + call.respond(HttpStatusCode.OK) + } catch (e: JibriBusyException) { + call.respond(HttpStatusCode.PreconditionFailed, "Jibri is currently busy") + } catch (e: IllegalStateException) { + call.respond(HttpStatusCode.PreconditionFailed, e.message ?: "") + } catch (t: Throwable) { + logger.error("Error starting service $t", t) + call.respond(HttpStatusCode.InternalServerError) + } + } + + /* + * [stopService] will stop the current service immediately + */ + post("stopService") { + logger.debug { "Got stop service request" } + jibriManager.stopService() + call.respond(HttpStatusCode.OK) + } + } + if (StatsConfig.enablePrometheus) { + logger.info("Enabling prometheus interface at :$port/metrics") + get("/metrics") { + val accepts = + parseHeaderValue(call.request.headers["Accept"]).sortedByDescending { it.quality } + .map { it.value } + val (metrics, contentType) = JibriMetricsContainer.getMetrics(accepts) + call.respondText(metrics, contentType = ContentType.parse(contentType)) + } + } + } + } + + private fun createServiceStatusHandler( + serviceParams: StartServiceParams, + webhookClient: WebhookClient + ): JibriServiceStatusHandler = { serviceState -> + when (serviceState) { + is ComponentState.Error -> { + val failure = JibriFailure( + JibriIq.FailureReason.ERROR, + serviceState.error + ) + val componentSessionStatus = JibriSessionStatus( + serviceParams.sessionId, + JibriIq.Status.OFF, + serviceParams.sipClientParams?.sipAddress, + failure, + serviceState.error.shouldRetry() + ) + logger.info( + "Current service had an error ${serviceState.error}, " + + "sending status error $componentSessionStatus" + ) + webhookClient.updateSessionStatus(componentSessionStatus) + } + + is ComponentState.Finished -> { + val componentSessionStatus = JibriSessionStatus( + serviceParams.sessionId, + JibriIq.Status.OFF, + serviceParams.sipClientParams?.sipAddress + ) + logger.info("Current service finished, sending status off $componentSessionStatus") + webhookClient.updateSessionStatus(componentSessionStatus) + } + + is ComponentState.Running -> { + val componentSessionStatus = JibriSessionStatus( + serviceParams.sessionId, + JibriIq.Status.ON, + serviceParams.sipClientParams?.sipAddress + ) + logger.info("Current service started up successfully, sending status on $componentSessionStatus") + webhookClient.updateSessionStatus(componentSessionStatus) + } + + else -> { + logger.info("Webhook client ignoring service state update: $serviceState") + } + } + } + + private fun handleStartService(startServiceParams: StartServiceParams, statusHandler: JibriServiceStatusHandler) { + when (startServiceParams.sinkType) { + RecordingSinkType.FILE -> { + // If it's a file recording, it must have the callLoginParams set + val callLoginParams = startServiceParams.callLoginParams + ?: throw IllegalStateException("Call login params missing") + jibriManager.startFileRecording( + ServiceParams(usageTimeoutMinutes = 0), + FileRecordingRequestParams( + startServiceParams.callParams, + startServiceParams.sessionId, + callLoginParams + ), + environmentContext = null, + statusHandler + ) + } + + RecordingSinkType.STREAM -> { + val youTubeStreamKey = startServiceParams.youTubeStreamKey + ?: throw IllegalStateException("Stream key missing") + // If it's a stream, it must have the callLoginParams set + val callLoginParams = startServiceParams.callLoginParams + ?: throw IllegalStateException("Call login params missing") + jibriManager.startStreaming( + ServiceParams(usageTimeoutMinutes = 0), + StreamingParams( + startServiceParams.callParams, + startServiceParams.sessionId, + callLoginParams, + youTubeStreamKey + ), + environmentContext = null, + statusHandler + ) + } + + RecordingSinkType.GATEWAY -> { + // If it's a sip gateway, it must have sipClientParams set + val sipClientParams = startServiceParams.sipClientParams + ?: throw IllegalStateException("SIP client params missing") + jibriManager.startSipGateway( + ServiceParams(usageTimeoutMinutes = 0), + // TODO: add session ID + SipGatewayServiceParams( + startServiceParams.callParams, + startServiceParams.callLoginParams, + sipClientParams + ), + environmentContext = null, + statusHandler + ) + } + } + } + + companion object { + val port: Int by config { + "http_api_port".from(Config.commandLineArgs).softDeprecated("use jibri.api.http.external-api-port") + "jibri.api.http.external-api-port".from(Config.configSource) + } + val host: String by config { + "jibri.api.http.external-api-host".from(Config.configSource) + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/api/http/internal/InternalHttpApi.kt b/src/main/kotlin/org/jitsi/jibri/api/http/internal/InternalHttpApi.kt new file mode 100644 index 0000000..149451c --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/api/http/internal/InternalHttpApi.kt @@ -0,0 +1,106 @@ +/* + * 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.jibri.api.http.internal + +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.jackson.jackson +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.response.respond +import io.ktor.server.routing.RoutingContext +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import io.ktor.server.routing.routing +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.jitsi.jibri.config.Config +import org.jitsi.metaconfig.config +import org.jitsi.utils.logging2.createLogger + +class InternalHttpApi( + private val configChangedHandler: () -> Unit, + private val gracefulShutdownHandler: () -> Unit, + private val shutdownHandler: () -> Unit +) { + private val logger = createLogger() + + fun Application.internalApiModule() { + install(ContentNegotiation) { + jackson {} + } + + routing { + route("/jibri/api/internal/v1.0") { + /* + * Signal this Jibri to shutdown gracefully, meaning shut down when + * it is idle (i.e. finish any currently running service). Returns a 200 + * and schedules a shutdown for when it becomes idle. + */ + post("gracefulShutdown") { + logger.info("Jibri gracefully shutting down") + respondOkAndRun(gracefulShutdownHandler) + } + /* + * Signal this Jibri to reload its config file at the soonest opportunity + * (when it does not have a currently running service). Returns a 200. + */ + post("notifyConfigChanged") { + logger.info("Config file changed") + respondOkAndRun(configChangedHandler) + } + /* + * Signal this Jibri to (cleanly) stop any services that are + * running and shutdown. Returns a 200. + */ + post("shutdown") { + logger.info("Jibri is forcefully shutting down") + respondOkAndRun(shutdownHandler) + } + } + } + } + companion object { + val port: Int by config { + "internal_http_port" + .from(Config.commandLineArgs).softDeprecated("use jibri.api.http.internal-api-port") + "jibri.api.http.internal-api-port".from(Config.configSource) + } + val host: String by config { + "jibri.api.http.internal-api-host".from(Config.configSource) + } + } +} + +/** + * Responds with [HttpStatusCode.OK] and then runs the given block + */ +private suspend fun RoutingContext.respondOkAndRun(block: () -> Unit) { + val latch = CompletableDeferred() + coroutineScope { + launch { + latch.join() + block() + } + try { + call.respond(HttpStatusCode.OK) + } finally { + latch.cancel() + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/api/xmpp/JibriIqHelper.kt b/src/main/kotlin/org/jitsi/jibri/api/xmpp/JibriIqHelper.kt new file mode 100644 index 0000000..869605e --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/api/xmpp/JibriIqHelper.kt @@ -0,0 +1,71 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.api.xmpp + +import org.jitsi.xmpp.extensions.jibri.JibriIq +import org.jivesoftware.smack.packet.IQ +import org.jxmpp.jid.Jid + +// TODO: this functionality should be added to JibriIq + +/** + * Create a result iq from the given [JibriIq] + */ +class JibriIqHelper { + companion object { + fun create(from: Jid, type: IQ.Type = IQ.Type.set, status: JibriIq.Status = JibriIq.Status.UNDEFINED): JibriIq { + val jibriIq = JibriIq() + jibriIq.to = from + jibriIq.type = type + jibriIq.status = status + + return jibriIq + } + } +} + +/** + * Return a result IQ for this [JibriIq], setting a few fields and then + * applying [block] + */ +fun JibriIq.createResult(block: JibriIq.() -> Unit): JibriIq = JibriIq().apply { + type = IQ.Type.result + to = this@createResult.from + from = this@createResult.to + stanzaId = this@createResult.stanzaId + sipAddress = this@createResult.sipAddress + block() +} + +enum class JibriMode(val mode: String) { + FILE(JibriIq.RecordingMode.FILE.toString()), + STREAM(JibriIq.RecordingMode.STREAM.toString()), + SIPGW("sipgw"), + UNDEFINED(JibriIq.RecordingMode.UNDEFINED.toString()) +} + +fun JibriIq.mode(): JibriMode { + if (!sipAddress.isNullOrBlank()) { + return JibriMode.SIPGW + } + return when (recordingMode) { + JibriIq.RecordingMode.FILE -> JibriMode.FILE + JibriIq.RecordingMode.STREAM -> JibriMode.STREAM + else -> JibriMode.UNDEFINED + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/api/xmpp/JibriStatusExts.kt b/src/main/kotlin/org/jitsi/jibri/api/xmpp/JibriStatusExts.kt new file mode 100644 index 0000000..f120f46 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/api/xmpp/JibriStatusExts.kt @@ -0,0 +1,71 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.api.xmpp + +import org.jitsi.jibri.status.ComponentBusyStatus +import org.jitsi.jibri.status.ComponentHealthStatus +import org.jitsi.jibri.status.JibriStatus +import org.jitsi.xmpp.extensions.health.HealthStatusPacketExt +import org.jitsi.xmpp.extensions.jibri.JibriBusyStatusPacketExt +import org.jitsi.xmpp.extensions.jibri.JibriStatusPacketExt +import java.lang.RuntimeException + +/** + * Translate the Jibri busy status enum to the jitsi-protocol-jabber version + */ +private fun ComponentBusyStatus.toBusyStatusExt(): JibriBusyStatusPacketExt.BusyStatus = when (this) { + ComponentBusyStatus.BUSY -> JibriBusyStatusPacketExt.BusyStatus.BUSY + ComponentBusyStatus.IDLE -> JibriBusyStatusPacketExt.BusyStatus.IDLE + ComponentBusyStatus.EXPIRED -> throw RuntimeException("'EXPIRED' not supported in JibriBusyStatusPacketExt") +} + +/** + * Translate the Jibri health status enum to the jitsi-protocol-jabber version + */ +private fun ComponentHealthStatus.toHealthStatusExt(): HealthStatusPacketExt.Health = when (this) { + ComponentHealthStatus.HEALTHY -> HealthStatusPacketExt.Health.HEALTHY + ComponentHealthStatus.UNHEALTHY -> HealthStatusPacketExt.Health.UNHEALTHY +} + +/** + * Convert a [JibriStatus] to [JibriStatusPacketExt] + * [shouldBeSentToMuc] should be called before calling this function to ensure the + * status can be translated. + */ +fun JibriStatus.toJibriStatusExt(): JibriStatusPacketExt { + val jibriStatusExt = JibriStatusPacketExt() + + val jibriBusyStatusExt = JibriBusyStatusPacketExt() + jibriBusyStatusExt.status = this.busyStatus.toBusyStatusExt() + jibriStatusExt.busyStatus = jibriBusyStatusExt + + val jibriHealthStatusExt = HealthStatusPacketExt() + jibriHealthStatusExt.status = this.health.healthStatus.toHealthStatusExt() + jibriStatusExt.healthStatus = jibriHealthStatusExt + + return jibriStatusExt +} + +/** + * 'Expired' is not a state we reflect in the control MUC (and isn't + * defined by [JibriStatusPacketExt]), it's used only for the + * internal health status so for now we don't see it to the MUC. + */ +fun JibriStatus.shouldBeSentToMuc(): Boolean = when (this.busyStatus) { + ComponentBusyStatus.EXPIRED -> false + else -> true +} diff --git a/src/main/kotlin/org/jitsi/jibri/api/xmpp/XmppApi.kt b/src/main/kotlin/org/jitsi/jibri/api/xmpp/XmppApi.kt new file mode 100644 index 0000000..c80fc98 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/api/xmpp/XmppApi.kt @@ -0,0 +1,448 @@ +/* + * 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.jibri.api.xmpp + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.jitsi.jibri.FileRecordingRequestParams +import org.jitsi.jibri.JibriBusyException +import org.jitsi.jibri.JibriManager +import org.jitsi.jibri.config.XmppEnvironmentConfig +import org.jitsi.jibri.health.EnvironmentContext +import org.jitsi.jibri.selenium.CallParams +import org.jitsi.jibri.service.AppData +import org.jitsi.jibri.service.JibriServiceStatusHandler +import org.jitsi.jibri.service.ServiceParams +import org.jitsi.jibri.service.impl.SipGatewayServiceParams +import org.jitsi.jibri.service.impl.StreamingParams +import org.jitsi.jibri.service.impl.YOUTUBE_URL +import org.jitsi.jibri.sipgateway.SipClientParams +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.status.JibriStatus +import org.jitsi.jibri.status.JibriStatusManager +import org.jitsi.jibri.util.getCallUrlInfoFromJid +import org.jitsi.utils.logging2.createLogger +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.mucclient.ConnectionStateListener +import org.jitsi.xmpp.mucclient.IQListener +import org.jitsi.xmpp.mucclient.MucClient +import org.jitsi.xmpp.mucclient.MucClientConfiguration +import org.jitsi.xmpp.mucclient.MucClientManager +import org.jivesoftware.smack.ConnectionConfiguration +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.StanzaError +import org.jivesoftware.smack.provider.ProviderManager +import org.jivesoftware.smackx.ping.PingManager +import org.jxmpp.jid.impl.JidCreate + +private class UnsupportedIqMode(val iqMode: String) : Exception() + +/** + * [XmppApi] connects to XMPP MUCs according to the given [XmppEnvironmentConfig]s (which are + * parsed from config.json) and listens for IQ messages which contain Jibri commands, which it relays + * to the given [JibriManager]. The IQ messages are instances of [JibriIq] and allow the + * starting and stopping of the services Jibri provides. + * [XmppApi] subscribes to [JibriStatusManager] status updates and translates those into + * XMPP presence (defined by [JibriStatusPacketExt]) updates to advertise the status of this Jibri. + * XMPP presence (defined by [JibriStatusPacketExt]) updates to advertise the status of this Jibri. + * [XmppApi] takes care of translating the XMPP commands into the appropriate + * [JibriManager] API calls and translates the results into XMPP IQ responses. + */ +class XmppApi( + private val jibriManager: JibriManager, + private val xmppConfigs: List, + private val jibriStatusManager: JibriStatusManager, +) : IQListener { + private val logger = createLogger() + + private val connectionStateListener = object : ConnectionStateListener { + override fun connected(mucClient: MucClient) { + jibriManager.jibriMetrics.xmppConnected(mucClient.tags()) + } + override fun reconnecting(mucClient: MucClient) { + jibriManager.jibriMetrics.xmppReconnecting(mucClient.tags()) + } + override fun reconnectionFailed(mucClient: MucClient) { + jibriManager.jibriMetrics.xmppReconnectionFailed(mucClient.tags()) + } + override fun pingFailed(mucClient: MucClient) { + jibriManager.jibriMetrics.xmppPingFailed(mucClient.tags()) + } + + /** + * If XMPP disconnects we lost our communication channel with jicofo. Jicofo currently doesn't attempt to + * communicate later, and starts a new session (with a different jibri) immediately. Make sure in this case the + * recording is stopped. + */ + override fun closed(mucClient: MucClient) { + jibriManager.jibriMetrics.xmppClosed(mucClient.tags()) + maybeStop(mucClient) + } + + /** + * If XMPP disconnects we lost our communication channel with jicofo. Jicofo currently doesn't attempt to + * communicate later, and starts a new session (with a different jibri) immediately. Make sure in this case the + * recording is stopped. + */ + override fun closedOnError(mucClient: MucClient) { + jibriManager.jibriMetrics.xmppClosedOnError(mucClient.tags()) + maybeStop(mucClient) + } + + private fun maybeStop(mucClient: MucClient) { + val xmppEnvironment = getXmppEnvironment(mucClient) ?: return + val environmentContext = createEnvironmentContext(xmppEnvironment, mucClient) + if (jibriManager.currentEnvironmentContext == environmentContext) { + logger.warn("XMPP disconnected, stopping.") + jibriManager.jibriMetrics.stoppedOnXmppClosed(mucClient.tags()) + jibriManager.stopService() + } + } + + /** Create statsd tags for a [MucClient]. */ + private fun MucClient.tags() = "xmpp_server_host:$id" + } + private lateinit var mucClientManager: MucClientManager + + /** + * Start up the XMPP API by connecting and logging in to all the configured XMPP environments. For each XMPP + * connection, we'll listen for incoming [JibriIq] messages and handle them appropriately. Join the MUC on + * each connection and send an initial [JibriStatusPacketExt] presence. + */ + fun start(mucManager: MucClientManager = MucClientManager()) { + this.mucClientManager = mucManager + + PingManager.setDefaultPingInterval(30) + JibriStatusPacketExt.registerExtensionProvider() + ProviderManager.addIQProvider( + JibriIq.ELEMENT, + JibriIq.NAMESPACE, + JibriIqProvider() + ) + updatePresence(jibriStatusManager.overallStatus) + jibriStatusManager.addStatusHandler(::updatePresence) + + mucClientManager.registerIQ(JibriIq()) + mucClientManager.setIQListener(this) + mucClientManager.addConnectionStateListener(connectionStateListener) + + // Join all the MUCs we've been told to + for (config in xmppConfigs) { + for (host in config.xmppServerHosts) { + logger.info("Connecting to xmpp environment '${config.name}' on $host") + val hostDetails: List = host.split(":") + + // We need to use the host as the ID because we'll only get one MUC client per 'ID' and + // we may have multiple hosts for the same environment + val clientConfig = MucClientConfiguration(host).apply { + hostname = hostDetails[0] + domain = config.controlLogin.domain + username = config.controlLogin.username + password = config.controlLogin.password + + if (hostDetails.size > 1) { + port = hostDetails[1] + } else { + config.controlLogin.port?.let { port = it.toString() } + } + + if (config.trustAllXmppCerts) { + logger.info( + "The trustAllXmppCerts config is enabled for this domain, " + + "all XMPP server provided certificates will be accepted" + ) + disableCertificateVerification = config.trustAllXmppCerts + } + + if (config.securityMode == ConnectionConfiguration.SecurityMode.disabled) { + logger.info( + "XMPP security is disabled for this domain, no TLS will be used." + ) + } + securityMode = config.securityMode + + val recordingMucJid = + JidCreate.bareFrom("${config.controlMuc.roomName}@${config.controlMuc.domain}").toString() + val sipMucJid: String? = config.sipControlMuc?.let { + JidCreate.entityBareFrom( + "${config.sipControlMuc.roomName}@${config.sipControlMuc.domain}" + ).toString() + } + mucJids = listOfNotNull(recordingMucJid, sipMucJid) + mucNickname = config.controlMuc.nickname + } + + mucClientManager.addMucClient(clientConfig) + } + } + } + + /** + * Function to update outgoing [presence] stanza with jibri status. + */ + private fun updatePresence(status: JibriStatus) { + if (status.shouldBeSentToMuc()) { + logger.info("Jibri reports its status is now $status, publishing presence to connections") + mucClientManager.setPresenceExtension(status.toJibriStatusExt()) + } else { + logger.info("Not forwarding status $status to the MUC") + } + } + + /** + * Handles the JibriIQ. + * + * @param iq the IQ to be handled. + * @param mucClient the [MucClient] from which the IQ comes. + * @return the IQ to be sent as a response or `null`. + */ + override fun handleIq(iq: IQ, mucClient: MucClient): IQ = if (iq is JibriIq) { + handleJibriIq(iq, mucClient) + } else { + IQ.createErrorResponse(iq, StanzaError.getBuilder().setCondition(StanzaError.Condition.bad_request).build()) + } + + /** + * Helper function to handle a [JibriIq] message with the context of the [XmppEnvironmentConfig] and [MucClient] + * that this [JibriIq] was received on. + */ + private fun handleJibriIq(jibriIq: JibriIq, mucClient: MucClient): IQ { + logger.info( + "Received JibriIq action=${jibriIq.action} mode=${jibriIq.recordingMode} " + + "room=${jibriIq.room} session=${jibriIq.sessionId} from environment $mucClient" + ) + val xmppEnvironment = getXmppEnvironment(mucClient) + ?: return IQ.createErrorResponse( + jibriIq, + StanzaError.getBuilder().setCondition(StanzaError.Condition.bad_request).build() + ) + return when (jibriIq.action) { + JibriIq.Action.START -> handleStartJibriIq(jibriIq, xmppEnvironment, mucClient) + + JibriIq.Action.STOP -> handleStopJibriIq(jibriIq) + + else -> IQ.createErrorResponse( + jibriIq, + StanzaError.getBuilder().setCondition(StanzaError.Condition.bad_request).build() + ) + } + } + + private fun getXmppEnvironment(mucClient: MucClient) = xmppConfigs.find { + it.xmppServerHosts.contains(mucClient.id) + } + + /** + * Handle a start [JibriIq] message. We'll respond immediately with a [JibriIq.Status.PENDING] IQ response and + * send a new IQ with the subsequent stats after starting the service: + * [JibriIq.Status.OFF] if there was an error starting the service (or an error while the service was running). + * In this case, a [JibriIq.FailureReason] will be set as well. + * [JibriIq.Status.ON] if the service started successfully + */ + private fun handleStartJibriIq( + startJibriIq: JibriIq, + xmppEnvironment: XmppEnvironmentConfig, + mucClient: MucClient + ): IQ { + logger.info("Received start request, starting service") + // If there is an issue with the service while it's running, we need to send an IQ + // to notify the caller who invoked the service of its status, so we'll listen + // for the service's status while it's running and this method will be invoked + // if it changes + val serviceStatusHandler = createServiceStatusHandler(startJibriIq, mucClient) + return try { + handleStartService( + startJibriIq, + xmppEnvironment, + createEnvironmentContext(xmppEnvironment, mucClient), + serviceStatusHandler + ) + logger.info("Sending 'pending' response to start IQ") + startJibriIq.createResult { + status = JibriIq.Status.PENDING + } + } catch (busy: JibriBusyException) { + logger.error("Jibri is currently busy, cannot service this request") + startJibriIq.createResult { + status = JibriIq.Status.OFF + failureReason = JibriIq.FailureReason.BUSY + shouldRetry = true + } + } catch (iq: UnsupportedIqMode) { + logger.error("Unsupported IQ mode: ${iq.iqMode}") + startJibriIq.createResult { + status = JibriIq.Status.OFF + failureReason = JibriIq.FailureReason.ERROR + shouldRetry = false + } + } catch (t: Throwable) { + logger.error("Error starting Jibri service ", t) + startJibriIq.createResult { + status = JibriIq.Status.OFF + failureReason = JibriIq.FailureReason.ERROR + shouldRetry = true + } + } + } + + private fun createServiceStatusHandler(request: JibriIq, mucClient: MucClient): JibriServiceStatusHandler = + { serviceState -> + when (serviceState) { + is ComponentState.Error -> { + with(JibriIqHelper.create(request.from, status = JibriIq.Status.OFF)) { + failureReason = JibriIq.FailureReason.ERROR + sipAddress = request.sipAddress + shouldRetry = serviceState.error.shouldRetry() + logger.info( + "Current service had an error ${serviceState.error}, " + + "sending error iq status=$status failureReason=$failureReason shouldRetry=$shouldRetry" + ) + mucClient.sendStanza(this) + } + } + + is ComponentState.Finished -> { + with(JibriIqHelper.create(request.from, status = JibriIq.Status.OFF)) { + sipAddress = request.sipAddress + logger.info("Current service finished, sending off iq status=$status") + mucClient.sendStanza(this) + } + } + + is ComponentState.Running -> { + with(JibriIqHelper.create(request.from, status = JibriIq.Status.ON)) { + sipAddress = request.sipAddress + logger.info("Current service started up successfully, sending on iq status=$status") + mucClient.sendStanza(this) + } + } + + else -> { + logger.info("XmppAPI ignoring service state update: $serviceState") + } + } + } + + /** + * Handle a stop [JibriIq] message to stop the currently running service (if there is one). Send a [JibriIq] + * response with [JibriIq.Status.OFF]. + */ + private fun handleStopJibriIq(stopJibriIq: JibriIq): IQ { + jibriManager.stopService() + // By this point the service has been fully stopped + return stopJibriIq.createResult { + status = JibriIq.Status.OFF + } + } + + /** + * Helper function to actually start the service. We need to parse the fields in the [JibriIq] message + * to determine which [JibriManager] service API to call, as well as convert the types into what [JibriManager] + * expects + */ + private fun handleStartService( + startIq: JibriIq, + xmppEnvironment: XmppEnvironmentConfig, + environmentContext: EnvironmentContext, + serviceStatusHandler: JibriServiceStatusHandler + ) { + val callUrlInfo = getCallUrlInfoFromJid( + startIq.room, + xmppEnvironment.stripFromRoomDomain, + xmppEnvironment.xmppDomain, + xmppEnvironment.baseUrl + ) + val appData = startIq.appData?.let { + jacksonObjectMapper().readValue(startIq.appData) + } + val serviceParams = ServiceParams(xmppEnvironment.usageTimeoutMins, appData) + val extraUrlParams = buildList { + startIq.rtcStatsEnabled?.let { add("config.analytics.rtcstatsEnabled=$it") } + } + val callParams = CallParams(callUrlInfo, extraUrlParams = extraUrlParams) + logger.info("Parsed call url info: $callUrlInfo") + + when (startIq.mode()) { + JibriMode.FILE -> { + jibriManager.startFileRecording( + serviceParams, + FileRecordingRequestParams(callParams, startIq.sessionId, xmppEnvironment.callLogin), + environmentContext, + serviceStatusHandler + ) + } + + JibriMode.STREAM -> { + val rtmpUrl = if (startIq.streamId.isRtmpUrl()) { + startIq.streamId + } else { + "$YOUTUBE_URL/${startIq.streamId}" + } + val viewingUrl = if (startIq.youtubeBroadcastId != null) { + if (startIq.youtubeBroadcastId.isViewingUrl()) { + startIq.youtubeBroadcastId + } else { + "http://youtu.be/${startIq.youtubeBroadcastId}" + } + } else { + null + } + logger.info("Using RTMP URL $rtmpUrl and viewing URL $viewingUrl") + jibriManager.startStreaming( + serviceParams, + StreamingParams( + callParams, + startIq.sessionId, + xmppEnvironment.callLogin, + rtmpUrl = rtmpUrl, + viewingUrl = viewingUrl + ), + environmentContext, + serviceStatusHandler + ) + } + + JibriMode.SIPGW -> { + jibriManager.startSipGateway( + serviceParams, + SipGatewayServiceParams( + callParams, + xmppEnvironment.callLogin, + SipClientParams(startIq.sipAddress, startIq.displayName) + ), + environmentContext, + serviceStatusHandler + ) + } + + else -> { + throw UnsupportedIqMode(startIq.mode().toString()) + } + } + } +} + +private fun String.isRtmpUrl(): Boolean = + startsWith("rtmp://", ignoreCase = true) || startsWith("rtmps://", ignoreCase = true) +private fun String.isViewingUrl(): Boolean = + startsWith("http://", ignoreCase = true) || startsWith("https://", ignoreCase = true) + +private fun createEnvironmentContext(xmppEnvironment: XmppEnvironmentConfig, mucClient: MucClient) = + EnvironmentContext("${xmppEnvironment.name}-${mucClient.id}") diff --git a/src/main/kotlin/org/jitsi/jibri/capture/Capturer.kt b/src/main/kotlin/org/jitsi/jibri/capture/Capturer.kt new file mode 100644 index 0000000..1d00ad5 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/capture/Capturer.kt @@ -0,0 +1,38 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.capture + +import org.jitsi.jibri.sink.Sink + +class UnsupportedOsException(override var message: String = "Jibri does not support this OS") : Exception() +class UnsupportedSinkTypeException(sink: Sink) : Exception("Unsupported sink type: ${sink::class.simpleName}") + +/** + * [Capturer] represents a process which will capture media. + */ +interface Capturer { + /** + * Start the capturer with the given [Sink]. + */ + fun start(sink: Sink) + + /** + * Stop the capturer + */ + fun stop() +} diff --git a/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/Errors.kt b/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/Errors.kt new file mode 100644 index 0000000..ac717db --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/Errors.kt @@ -0,0 +1,29 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jibri.capture.ffmpeg + +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.status.ErrorScope + +open class FfmpegError(scope: ErrorScope, detail: String) : JibriError(scope, detail) +object FfmpegFailedToStart : FfmpegError(ErrorScope.SYSTEM, "Ffmpeg failed to start") +class FfmpegUnexpectedSignal(outputLine: String) : FfmpegError(ErrorScope.SESSION, outputLine) +class BadRtmpUrl(outputLine: String) : FfmpegError(ErrorScope.SESSION, outputLine) { + override fun shouldRetry(): Boolean = false +} +class BrokenPipe(outputLine: String) : FfmpegError(ErrorScope.SESSION, outputLine) +class QuitUnexpectedly(outputLine: String) : FfmpegError(ErrorScope.SESSION, outputLine) diff --git a/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegCapturer.kt b/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegCapturer.kt new file mode 100644 index 0000000..b8c3f3a --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegCapturer.kt @@ -0,0 +1,141 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.capture.ffmpeg + +import org.jitsi.jibri.capture.Capturer +import org.jitsi.jibri.capture.UnsupportedOsException +import org.jitsi.jibri.capture.UnsupportedSinkTypeException +import org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.sink.Sink +import org.jitsi.jibri.sink.impl.FileSink +import org.jitsi.jibri.sink.impl.StreamSink +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.JibriSubprocess +import org.jitsi.jibri.util.OsDetector +import org.jitsi.jibri.util.OsType +import org.jitsi.jibri.util.ProcessExited +import org.jitsi.jibri.util.ProcessFailedToStart +import org.jitsi.jibri.util.ProcessRunning +import org.jitsi.jibri.util.ProcessState +import org.jitsi.jibri.util.StatusPublisher +import org.jitsi.jibri.util.getLoggerWithHandler +import org.jitsi.metaconfig.config +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger + +/** + * [FfmpegCapturer] is responsible for launching ffmpeg, capturing from the + * configured audio and video devices, and writing to the given [Sink] + */ +class FfmpegCapturer( + parentLogger: Logger, + osDetector: OsDetector = OsDetector(), + ffmpeg: JibriSubprocess? = null +) : Capturer, StatusPublisher() { + private val logger = createChildLogger(parentLogger) + private val ffmpeg = ffmpeg ?: JibriSubprocess(logger, "ffmpeg", ffmpegOutputLogger) + private val getCommand: (Sink) -> List + private val ffmpegStatusStateMachine = FfmpegStatusStateMachine() + + companion object { + const val COMPONENT_ID = "Ffmpeg Capturer" + private val ffmpegOutputLogger = getLoggerWithHandler("ffmpeg", FfmpegFileHandler()) + + val commandLinuxRecording: List by config { + "jibri.ffmpeg.command-linux-recording".from(Config.configSource) + } + val commandLinuxStreaming: List by config { + "jibri.ffmpeg.command-linux-streaming".from(Config.configSource) + } + val commandMacRecording: List by config { + "jibri.ffmpeg.command-mac-recording".from(Config.configSource) + } + val commandMacStreaming: List by config { + "jibri.ffmpeg.command-mac-streaming".from(Config.configSource) + } + } + + init { + val osType = osDetector.getOsType() + logger.debug { "Detected OS: $osType" } + getCommand = when (osType) { + OsType.MAC -> { sink: Sink -> + when (sink) { + is StreamSink -> commandMacStreaming + is FileSink -> commandMacRecording + else -> throw UnsupportedSinkTypeException(sink) + } + listOf(sink.path) + } + + OsType.LINUX -> { sink: Sink -> + when (sink) { + is StreamSink -> commandLinuxStreaming + is FileSink -> commandLinuxRecording + else -> throw UnsupportedSinkTypeException(sink) + } + listOf(sink.path) + } + + else -> throw UnsupportedOsException() + } + + this.ffmpeg.addStatusHandler(this::onFfmpegProcessUpdate) + ffmpegStatusStateMachine.onStateTransition(this::onFfmpegStateMachineStateChange) + } + + /** + * Start the capturer and write to the given [Sink]. + */ + override fun start(sink: Sink) { + val command = getCommand(sink) + ffmpeg.launch(command) + } + + /** + * Handle a [ProcessState] update from ffmpeg by parsing it into an [FfmpegEvent] and passing it to the state + * machine + */ + private fun onFfmpegProcessUpdate(ffmpegState: ProcessState) { + // We handle the case where it failed to start separately, since there is no output + when (ffmpegState.runningState) { + is ProcessFailedToStart -> { + ffmpegStatusStateMachine.transition( + FfmpegEvent.ErrorLine(FfmpegFailedToStart) + ) + } + + else -> { + if (ffmpegState.runningState is ProcessExited) { + logger.info("Ffmpeg quit abruptly. Last output line: ${ffmpegState.mostRecentOutput}") + } + val status = OutputParser.parse(ffmpegState.mostRecentOutput) + ffmpegStatusStateMachine.transition(status.toFfmpegEvent(ffmpegState.runningState is ProcessRunning)) + } + } + } + + private fun onFfmpegStateMachineStateChange(oldState: ComponentState, newState: ComponentState) { + logger.info("Ffmpeg capturer transitioning from state $oldState to $newState") + publishStatus(newState) + } + + /** + * Stops the capturer + */ + override fun stop() = ffmpeg.stop() +} diff --git a/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegStatusStateMachine.kt b/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegStatusStateMachine.kt new file mode 100644 index 0000000..49d66f5 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegStatusStateMachine.kt @@ -0,0 +1,138 @@ +/* + * 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.jibri.capture.ffmpeg + +import com.tinder.StateMachine +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.NotifyingStateMachine + +sealed class FfmpegEvent(val outputLine: String) { + class EncodingLine(outputLine: String) : FfmpegEvent(outputLine) + class ErrorLine(val error: JibriError) : FfmpegEvent(error.detail) + class FinishLine(outputLine: String) : FfmpegEvent(outputLine) + class OtherLine(outputLine: String) : FfmpegEvent(outputLine) + + /** + * Used any time Ffmpeg has exited, regardless of what [outputLine] contains. However, + * [error] will be set if [outputLine] contains an error, so that we may describe the + * scope of that error. + */ + class FfmpegExited(outputLine: String, val error: JibriError? = null) : FfmpegEvent(outputLine) +} + +/** + * To properly translate an [FfmpegOutputStatus] to an [FfmpegEvent], we need to take into + * account what the status of the parsed line indicates, but also the current running state + * of ffmpeg itself: if ffmpeg crashes, then its last output line may be "normal", but we need + * to react to the fact that ffmpeg is no longer running. + */ +fun FfmpegOutputStatus.toFfmpegEvent(ffmpegStillRunning: Boolean): FfmpegEvent = when (lineType) { + OutputLineClassification.ENCODING -> { + if (ffmpegStillRunning) { + FfmpegEvent.EncodingLine(detail) + } else { + FfmpegEvent.FfmpegExited(detail, QuitUnexpectedly(detail)) + } + } + + OutputLineClassification.UNKNOWN -> { + if (ffmpegStillRunning) { + FfmpegEvent.OtherLine(detail) + } else { + FfmpegEvent.FfmpegExited(detail, QuitUnexpectedly(detail)) + } + } + + OutputLineClassification.FINISHED -> FfmpegEvent.FinishLine(detail) + + OutputLineClassification.ERROR -> { + this as FfmpegErrorStatus + FfmpegEvent.ErrorLine(this.error) + } +} + +sealed class SideEffect + +class FfmpegStatusStateMachine : NotifyingStateMachine() { + private val stateMachine = StateMachine.create { + initialState(ComponentState.StartingUp) + + state { + on { + transitionTo(ComponentState.Running) + } + on { + transitionTo(ComponentState.Error(it.error)) + } + on { + transitionTo(ComponentState.Finished) + } + on { + dontTransition() + } + on { + it.error?.let { + transitionTo(ComponentState.Error(it)) + } ?: transitionTo(ComponentState.Finished) + } + } + + state { + on { + dontTransition() + } + on { + transitionTo(ComponentState.Error(it.error)) + } + on { + transitionTo(ComponentState.Finished) + } + on { + dontTransition() + } + on { + it.error?.let { + transitionTo(ComponentState.Error(it)) + } ?: transitionTo(ComponentState.Finished) + } + } + + state { + on(any()) { + dontTransition() + } + } + + state { + on(any()) { + dontTransition() + } + } + + onTransition { + val validTransition = it as? StateMachine.Transition.Valid ?: run { + throw Exception("Invalid state transition: $it") + } + if (validTransition.fromState::class != validTransition.toState::class) { + notify(validTransition.fromState, validTransition.toState) + } + } + } + + fun transition(event: FfmpegEvent): StateMachine.Transition<*, *, *> = stateMachine.transition(event) +} diff --git a/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/OutputParser.kt b/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/OutputParser.kt new file mode 100644 index 0000000..78cf0d6 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/OutputParser.kt @@ -0,0 +1,126 @@ +/* + * 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.jibri.capture.ffmpeg + +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.util.ONE_OR_MORE_DIGITS +import org.jitsi.jibri.util.ONE_OR_MORE_NON_SPACES +import org.jitsi.jibri.util.ZERO_OR_MORE_SPACES +import java.util.regex.Pattern + +enum class OutputLineClassification { + UNKNOWN, + ENCODING, + FINISHED, + ERROR +} + +/** + * Represents a parsed line of ffmpeg's stdout output. + */ +open class FfmpegOutputStatus(val lineType: OutputLineClassification, val detail: String = "") { + override fun toString(): String = "Line type: $lineType, detail: $detail" +} + +/** + * Represents a line of ffmpeg output that indicated there was a warning. + */ +open class FfmpegErrorStatus(val error: JibriError) : FfmpegOutputStatus(OutputLineClassification.ERROR, error.detail) + +/** + * Represents a line of ffmpeg output that indicated a bad RTMP URL + */ +class BadRtmpUrlStatus(outputLine: String) : FfmpegErrorStatus(BadRtmpUrl(outputLine)) + +class BrokenPipeStatus(outputLine: String) : FfmpegErrorStatus(BrokenPipe(outputLine)) + +/** + * Ffmpeg quit due to a signal other than what we sent to it + */ +class FfmpegUnexpectedSignalStatus(outputLine: String) : FfmpegErrorStatus(FfmpegUnexpectedSignal(outputLine)) + +/** + * Parses the stdout output of ffmpeg to check if it's working + */ +class OutputParser { + companion object { + /** + * Ffmpeg prints to stdout while its running with a status of its current job. + * For the most part, it uses the following format: + * fieldName=fieldValue fieldName2=fieldValue fieldName3=fieldValue... + * where any amount spaces can be inserted anywhere in that pattern (except for within + * a fieldName or fieldValue). This pattern will parse all fields from an ffmpeg output + * string + */ + private const val FFMPEG_OUTPUT_FIELD_NAME = ONE_OR_MORE_NON_SPACES + private const val FFMPEG_OUTPUT_FIELD_VALUE = ONE_OR_MORE_NON_SPACES + private const val FFMPEG_OUTPUT_FIELD = + "$ZERO_OR_MORE_SPACES($FFMPEG_OUTPUT_FIELD_NAME)$ZERO_OR_MORE_SPACES=" + + "$ZERO_OR_MORE_SPACES($FFMPEG_OUTPUT_FIELD_VALUE)" + + private const val FFMPEG_ENCODING_LINE = "($FFMPEG_OUTPUT_FIELD)+$ZERO_OR_MORE_SPACES" + private const val FFMPEG_EXITED_LINE = "Exiting.*signal$ZERO_OR_MORE_SPACES($ONE_OR_MORE_DIGITS).*" + private const val BAD_RTMP_URL = "rtmp://.*Input/output error" + private const val BROKEN_PIPE = ".*Broken pipe.*" + + /** + * Errors are done a bit differently, as different errors have different scopes. For example, + * a bad RTMP url is an error that only affects this session but an error about running out of + * disk space affects the entire system. This map associates the regex of the ffmpeg error output + * to a function which takes in the output line and returns an [FfmpegErrorStatus] + */ + private val errorTypes = mapOf FfmpegErrorStatus>( + BAD_RTMP_URL to ::BadRtmpUrlStatus, + BROKEN_PIPE to ::BrokenPipeStatus + ) + + /** + * Parse [outputLine], a line of output from Ffmpeg, into an [FfmpegOutputStatus] + */ + fun parse(outputLine: String): FfmpegOutputStatus { + // First we'll check if the output represents that ffmpeg has exited + val exitedMatcher = Pattern.compile(FFMPEG_EXITED_LINE).matcher(outputLine) + if (exitedMatcher.matches()) { + return when (exitedMatcher.group(1).toInt()) { + // 2 is the signal we pass to stop ffmpeg + 2 -> FfmpegOutputStatus(OutputLineClassification.FINISHED, outputLine) + + else -> FfmpegUnexpectedSignalStatus(outputLine) + } + } + // Check if the output is a normal, encoding output + val encodingLineMatcher = Pattern.compile(FFMPEG_ENCODING_LINE).matcher(outputLine) + if (encodingLineMatcher.matches()) { +// val encodingFieldsMatcher = Pattern.compile(ffmpegOutputField).matcher(outputLine) +// val fields = mutableMapOf() +// while (encodingFieldsMatcher.find()) { +// val fieldName = encodingFieldsMatcher.group(1).trim() +// val fieldValue = encodingFieldsMatcher.group(2).trim() +// fields[fieldName] = fieldValue +// } + return FfmpegOutputStatus(OutputLineClassification.ENCODING, outputLine) + } + // Now we'll look for error output + for ((errorLine, createError) in errorTypes) { + val errorMatcher = Pattern.compile(errorLine).matcher(outputLine) + if (errorMatcher.matches()) { + return createError(outputLine) + } + } + return FfmpegOutputStatus(OutputLineClassification.UNKNOWN, outputLine) + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/util/FfmpegFileHandler.kt b/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/util/FfmpegFileHandler.kt new file mode 100644 index 0000000..12a71f8 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/util/FfmpegFileHandler.kt @@ -0,0 +1,26 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.capture.ffmpeg.util + +import java.util.logging.FileHandler + +/** + * A distinct [FileHandler] so that we can configure the file + * Ffmpeg logs to separately in the logging config + */ +class FfmpegFileHandler : FileHandler() diff --git a/src/main/kotlin/org/jitsi/jibri/config/Config.kt b/src/main/kotlin/org/jitsi/jibri/config/Config.kt new file mode 100644 index 0000000..8d79e01 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/config/Config.kt @@ -0,0 +1,46 @@ +/* + * 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.jibri.config + +import com.typesafe.config.ConfigFactory +import org.jitsi.config.TypesafeConfigSource +import org.jitsi.metaconfig.ConfigSource +import org.jitsi.metaconfig.MapConfigSource + +class Config { + companion object { + val ConfigFromFile = TypesafeConfigSource("config", ConfigFactory.load()) + + /** + * The 'new' config source + */ + var configSource = ConfigFromFile + + /** + * The 'legacy' config sources: we parsed a JSON file into a [JibriConfig] instance. + * Unfortunately we can't parse the JSON file via the new config library because it + * contains comments (which the new library doesn't support). + */ + var legacyConfigSource = JibriConfig() + + /** + * We also accepted config parameters via the command line, so this config source + * is set to a [ConfigSource] containing those values. + */ + var commandLineArgs: ConfigSource = MapConfigSource("command line args") + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/config/JibriConfig.kt b/src/main/kotlin/org/jitsi/jibri/config/JibriConfig.kt new file mode 100644 index 0000000..b23af95 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/config/JibriConfig.kt @@ -0,0 +1,203 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.config + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.exc.InvalidFormatException +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.jitsi.jibri.logger +import org.jivesoftware.smack.ConnectionConfiguration +import java.io.File + +data class XmppCredentials( + val domain: String = "", + val port: Int? = null, + val username: String = "", + val password: String = "", + val randomizeUsername: Boolean = false +) { + override fun toString(): String = "XmppCredentials(domain=$domain, port=$port, username=$username, password=*****)" +} + +fun com.typesafe.config.Config.toXmppCredentials(): XmppCredentials = XmppCredentials( + domain = getString("domain"), + port = if (hasPath("port")) getInt("port") else null, + username = getString("username"), + password = getString("password"), + randomizeUsername = if (hasPath("randomize-username")) getBoolean("randomize-username") else false +) + +data class XmppMuc( + val domain: String, + @JsonProperty("room_name") + val roomName: String, + val nickname: String +) + +fun com.typesafe.config.Config.toXmppMuc(): XmppMuc = XmppMuc( + domain = getString("domain"), + roomName = getString("room-name"), + nickname = getString("nickname") +) + +data class XmppEnvironmentConfig( + /** + * A user-friendly name for this environment + */ + val name: String, + /** + * A list of xmpp server hosts to which we'll connect + */ + @JsonProperty("xmpp_server_hosts") + val xmppServerHosts: List, + /** + * The base xmpp domain + */ + @JsonProperty("xmpp_domain") + val xmppDomain: String, + /** + * The baseUrl we'll use, if set, to join the call + */ + @JsonProperty("baseUrl") + val baseUrl: String?, + /** + * The login information for the control API + */ + @JsonProperty("control_login") + val controlLogin: XmppCredentials, + /** + * The muc we'll join to announce our presence for + * recording and streaming services + */ + @JsonProperty("control_muc") + val controlMuc: XmppMuc, + /** + * The muc we'll join to announce our presence + * for sip gateway services + * TODO: should this and controlMuc above be + * optional? but somehow require at least one + * to be set? + */ + @JsonProperty("sip_control_muc") + val sipControlMuc: XmppMuc?, + /** + * The login information the selenium web client will use + */ + @JsonProperty("call_login") + val callLogin: XmppCredentials, + /** + * The value we'll strip from the room jid domain to derive + * the call url + */ + @JsonProperty("room_jid_domain_string_to_strip_from_start") + val stripFromRoomDomain: String, + /** + * How long Jibri sessions will be allowed to last before + * they are stopped. A value of 0 allows them to go on + * indefinitely + */ + @JsonProperty("usage_timeout") + val usageTimeoutMins: Int, + /** + * Whether or not we'll automatically trust any + * cert on this XMPP domain + */ + @JsonProperty("always_trust_certs") + val trustAllXmppCerts: Boolean = true, + /** + * The XMPP security mode to use for the XMPP connection + */ + @JsonProperty("security_mode") + @JsonInclude(JsonInclude.Include.NON_NULL) + val securityMode: ConnectionConfiguration.SecurityMode? = null +) + +fun com.typesafe.config.Config.toXmppEnvironment(): XmppEnvironmentConfig = XmppEnvironmentConfig( + name = getString("name"), + xmppServerHosts = getStringList("xmpp-server-hosts"), + xmppDomain = getString("xmpp-domain"), + baseUrl = if (hasPath("base-url")) { + getString("base-url") + } else { + null + }, + controlLogin = getConfig("control-login").toXmppCredentials(), + controlMuc = getConfig("control-muc").toXmppMuc(), + sipControlMuc = if (hasPath("sip-control-muc")) { + getConfig("sip-control-muc").toXmppMuc() + } else { + null + }, + callLogin = getConfig("call-login").toXmppCredentials(), + stripFromRoomDomain = getString("strip-from-room-domain"), + usageTimeoutMins = getDuration("usage-timeout").toMinutes().toInt(), + trustAllXmppCerts = getBoolean("trust-all-xmpp-certs"), + securityMode = if (hasPath("security-mode")) { + getEnum(ConnectionConfiguration.SecurityMode::class.java, "security-mode") + } else { + null + } +) + +data class JibriConfig( + @JsonProperty("jibri_id") + val jibriId: String? = null, + @JsonProperty("recording_directory") + val recordingDirectory: String? = null, + /** + * Whether or not Jibri should return to idle state + * after handling (successfully or unsuccessfully) + * a request. A value of 'true' here means that a Jibri + * will NOT return back to the IDLE state and will need + * to be restarted in order to be used again. + */ + @JsonProperty("single_use_mode") + val singleUseMode: Boolean? = null, + /** + * Whether or not pushing stats to statsd + * should be enabled. See [org.jitsi.jibri.statsd.JibriStatsDClient]. + */ + @JsonProperty("enable_stats_d") + val enabledStatsD: Boolean? = null, + @JsonProperty("finalize_recording_script_path") + val finalizeRecordingScriptPath: String? = null, + @JsonProperty("xmpp_environments") + val xmppEnvironments: List? = null +) + +fun loadConfigFromFile(configFile: File): JibriConfig? = try { + val config: JibriConfig = jacksonObjectMapper() + .configure(JsonParser.Feature.ALLOW_COMMENTS, true) + .readValue(configFile) + logger.info("Successfully parsed legacy config") + config +} catch (e: MismatchedInputException) { + logger.error("A required config parameter was missing: ${e.originalMessage}") + null +} catch (e: UnrecognizedPropertyException) { + logger.error("An unrecognized config parameter was found: ${e.originalMessage}") + null +} catch (e: InvalidFormatException) { + logger.error("A config parameter was incorrectly formatted: ${e.localizedMessage}") + null +} diff --git a/src/main/kotlin/org/jitsi/jibri/error/JibriError.kt b/src/main/kotlin/org/jitsi/jibri/error/JibriError.kt new file mode 100644 index 0000000..bddaa46 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/error/JibriError.kt @@ -0,0 +1,27 @@ +/* + * 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.jibri.error + +import org.jitsi.jibri.status.ErrorScope + +open class JibriError( + val scope: ErrorScope, + val detail: String +) { + open fun shouldRetry(): Boolean = true + override fun toString(): String = "Error: ${this::class.java.simpleName} $scope $detail" +} diff --git a/src/main/kotlin/org/jitsi/jibri/health/JibriHealth.kt b/src/main/kotlin/org/jitsi/jibri/health/JibriHealth.kt new file mode 100644 index 0000000..8ab3c9b --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/health/JibriHealth.kt @@ -0,0 +1,41 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.health + +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.JsonInclude +import org.jitsi.jibri.status.JibriStatus + +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) +data class EnvironmentContext( + private val name: String +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) +data class JibriHealth( + /** + * Whether or not this Jibri is "busy". See [JibriManager#busy] + */ + private val status: JibriStatus, + /** + * Context for the environment Jibri is currently active on + * (only present if [busy] is true) + */ + private val environmentContext: EnvironmentContext? = null +) diff --git a/src/main/kotlin/org/jitsi/jibri/metrics/JibriMetrics.kt b/src/main/kotlin/org/jitsi/jibri/metrics/JibriMetrics.kt new file mode 100644 index 0000000..959a00b --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/metrics/JibriMetrics.kt @@ -0,0 +1,166 @@ +/* + * 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.jibri.metrics + +import com.timgroup.statsd.NonBlockingStatsDClient +import org.jitsi.jibri.RecordingSinkType +import org.jitsi.jibri.status.ComponentBusyStatus +import org.jitsi.jibri.status.ComponentHealthStatus +import org.jitsi.jibri.status.JibriStatus +import org.jitsi.utils.logging2.createLogger + +class JibriMetrics { + private val logger = createLogger() + + private val statsd = if (StatsConfig.enableStatsD) { + NonBlockingStatsDClient( + "jibri", + StatsConfig.statsdHost, + StatsConfig.statsdPort + ) + } else { + null + } + + private fun incrementStatsDCounter(aspect: String, tag: String) { + statsd?.let { + logger.debug { "Incrementing statsd counter: $aspect:$tag" } + it.incrementCounter(aspect, tag) + } + } + + fun requestWhileBusy(type: RecordingSinkType) { + incrementStatsDCounter(ASPECT_BUSY, type.getTag()) + requestsWhileBusy.inc() + } + + fun start(type: RecordingSinkType) { + incrementStatsDCounter(ASPECT_START, type.getTag()) + sessionsStarted.inc() + } + + fun stop(type: RecordingSinkType) { + incrementStatsDCounter(ASPECT_STOP, type.getTag()) + sessionsStopped.inc() + } + + fun error(type: RecordingSinkType) { + incrementStatsDCounter(ASPECT_ERROR, type.getTag()) + errors.inc() + } + + fun xmppConnected(tags: String) { + incrementStatsDCounter(XMPP_CONNECTED, tags) + xmppConnected.inc() + } + + fun xmppReconnecting(tags: String) { + incrementStatsDCounter(XMPP_RECONNECTING, tags) + xmppReconnecting.inc() + } + + fun xmppReconnectionFailed(tags: String) { + incrementStatsDCounter(XMPP_RECONNECTION_FAILED, tags) + xmppReconnectionFailed.inc() + } + + fun xmppPingFailed(tags: String) { + incrementStatsDCounter(XMPP_PING_FAILED, tags) + xmppPingFailed.inc() + } + + fun xmppClosed(tags: String) { + incrementStatsDCounter(XMPP_CLOSED, tags) + xmppClosed.inc() + } + + fun xmppClosedOnError(tags: String) { + incrementStatsDCounter(XMPP_CLOSED_ON_ERROR, tags) + xmppClosedOnError.inc() + } + + fun stoppedOnXmppClosed(tags: String) { + incrementStatsDCounter(STOPPED_ON_XMPP_CLOSED, tags) + stoppedOnXmppClosed.inc() + } + + fun updateStatus(status: JibriStatus) { + healthy.set(status.health.healthStatus == ComponentHealthStatus.HEALTHY) + recording.set(status.busyStatus == ComponentBusyStatus.BUSY) + } + + companion object { + val sessionsStarted = JibriMetricsContainer.registerCounter( + "sessions_started", + "Number of times a session was started." + ) + val sessionsStopped = JibriMetricsContainer.registerCounter( + "sessions_stopped", + "Number of times a session was stopped." + ) + val errors = JibriMetricsContainer.registerCounter( + "errors", + "Number of errors." + ) + val requestsWhileBusy = JibriMetricsContainer.registerCounter( + "busy", + "Number of times a request was received while the instance was busy." + ) + val xmppConnected = JibriMetricsContainer.registerCounter( + "xmpp_connected", + "Number of times an XMPP connection connected." + ) + val xmppReconnecting = JibriMetricsContainer.registerCounter( + "xmpp_reconnecting", + "Number of times an XMPP connection started re-connecting." + ) + val xmppReconnectionFailed = JibriMetricsContainer.registerCounter( + "xmpp_reconnection_failed", + "Number of times an XMPP re-connection failed." + ) + val xmppPingFailed = JibriMetricsContainer.registerCounter( + "xmpp_ping_failed", + "Number of times an XMPP ping timed out." + ) + val xmppClosed = JibriMetricsContainer.registerCounter( + "xmpp_closed", + "Number of times an XMPP connection was closed." + ) + val xmppClosedOnError = JibriMetricsContainer.registerCounter( + "xmpp_closed_on_error", + "Number of times an XMPP connection was closed on error." + ) + val stoppedOnXmppClosed = JibriMetricsContainer.registerCounter( + "stopped_on_xmpp_closed", + "Number of times a session was stopped because XMPP disconnected." + ) + val healthy = JibriMetricsContainer.registerBooleanMetric( + "healthy", + "Whether the jibri instance is currently healthy." + ) + val recording = JibriMetricsContainer.registerBooleanMetric( + "recording", + "Whether the jibri instance is currently in use." + ) + } +} + +private fun RecordingSinkType.getTag() = when (this) { + RecordingSinkType.STREAM -> TAG_SERVICE_LIVE_STREAM + RecordingSinkType.FILE -> TAG_SERVICE_RECORDING + RecordingSinkType.GATEWAY -> TAG_SERVICE_SIP_GATEWAY +} diff --git a/src/main/kotlin/org/jitsi/jibri/metrics/JibriMetricsContainer.kt b/src/main/kotlin/org/jitsi/jibri/metrics/JibriMetricsContainer.kt new file mode 100644 index 0000000..4475d0f --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/metrics/JibriMetricsContainer.kt @@ -0,0 +1,21 @@ +/* + * 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.jibri.metrics + +import org.jitsi.metrics.MetricsContainer + +object JibriMetricsContainer : MetricsContainer(namespace = "jitsi_jibri") diff --git a/src/main/kotlin/org/jitsi/jibri/metrics/StatsConfig.kt b/src/main/kotlin/org/jitsi/jibri/metrics/StatsConfig.kt new file mode 100644 index 0000000..0852431 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/metrics/StatsConfig.kt @@ -0,0 +1,39 @@ +/* + * 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.jibri.metrics + +import org.jitsi.jibri.config.Config +import org.jitsi.metaconfig.config + +object StatsConfig { + val enableStatsD: Boolean by config { + "JibriConfig::enableStatsD" { Config.legacyConfigSource.enabledStatsD!! } + "jibri.stats.enable-stats-d".from(Config.configSource) + } + + val statsdHost: String by config { + "jibri.stats.host".from(Config.configSource) + } + + val statsdPort: Int by config { + "jibri.stats.port".from(Config.configSource) + } + + val enablePrometheus: Boolean by config { + "jibri.stats.prometheus.enabled".from(Config.configSource) + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/metrics/StatsDEvents.kt b/src/main/kotlin/org/jitsi/jibri/metrics/StatsDEvents.kt new file mode 100644 index 0000000..9b7c374 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/metrics/StatsDEvents.kt @@ -0,0 +1,36 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.metrics + +internal const val ASPECT_START = "start" +internal const val ASPECT_STOP = "stop" +internal const val ASPECT_BUSY = "busy" +internal const val ASPECT_ERROR = "error" + +internal const val XMPP_CONNECTED = "xmpp-connected" +internal const val XMPP_RECONNECTING = "xmpp-reconnecting" +internal const val XMPP_RECONNECTION_FAILED = "xmpp-reconnection-failed" +internal const val XMPP_PING_FAILED = "xmpp-ping-failed" +internal const val XMPP_CLOSED = "xmpp-closed" +internal const val XMPP_CLOSED_ON_ERROR = "xmpp-closed-on-error" + +// A recording session was stopped because XMPP disconnected. +internal const val STOPPED_ON_XMPP_CLOSED = "stopped-on-xmpp-closed" + +internal const val TAG_SERVICE_RECORDING = "recording" +internal const val TAG_SERVICE_LIVE_STREAM = "live_stream" +internal const val TAG_SERVICE_SIP_GATEWAY = "sip_gateway" diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/Errors.kt b/src/main/kotlin/org/jitsi/jibri/selenium/Errors.kt new file mode 100644 index 0000000..792f03a --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/Errors.kt @@ -0,0 +1,25 @@ +/* + * 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.jibri.selenium + +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.status.ErrorScope + +object FailedToJoinCall : JibriError(ErrorScope.SESSION, "Failed to join the call") +object ChromeHung : JibriError(ErrorScope.SESSION, "Chrome hung") +object NoMediaReceived : JibriError(ErrorScope.SESSION, "No media received") +object IceFailed : JibriError(ErrorScope.SESSION, "ICE failed") diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/JibriSelenium.kt b/src/main/kotlin/org/jitsi/jibri/selenium/JibriSelenium.kt new file mode 100644 index 0000000..a3a2bf9 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/JibriSelenium.kt @@ -0,0 +1,449 @@ +/* + * 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.jibri.selenium + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.jitsi.jibri.CallUrlInfo +import org.jitsi.jibri.MainConfig +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.config.XmppCredentials +import org.jitsi.jibri.selenium.pageobjects.CallPage +import org.jitsi.jibri.selenium.status_checks.EmptyCallStatusCheck +import org.jitsi.jibri.selenium.status_checks.IceConnectionStatusCheck +import org.jitsi.jibri.selenium.status_checks.LocalParticipantKickedStatusCheck +import org.jitsi.jibri.selenium.status_checks.MediaReceivedStatusCheck +import org.jitsi.jibri.selenium.util.BrowserFileHandler +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.StatusPublisher +import org.jitsi.jibri.util.TaskPools +import org.jitsi.jibri.util.extensions.scheduleAtFixedRate +import org.jitsi.jibri.util.getLoggerWithHandler +import org.jitsi.jibri.util.randomAlphaNum +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.from +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import org.openqa.selenium.TimeoutException +import org.openqa.selenium.WebDriverException +import org.openqa.selenium.chrome.ChromeDriver +import org.openqa.selenium.chrome.ChromeDriverService +import org.openqa.selenium.chrome.ChromeOptions +import org.openqa.selenium.logging.LogType +import org.openqa.selenium.logging.LoggingPreferences +import java.io.File +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Level + +/** + * Parameters needed for joining the call in Selenium + */ +data class CallParams( + val callUrlInfo: CallUrlInfo, + /** + * The email that should be used for jibri. Note that this + * is currently only used in the sipgateway gateway scenario; when doing + * recording the jibri is 'invisible' in the call + */ + val email: String = "", + /** + * SipJibri will set the passcode in the local storage + * This will be used by jitsi-meet to send the conference passcode to prosody + * and bypass the password prompt + */ + val passcode: String? = null, + /** + * Override the default value of callStats username. + * Note that this is currently only used in the sipgateway gateway scenario; + */ + val callStatsUsernameOverride: String = "", + /** + * Display name which when it is set, it is used by jibri when joining the web conference. + * Note that this is currently only used in the sipgateway gateway scenario; + */ + val displayName: String = "", + /** + * Extra URL params to add when joining the call, on top of the service-specific defaults. + */ + val extraUrlParams: List = listOf() +) { + override fun toString(): String = if (passcode.isNullOrEmpty()) { + "CallParams(callUrlInfo=$callUrlInfo, email='$email', passcode=$passcode" + + ", callStatsUsernameOverride=$callStatsUsernameOverride, displayName=$displayName)" + } else { + "CallParams(callUrlInfo=$callUrlInfo, email='$email', passcode=*****" + + ", callStatsUsernameOverride=$callStatsUsernameOverride, displayName=$displayName)" + } +} + +/** + * Options that can be passed to [JibriSelenium] + */ +data class JibriSeleniumOptions( + /** + * Which display selenium should be started on + */ + val display: String = System.getenv("DISPLAY") ?: ":0", + /** + * The display name that should be used for jibri. Note that this + * is currently only used in the sipgateway gateway scenario; when doing + * recording the jibri is 'invisible' in the call + */ + val displayName: String = "", + /** + * The email that should be used for jibri. Note that this + * is currently only used in the sipgateway gateway scenario; when doing + * recording the jibri is 'invisible' in the call + */ + val email: String = "", + /** + * The callstats username to be used for jibri. + * Set this only to override the default callStatsUsername + */ + val callStatsUsernameOverride: String = "", + /** + * Chrome command line flags to add (in addition to the common + * ones) + */ + val extraChromeCommandLineFlags: List = listOf(), + /** + * How long we should stay in a call with no other participants before quitting + */ + val emptyCallTimeout: Duration = EmptyCallStatusCheck.defaultCallEmptyTimeout, + /** + * Use local participant status checks, such as if the local participant is kicked out + * This is currently only used in the sipgateway gateway scenario; + */ + val enableLocalParticipantStatusChecks: Boolean = false +) + +val SIP_GW_URL_OPTIONS = listOf( + "config.iAmRecorder=true", + "config.iAmSipGateway=true", + "config.ignoreStartMuted=true", + "config.analytics.disabled=true", + "config.enableEmailInStats=false", + "config.p2p.enabled=false", + "config.prejoinConfig.enabled=false", + "config.requireDisplayName=false", + "config.deeplinking.disabled=true", + "devices.videoInput=\"PJSUA\"" +) + +val RECORDING_URL_OPTIONS = listOf( + "config.analytics.disabled=true", + "config.disableInitialGUM=true", + "config.externalConnectUrl=null", + "config.iAmRecorder=true", + "config.p2p.enabled=false", + "config.prejoinConfig.enabled=false", + "config.requireDisplayName=false", + "config.deeplinking.disabled=true", + "config.startWithAudioMuted=true", + "config.startWithVideoMuted=true", + "interfaceConfig.APP_NAME=\"Jibri\"" +) + +/** + * The [JibriSelenium] class is responsible for all of the interactions with + * Selenium. It: + * 1) Owns the webdriver + * 2) Handles passing the proper options to Chrome + * 3) Sets the necessary localstorage variables before joining a call + * It implements [StatusPublisher] to publish its status + */ +class JibriSelenium( + parentLogger: Logger, + private val jibriSeleniumOptions: JibriSeleniumOptions = JibriSeleniumOptions() +) : StatusPublisher() { + private val logger = createChildLogger(parentLogger) + private var chromeDriver: ChromeDriver + private var currCallUrl: String? = null + private val stateMachine = SeleniumStateMachine() + private var shuttingDown = AtomicBoolean(false) + private val chromeOpts: List by config("jibri.chrome.flags".from(Config.configSource)) + private var callPage: CallPage + + /** + * A task which executes at an interval and checks various aspects of the call to make sure things are + * working correctly + */ + private var recurringCallStatusCheckTask: ScheduledFuture<*>? = null + + /* + * Set up default chrome driver options (using fake device, etc.) + */ + init { + if (System.getProperty("webdriver.chrome.logfile") == null) { + System.setProperty("webdriver.chrome.logfile", "/var/log/jitsi/jibri/chromedriver.log") + } + val chromeDriverLogFile = File(System.getProperty("webdriver.chrome.logfile")) + // chromedriver exits immediately (exit value 1) if it cannot open its log file, so make + // sure the parent directory exists. + chromeDriverLogFile.parentFile?.mkdirs() + val chromeOptions = ChromeOptions() + chromeOptions.addArguments(chromeOpts) + chromeOptions.addArguments(jibriSeleniumOptions.extraChromeCommandLineFlags) + val chromeDriverService = ChromeDriverService.Builder() + .withEnvironment(mapOf("DISPLAY" to jibriSeleniumOptions.display)) + .withLogFile(chromeDriverLogFile) + .build() + val logPrefs = LoggingPreferences() + logPrefs.enable(LogType.DRIVER, Level.ALL) + chromeOptions.setCapability("goog:loggingPrefs", logPrefs) + chromeDriver = ChromeDriver(chromeDriverService, chromeOptions) + chromeDriver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(60)) + callPage = CallPage.create(chromeDriver) + + stateMachine.onStateTransition(this::onSeleniumStateChange) + } + + private fun onSeleniumStateChange(oldState: ComponentState, newState: ComponentState) { + logger.info("Transitioning from state $oldState to $newState") + publishStatus(newState) + } + + private fun startRecurringCallStatusChecks() { + // Note that the order here is important: we always want to check for no participants before we check + // for media being received, since the call being empty will also mean Jibri is not receiving media but should + // not cause Jibri to go unhealthy (like not receiving media when there are others in the call will). + val callStatusChecks = buildList { + add(EmptyCallStatusCheck(logger, callEmptyTimeout = jibriSeleniumOptions.emptyCallTimeout)) + add(MediaReceivedStatusCheck(logger)) + add(IceConnectionStatusCheck(logger)) + if (jibriSeleniumOptions.enableLocalParticipantStatusChecks) { + add(LocalParticipantKickedStatusCheck(logger)) + } + } + + // We fire all state transitions in the ioPool, otherwise we may try and cancel the + // recurringCallStatusCheckTask from within the thread it was executing in. Another solution would've been + // to pass 'false' to recurringCallStatusCheckTask.cancel, but it felt cleaner to separate the threads + val transitionState = { event: SeleniumEvent -> + TaskPools.ioPool.submit { + stateMachine.transition(event) + } + } + recurringCallStatusCheckTask = TaskPools.recurringTasksPool.scheduleAtFixedRate(15, TimeUnit.SECONDS, 15) { + try { + // Run through each of the checks. If we hit one that returns an event, then we stop and process the + // state transition from that event. Note: it's intentional that we stop at the first check that fails + // and 'asSequence' is necessary to do that. + val event = callStatusChecks + .asSequence() + .map { check -> check.run(callPage) } + .firstOrNull { result -> result != null } + if (event != null) { + logger.info("Recurring call status checks generated event $event") + transitionState(event) + } + } catch (t: TimeoutException) { + // We've found that a timeout here often implies chrome has hung so consider this as an error + // which will result in this Jibri being marked as unhealthy + logger.error("Javascript timed out, assuming chrome has hung", t) + transitionState(SeleniumEvent.ChromeHung) + } catch (t: WebDriverException) { + if (!shuttingDown.get()) { + logger.error("Can't reach browser", t) + transitionState(SeleniumEvent.ChromeHung) + } + } catch (t: Throwable) { + logger.error("Error while running call status checks", t) + // We'll just assume anything here is an issue + transitionState(SeleniumEvent.ChromeHung) + } + } + } + + /** + * Keep track of all the participants who take part in the call while + * Jibri is active + */ + private fun addParticipantTrackers() { + callPage.injectParticipantTrackerScript() + if (jibriSeleniumOptions.enableLocalParticipantStatusChecks) { + callPage.injectLocalParticipantTrackerScript() + } + } + + fun addToPresence(key: String, value: String): Boolean = callPage.addToPresence(key, value) + + fun sendPresence(): Boolean = callPage.sendPresence() + + fun setParticipantProperties(properties: Map): Boolean = + callPage.setParticipantProperties(properties) + + fun handleDtmfStar6() { + logger.info("Handling *6 DTMF command (audio toggle)") + + if (callPage.isVisitor()) { + logger.info("In visitor mode, toggling raise hand") + callPage.raiseHand() + } else if (!callPage.isLocalAudioMuted()) { + logger.info("Currently audio unmuted, muting audio") + val result = callPage.toggleAudioMute() + logger.info("toggleAudioMute result: $result") + } else { + val audioForceMuted = callPage.isAudioForceMuted() + if (audioForceMuted) { + logger.info("Audio force muted by AV moderation, raising hand to request unmute") + callPage.raiseHand() + } else { + val result = callPage.toggleAudioMute() + logger.info("toggleAudioMute result: $result") + } + } + } + + fun handleDtmfStar7() { + logger.info("Handling *7 DTMF command (video toggle)") + + if (callPage.isVisitor()) { + logger.info("In visitor mode, toggling raise hand") + callPage.raiseHand() + } else if (!callPage.isLocalVideoMuted()) { + logger.info("Currently video unmuted, muting video") + val result = callPage.toggleVideoMute() + logger.info("toggleVideoMute result: $result") + } else { + val videoForceMuted = callPage.isVideoForceMuted() + if (videoForceMuted) { + logger.info("Video force muted by AV moderation, raising hand to request unmute") + callPage.raiseHand() + } else { + val result = callPage.toggleVideoMute() + logger.info("toggleVideoMute result: $result") + } + } + } + + /** + * Join a web call with Selenium + */ + fun joinCall( + callUrlInfo: CallUrlInfo, + xmppCredentials: XmppCredentials? = null, + passcode: String? = null, + unmute: Boolean = false + ) { + // These are all blocking calls, so offload the work to another thread + TaskPools.ioPool.submit { + try { + var callStatsUsername = "jibri" + if (jibriSeleniumOptions.callStatsUsernameOverride.isNotEmpty()) { + callStatsUsername = jibriSeleniumOptions.callStatsUsernameOverride + } else if (MainConfig.jibriId.isNotEmpty()) { + callStatsUsername = MainConfig.jibriId + } + + val localStorageValues = mutableMapOf( + "displayname" to jibriSeleniumOptions.displayName, + "email" to jibriSeleniumOptions.email, + "callStatsUserName" to callStatsUsername + ) + xmppCredentials?.let { + val username = if (xmppCredentials.randomizeUsername) { + "${xmppCredentials.username}-${randomAlphaNum(8)}" + } else { + xmppCredentials.username + } + localStorageValues["xmpp_username_override"] = "$username@${xmppCredentials.domain}" + localStorageValues["xmpp_password_override"] = xmppCredentials.password + } + passcode?.let { + localStorageValues["xmpp_conference_password_override"] = passcode + } + + // jitsi-meet parses this twice... + val innerJson = jacksonObjectMapper().writeValueAsString(localStorageValues) + val innerJsonEscaped = innerJson.replace("\"", "\\\"") + val outerJson = "\"$innerJsonEscaped\"" + val callUrl = callUrlInfo.withAdditionalUrlParams( + listOf( + "config.useHostPageLocalStorage=true", + "appData.localStorageContent=${encodeURIComponent(outerJson)}", + ) + ) + + if (!callPage.visit(callUrl)) { + stateMachine.transition(SeleniumEvent.FailedToJoinCall) + } else if (unmute && !callPage.unmute()) { + stateMachine.transition(SeleniumEvent.FailedToJoinCall) + } else { + startRecurringCallStatusChecks() + addParticipantTrackers() + currCallUrl = callUrlInfo.callUrl + stateMachine.transition(SeleniumEvent.CallJoined) + } + } catch (t: Throwable) { + logger.error("An error occurred while joining the call", t) + stateMachine.transition(SeleniumEvent.FailedToJoinCall) + } + } + } + + fun getParticipants(): List> = callPage.getParticipants() + + fun leaveCallAndQuitBrowser() { + logger.info("Leaving call and quitting browser") + shuttingDown.set(true) + recurringCallStatusCheckTask?.cancel(true) + logger.info("Recurring call status checks cancelled") + + browserOutputLogger.info("Logs for call $currCallUrl") + try { + chromeDriver.manage().logs().availableLogTypes.forEach { logType -> + val logEntries = chromeDriver.manage().logs().get(logType) + logger.info("Got ${logEntries.all.size} log entries for type $logType") + browserOutputLogger.info("========= TYPE=$logType ===========") + logEntries.all.forEach { + browserOutputLogger.info(it.toString()) + } + } + } catch (t: Throwable) { + logger.error("Error trying to get chromedriver logs", t) + } + logger.info("Leaving web call") + try { + callPage.leave() + } catch (t: Throwable) { + logger.error("Error trying to leave the call", t) + } + currCallUrl = null + logger.info("Quitting chrome driver") + chromeDriver.quit() + logger.info("Chrome driver quit") + } + + companion object { + private val browserOutputLogger = getLoggerWithHandler("browser", BrowserFileHandler()) + const val COMPONENT_ID = "Selenium" + } +} + +/** Match javascript's encodeURIComponent */ +private fun encodeURIComponent(str: String) = URLEncoder.encode(str, StandardCharsets.US_ASCII.toString()) + .replace("+", "%20") + .replace("%21", "!") + .replace("%27", "'") + .replace("%28", "(") + .replace("%29", ")") + .replace("%7E", "~") diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/SeleniumStateMachine.kt b/src/main/kotlin/org/jitsi/jibri/selenium/SeleniumStateMachine.kt new file mode 100644 index 0000000..88ef38f --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/SeleniumStateMachine.kt @@ -0,0 +1,92 @@ +/* + * 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.jibri.selenium + +import com.tinder.StateMachine +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.NotifyingStateMachine + +sealed class SeleniumEvent { + object CallJoined : SeleniumEvent() + object FailedToJoinCall : SeleniumEvent() + object CallEmpty : SeleniumEvent() + object NoMediaReceived : SeleniumEvent() + object IceFailedEvent : SeleniumEvent() + object ChromeHung : SeleniumEvent() + object LocalParticipantKicked : SeleniumEvent() +} + +sealed class SideEffect + +class SeleniumStateMachine : NotifyingStateMachine() { + private val stateMachine = StateMachine.create { + initialState(ComponentState.StartingUp) + + state { + on { + transitionTo(ComponentState.Running) + } + on { + transitionTo(ComponentState.Error(FailedToJoinCall)) + } + on { + transitionTo(ComponentState.Error(ChromeHung)) + } + } + + state { + on { + transitionTo(ComponentState.Finished) + } + on { + transitionTo(ComponentState.Error(NoMediaReceived)) + } + on { + transitionTo(ComponentState.Error(IceFailed)) + } + on { + transitionTo(ComponentState.Finished) + } + on { + transitionTo(ComponentState.Error(ChromeHung)) + } + } + + state { + on(any()) { + dontTransition() + } + } + + state { + on(any()) { + dontTransition() + } + } + + onTransition { + val validTransition = it as? StateMachine.Transition.Valid ?: run { + throw Exception("Invalid state transition: $it") + } + if (validTransition.fromState::class != validTransition.toState::class) { + notify(validTransition.fromState, validTransition.toState) + } + } + } + + fun transition(event: SeleniumEvent): StateMachine.Transition<*, *, *> = stateMachine.transition(event) +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/AbstractPageObject.kt b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/AbstractPageObject.kt new file mode 100644 index 0000000..5a9ba0d --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/AbstractPageObject.kt @@ -0,0 +1,42 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.selenium.pageobjects + +import org.jitsi.jibri.CallUrlInfo +import org.jitsi.utils.logging2.createLogger +import org.openqa.selenium.remote.RemoteWebDriver +import kotlin.time.measureTime + +/** + * [AbstractPageObject] is a page object class containing logic common to + * all page object instances + */ +open class AbstractPageObject(protected val driver: RemoteWebDriver) { + private val logger = createLogger() + + open fun visit(url: CallUrlInfo): Boolean { + logger.info("Visiting url $url") + + val totalTime = measureTime { + driver.get(url.callUrl) + } + + logger.info("Waited $totalTime for driver to load page") + return true + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/AppCallPage.kt b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/AppCallPage.kt new file mode 100644 index 0000000..6555c1e --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/AppCallPage.kt @@ -0,0 +1,636 @@ +package org.jitsi.jibri.selenium.pageobjects + +import org.jitsi.jibri.CallUrlInfo +import org.jitsi.utils.logging2.createLogger +import org.openqa.selenium.TimeoutException +import org.openqa.selenium.remote.RemoteWebDriver +import org.openqa.selenium.support.PageFactory +import org.openqa.selenium.support.ui.WebDriverWait +import java.time.Duration +import kotlin.time.measureTimedValue + +/** + * Page object representing the in-call page on a jitsi-meet server. + * NOTE that all the calls here which execute javascript may throw (if, for example, chrome has crashed). It is + * intentional that this exceptions are propagated up: the caller should handle those cases. + */ +class AppCallPage(driver: RemoteWebDriver) : AbstractPageObject(driver), CallPage { + private val logger = createLogger() + + init { + PageFactory.initElements(driver, this) + } + + override fun visit(url: CallUrlInfo): Boolean { + if (!super.visit(url)) { + return false + } + val (result, totalTime) = measureTimedValue { + try { + WebDriverWait(driver, Duration.ofSeconds(30)).until { + val result = driver.executeScript( + """ + try { + return APP.conference._room.isJoined(); + } catch (e) { + return e.message; + } + """.trimMargin() + ) + when (result) { + is Boolean -> result + + else -> { + logger.debug { "Not joined yet: $result" } + false + } + } + } + true + } catch (t: TimeoutException) { + logger.error("Timed out waiting for call page to load") + false + } + } + + if (result) { + logger.info("Waited $totalTime to join the conference") + } + + return result + } + + /** + * Enable the local camera and microphone. Used in sip gateway mode. + */ + override fun unmute() = doUnmute("Audio") && doUnmute("Video") + + private fun doUnmute(mediaType: String): Boolean { + return try { + val result = driver.executeScript( + """ + try { + APP.conference.mute$mediaType(false); + } catch (e) { + return e.message; + } + """.trimMargin() + ) + if (result != null) { + logger.info { "Failed to unmute $mediaType: $result" } + return false + } + return true + } catch (t: TimeoutException) { + logger.error("Timed out waiting for unmute $mediaType") + false + } + } + + /** Returns the number of participants excluding hidden participants. */ + override fun getNumParticipants(): Int { + val result = driver.executeScript( + """ + try { + return (APP.conference._room.getParticipants().$PARTICIPANT_FILTER_SCRIPT).length + 1; + } catch (e) { + return e.message; + } + """.trimMargin() + ) + return when (result) { + is Number -> result.toInt() + else -> 1 + } + } + + /** + * Return true if there are no other participants in the conference. + */ + override fun isCallEmpty() = getNumParticipants() <= 1 + + @Suppress("UNCHECKED_CAST") + private fun getStats(): Map { + val result = driver.executeScript( + """ + try { + return APP.conference.getStats(); + } catch (e) { + return e.message; + } + """.trimMargin() + ) + if (result is String) { + return mapOf() + } + return result as Map + } + + @Suppress("UNCHECKED_CAST") + override fun getBitrates(): Map { + val stats = getStats() + return stats.getOrDefault("bitrate", mapOf()) as Map + } + + override fun injectParticipantTrackerScript(): Boolean { + val result = driver.executeScript( + """ + try { + window._jibriParticipants = new Map(); + const existingMembers = APP.conference._room.room.members || {}; + const existingMemberJids = Object.keys(existingMembers); + console.log("There were " + existingMemberJids.length + " existing members"); + existingMemberJids.forEach(jid => { + const existingMember = existingMembers[jid]; + const nick = existingMember.nick; + if (existingMember.identity) { + console.log("Member ", existingMember, " has identity, adding"); + if (nick && nick.length > 0 && existingMember.identity.user) { + existingMember.identity.user.name = nick; + } + window._jibriParticipants.set(jid, existingMember.identity); + } else { + console.log("Member ", existingMember.jid, " has no identity, skipping"); + } + }); + APP.conference._room.room.addListener( + "xmpp.muc_member_joined", + (from, nick, role, hidden, statsid, status, identity) => { + console.log("Jibri got MUC_MEMBER_JOINED: ", from, identity); + if (!hidden && identity) { + if (nick && nick.length > 0 && identity.user) { + identity.user.name = nick; + } + window._jibriParticipants.set(from, identity); + } + } + ); + APP.conference._room.room.addListener( + "xmpp.display_name_changed", + (jid, displayName) => { + const identity = window._jibriParticipants.get(jid); + if (displayName && displayName.length > 0 && identity && identity.user) { + identity.user.name = displayName; + } + } + ); + + return true; + } catch (e) { + return e.message; + } + """.trimMargin() + ) + return when (result) { + is Boolean -> result + else -> false + } + } + + override fun injectLocalParticipantTrackerScript(): Boolean { + val result = driver.executeScript( + """ + try { + window._isLocalParticipantKicked=false + + APP.conference._room.room.addListener( + "xmpp.kicked", + (isSelfPresence, actorId, kickedParticipantId, reason) => { + console.log("Jibri got a KICKED event: ", isSelfPresence, actorId, kickedParticipantId, reason); + if (isSelfPresence) { + window._isLocalParticipantKicked=true + } + } + ); + + return true; + } catch (e) { + return e.message; + } + """.trimMargin() + ) + return when (result) { + is Boolean -> result + else -> false + } + } + + @Suppress("UNCHECKED_CAST") + override fun getParticipants(): List> { + val result = driver.executeScript( + """ + try { + return window._jibriParticipants.values().toArray(); + } catch (e) { + return e.message; + } + """.trimMargin() + ) + if (result is List<*>) { + return result as List> + } else { + return listOf() + } + } + + /** + * Return how many of the participants are Jigasi clients. + * Note: excludes any participants that are hidden (for example transcribers) + */ + override fun numRemoteParticipantsJigasi(): Int { + val result = driver.executeScript( + """ + try { + return APP.conference._room.getParticipants() + .$PARTICIPANT_FILTER_SCRIPT + .filter(participant => participant.getProperty("features_jigasi") == true) + .length; + } catch (e) { + return e.message; + } + """.trimMargin() + ) + return when (result) { + is Number -> result.toInt() + + else -> { + logger.error("error running numRemoteParticipantsJigasi script: $result ${result?.javaClass}") + 0 + } + } + } + + /** How many of the participants are hidden or hiddenFromRecorder. */ + override fun numHiddenParticipants(): Int { + val result = driver.executeScript( + """ + try { + return APP.conference._room.getParticipants() + .filter(p => (p.isHidden() || p.isHiddenFromRecorder())) + .length; + } catch (e) { + return e.message; + } + """.trimMargin() + ) + return when (result) { + is Number -> result.toInt() + + else -> { + logger.error("error running numHiddenParticipants script: $result ${result?.javaClass}") + 0 + } + } + } + + /** + * Return true if ICE is connected. + */ + override fun isIceConnected(): Boolean { + val result: Any? = driver.executeScript( + """ + try { + return APP.conference.getConnectionState(); + } catch(e) { + return e.message; + } + """ + ) + return (result.toString().lowercase() == "connected").also { + if (!it) { + logger.warn("ICE not connected: $result") + } + } + } + + override fun isLocalParticipantKicked(): Boolean { + val result = driver.executeScript( + """ + try { + return window._isLocalParticipantKicked; + } catch (e) { + return e.message; + } + """.trimMargin() + ) + if (result is Boolean) { + return result + } else { + return false + } + } + + /** + * Returns a count of how many remote participants are totally muted (audio + * and video). We ignore jigasi participants as they maybe muted in their presence + * but also hard muted via the device, and we later ignore their state. + * Note: Excludes hidden participants. + */ + override fun numRemoteParticipantsMuted(): Int { + val result = driver.executeScript( + """ + try { + return APP.conference._room.getParticipants() + .$PARTICIPANT_FILTER_SCRIPT + .filter(participant => participant.isAudioMuted() && participant.isVideoMuted() + && participant.getProperty("features_jigasi") !== true) + .length; + } catch (e) { + return e.message; + } + """.trimMargin() + ) + return when (result) { + is Number -> result.toInt() + + else -> { + logger.error("error running numRemoteParticipantsMuted script: $result ${result?.javaClass}") + 0 + } + } + } + + override fun isVisitor(): Boolean { + val result = driver.executeScript( + """ + try { + return APP.store.getState()['features/visitors']?.iAmVisitor === true; + } catch (e) { + return false; + } + """.trimMargin() + ) + return result as? Boolean ?: false + } + + override fun isLocalAudioMuted(): Boolean { + val result = driver.executeScript( + """ + try { + return APP.conference.isLocalAudioMuted(); + } catch (e) { + return true; + } + """.trimMargin() + ) + return result as? Boolean ?: true + } + + override fun isLocalVideoMuted(): Boolean { + val result = driver.executeScript( + """ + try { + return APP.conference.isLocalVideoMuted(); + } catch (e) { + return true; + } + """.trimMargin() + ) + return result as? Boolean ?: true + } + + /** Returns true if AV moderation is enabled for audio and the local participant is not approved to unmute. */ + override fun isAudioForceMuted(): Boolean { + val result = driver.executeScript( + """ + try { + var state = APP.store.getState(); + var avMod = state['features/av-moderation']; + if (!avMod || !avMod.audioModerationEnabled) return false; + var local = state['features/base/participants']?.local; + if (local && local.role === 'moderator') return false; + return avMod.audioUnmuteApproved !== true; + } catch (e) { + return false; + } + """.trimMargin() + ) + return result as? Boolean ?: false + } + + /** Returns true if AV moderation is enabled for video and the local participant is not approved to unmute. */ + override fun isVideoForceMuted(): Boolean { + val result = driver.executeScript( + """ + try { + var state = APP.store.getState(); + var avMod = state['features/av-moderation']; + if (!avMod || !avMod.videoModerationEnabled) return false; + var local = state['features/base/participants']?.local; + if (local && local.role === 'moderator') return false; + return avMod.videoUnmuteApproved !== true; + } catch (e) { + return false; + } + """.trimMargin() + ) + return result as? Boolean ?: false + } + + // Toggles video mute and waits for the Redux store to confirm the state change. + // muteVideo() returns a Promise — firing it synchronously and returning immediately + // would not guarantee the toggle completed. Unmuting requires re-acquiring the camera + // device, which is slower (12s timeout) than muting/releasing it (5s). On timeout, + // extra state is returned to help diagnose why the change did not complete. + override fun toggleVideoMute(): Any? { + driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(20)) + return driver.executeAsyncScript( + """ + var done = arguments[0]; + try { + var isMuted = APP.conference.isLocalVideoMuted(); + var unsubscribe; + var timer; + var cleanup = function() { clearTimeout(timer); if (unsubscribe) unsubscribe(); }; + + var waitForChange = function(ms, timeoutMsg) { + timer = setTimeout(function() { + cleanup(); + var st = APP.store.getState(); + var vt = st['features/base/tracks'].filter(function(t) { return t.local && t.mediaType === 'video'; }); + done(timeoutMsg + ' mediaMuted=' + st['features/base/media'].video.muted + + ' videoTracks=' + vt.length + ' hasJitsiTrack=' + vt.some(function(t) { return !!t.jitsiTrack; })); + }, ms); + unsubscribe = APP.store.subscribe(function() { + var nowMuted = APP.conference.isLocalVideoMuted(); + if (nowMuted !== isMuted) { + cleanup(); + done('ok wasMuted=' + isMuted + ' nowMuted=' + nowMuted); + } + }); + }; + + if (!isMuted) { + // Prefer muting via the track directly so the Promise rejection is catchable. + // Fall back to a Redux dispatch when no track is present yet. + var localVideo = APP.store.getState()['features/base/tracks'] + .find(function(t) { return t.local && t.mediaType === 'video' && t.jitsiTrack; }); + if (localVideo) { + waitForChange(5000, 'timeout-muting'); + localVideo.jitsiTrack.mute().catch(function(e) { cleanup(); done('error-mute: ' + e); }); + } else { + waitForChange(5000, 'timeout-mute-no-track'); + APP.store.dispatch({ type: 'SET_VIDEO_MUTED', muted: true }); + } + } else { + // ensureTrack tells Jitsi to re-acquire the camera if no track exists yet. + waitForChange(12000, 'timeout-unmuting'); + APP.store.dispatch({ type: 'SET_VIDEO_MUTED', muted: false, ensureTrack: true }); + } + } catch (e) { + done('error: ' + e.message); + } + """.trimMargin() + ) + } + + // Toggles audio mute and waits for the Redux store to confirm the state change. + // Audio track operations (especially unmuting/re-acquiring the microphone) are async — + // dispatching and returning immediately would not guarantee the toggle completed. + // Unmuting has a longer timeout (12s) than muting (5s) because re-acquiring a device + // is slower than releasing it. On timeout, extra state is returned to help diagnose + // why the change did not complete. + override fun toggleAudioMute(): Any? { + driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(20)) + return driver.executeAsyncScript( + """ + var done = arguments[0]; + try { + var isMuted = APP.conference.isLocalAudioMuted(); + var unsubscribe; + var timer; + var cleanup = function() { clearTimeout(timer); if (unsubscribe) unsubscribe(); }; + + var waitForChange = function(ms, timeoutMsg) { + timer = setTimeout(function() { + cleanup(); + var st = APP.store.getState(); + var at = st['features/base/tracks'].filter(function(t) { return t.local && t.mediaType === 'audio'; }); + done(timeoutMsg + ' mediaMuted=' + st['features/base/media'].audio.muted + + ' audioTracks=' + at.length + ' hasJitsiTrack=' + at.some(function(t) { return !!t.jitsiTrack; })); + }, ms); + unsubscribe = APP.store.subscribe(function() { + var nowMuted = APP.conference.isLocalAudioMuted(); + if (nowMuted !== isMuted) { + cleanup(); + done('ok wasMuted=' + isMuted + ' nowMuted=' + nowMuted); + } + }); + }; + + if (!isMuted) { + // Prefer muting via the track directly so the Promise rejection is catchable. + // Fall back to a Redux dispatch when no track is present yet. + var localAudio = APP.store.getState()['features/base/tracks'] + .find(function(t) { return t.local && t.mediaType === 'audio' && t.jitsiTrack; }); + if (localAudio) { + waitForChange(5000, 'timeout-muting'); + localAudio.jitsiTrack.mute().catch(function(e) { cleanup(); done('error-mute: ' + e); }); + } else { + waitForChange(5000, 'timeout-mute-no-track'); + APP.store.dispatch({ type: 'SET_AUDIO_MUTED', muted: true }); + } + } else { + // ensureTrack tells Jitsi to re-acquire the microphone if no track exists yet. + waitForChange(12000, 'timeout-unmuting'); + APP.store.dispatch({ type: 'SET_AUDIO_MUTED', muted: false, ensureTrack: true }); + } + } catch (e) { + done('error: ' + e.message); + } + """.trimMargin() + ) + } + + override fun raiseHand(): Boolean { + val result = driver.executeScript( + """ + try { + const local = APP.store.getState()['features/base/participants']?.local; + const isRaised = local && local.raisedHandTimestamp > 0; + APP.store.dispatch({ + type: 'LOCAL_PARTICIPANT_RAISE_HAND', + raisedHandTimestamp: isRaised ? 0 : Date.now() + }); + return true; + } catch (e) { + return e.message; + } + """.trimMargin() + ) + return result is Boolean && result + } + + /** + * Add the given key, value pair to the presence map and send a new presence + * message + */ + override fun addToPresence(key: String, value: String): Boolean { + val result = driver.executeScript( + """ + try { + APP.conference._room.room.addOrReplaceInPresence( + arguments[0], + { + value: arguments[1] + } + ); + } catch (e) { + return e.message; + } + """.trimMargin(), + key, + value + ) + return when (result) { + is String -> false + else -> true + } + } + + override fun sendPresence(): Boolean { + val result = driver.executeScript( + """ + try { + APP.conference._room.room.sendPresence(); + } catch (e) { + return e.message; + } + """.trimMargin() + ) + return when (result) { + is String -> false + else -> true + } + } + + override fun leave(): Boolean { + val result = driver.executeScript( + """ + try { + return APP.conference._room.leave(); + } catch (e) { + return e.message; + } + """.trimMargin() + ) + + // Let's wait till we are alone in the room + // (give time for the js Promise to finish before quiting selenium) + WebDriverWait(driver, Duration.ofSeconds(2)).until { + getNumParticipants() == 1 + } + + return when (result) { + is String -> false + else -> true + } + } + + companion object { + /** + * Javascript to apply a filter to the list of participants to exclude ones which should be hidden from jibri. + */ + const val PARTICIPANT_FILTER_SCRIPT = "filter(p => !(p.isHidden() || p.isHiddenFromRecorder()))" + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/CallPage.kt b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/CallPage.kt new file mode 100644 index 0000000..b39d0df --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/CallPage.kt @@ -0,0 +1,66 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.selenium.pageobjects + +import org.jitsi.jibri.CallUrlInfo +import org.jitsi.jibri.config.Config +import org.jitsi.metaconfig.config +import org.openqa.selenium.remote.RemoteWebDriver + +interface CallPage { + fun visit(url: CallUrlInfo): Boolean + fun getNumParticipants(): Int + fun isCallEmpty(): Boolean + fun injectParticipantTrackerScript(): Boolean + fun injectLocalParticipantTrackerScript(): Boolean + fun getParticipants(): List> + fun unmute(): Boolean + fun numRemoteParticipantsJigasi(): Int + fun numHiddenParticipants(): Int + fun isIceConnected(): Boolean + fun isLocalParticipantKicked(): Boolean + fun numRemoteParticipantsMuted(): Int + fun isVisitor(): Boolean + fun isLocalAudioMuted(): Boolean + fun isLocalVideoMuted(): Boolean + fun isAudioForceMuted(): Boolean + fun isVideoForceMuted(): Boolean + fun toggleVideoMute(): Any? + fun toggleAudioMute(): Any? + fun raiseHand(): Boolean + fun addToPresence(key: String, value: String): Boolean + fun sendPresence(): Boolean + fun setParticipantProperties(properties: Map): Boolean { + properties.forEach { (key, value) -> addToPresence(key, value) } + return sendPresence() + } + fun leave(): Boolean + fun getBitrates(): Map + + companion object { + val useExternalAPI: Boolean by config { + "jibri.selenium.use-external-api".from(Config.configSource) + } + + fun create(driver: RemoteWebDriver): CallPage = if (useExternalAPI) { + ExternalAPIPage(driver) + } else { + AppCallPage(driver) + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPage.kt b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPage.kt new file mode 100644 index 0000000..e0865a5 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPage.kt @@ -0,0 +1,443 @@ +package org.jitsi.jibri.selenium.pageobjects + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.jitsi.jibri.CallUrlInfo +import org.jitsi.utils.logging2.createLogger +import org.openqa.selenium.TimeoutException +import org.openqa.selenium.remote.RemoteWebDriver +import org.openqa.selenium.support.PageFactory +import org.openqa.selenium.support.ui.WebDriverWait +import java.io.File +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.time.Duration + +/** CallPage implementation with External API event-driven updates. */ +class ExternalAPIPage(driver: RemoteWebDriver) : AbstractPageObject(driver), CallPage { + private val logger = createLogger() + private val mapper by lazy { jacksonObjectMapper() } + + /** + * The bundled recorder.html, extracted to a local file so we can load it + * directly in the browser instead of relying on the deployment to serve it. + * Extracted lazily and reused for the lifetime of the page object. + */ + private val recorderHtmlFile: File by lazy { extractRecorderHtml() } + + init { + PageFactory.initElements(driver, this) + driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(20)) + } + + override fun visit(url: CallUrlInfo): Boolean { + val room = url.callName.substringAfterLast('/').lowercase() + // The deployment base url is passed to the page so it can source + // external_api.js from the deployment (the page itself is loaded from a + // local file and has no deployment origin of its own). + val baseUrl = url.baseUrl + // `tenant` is only set explicitly for XMPP-invited joins; other callers (e.g. the + // HTTP startService API) only set baseUrl/callName, so fall back to baseUrl's path. + val tenantPath = if (url.tenant.isNotEmpty()) { + url.tenant.replace(".", "/") + } else { + URI(baseUrl).path.trim('/') + } + val recorderUrl = buildString { + append(recorderHtmlFile.toURI().toString()) + append("?room=").append(encode(room)) + append("&baseUrl=").append(encode(baseUrl)) + if (tenantPath.isNotEmpty()) { + append("&tenant=").append(encode(tenantPath)) + } + if (url.urlParams.isNotEmpty()) { + val parsedConfig = parseUrlParams(url.urlParams) + append("&config=").append(encode(mapper.writeValueAsString(parsedConfig))) + } + } + + logger.info("Loading recorder page for room=$room baseUrl=$baseUrl tenant=$tenantPath") + driver.get(recorderUrl) + + return try { + WebDriverWait(driver, Duration.ofSeconds(30)).until { + val apiError = driver.executeScript("return window.jibriPageState?.apiError;") + if (apiError != null) { + throw IllegalStateException("API error: $apiError") + } + val script = "return window.jibriPageState?.conferenceJoined === true;" + val result = driver.executeScript(script) as? Boolean ?: false + result + } + logger.info("Recorder page initialized successfully") + true + } catch (e: TimeoutException) { + logger.error("Failed to join conference: timeout waiting for conferenceJoined event") + false + } catch (e: IllegalStateException) { + logger.error("Failed to join conference: ${e.message}") + false + } + } + + private fun extractRecorderHtml(): File { + val tmpFile = File.createTempFile("jibri-recorder", ".html").apply { deleteOnExit() } + val resource = javaClass.getResourceAsStream("/recorder.html") + ?: throw IllegalStateException("recorder.html not found on the classpath") + resource.use { input -> tmpFile.outputStream().use { input.copyTo(it) } } + return tmpFile + } + + // Parses "config.a.b=value" strings into nested {config: {a: {b: value}}} objects. + private fun parseUrlParams(rawParams: List): Map { + val result = mutableMapOf() + + for (param in rawParams) { + if (param.isEmpty()) continue + + val parts = param.split("=", limit = 2) + if (parts.size != 2) continue + + val (key, rawValue) = parts + if (key.isEmpty() || rawValue.isEmpty()) continue + + val value = try { + mapper.readValue(rawValue, Any::class.java) + } catch (e: Exception) { + rawValue + } + + setNestedValue(result, key, value) + } + + return result + } + + // Recursively build nested map from dotted path: "config.a.b" → map[config][a][b] = value + @Suppress("UNCHECKED_CAST") + private fun setNestedValue(map: MutableMap, path: String, value: Any?) { + val parts = path.split(".") + var current = map + for (i in 0 until parts.size - 1) { + current = (current.getOrPut(parts[i]) { mutableMapOf() } as MutableMap) + } + current[parts.last()] = value + } + + private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + + /** + * Calls [methodName] on window.jibriRecorderApi, guarding against the API or the method + * not being ready yet and any error. [script] can assume `api` and `cb` are already in scope. + */ + private fun callRecorderApiAsync(methodName: String, script: String): Any? = try { + driver.executeAsyncScript( + """ + const cb = arguments[arguments.length - 1]; + const api = window.jibriRecorderApi; + if (!api || typeof api.$methodName !== 'function') { + cb(null); + return; + } + $script + """ + ) + } catch (t: Throwable) { + logger.error("Error calling jibriRecorderApi.$methodName", t) + null + } + + /** Enable the local camera and microphone. Used in sip gateway mode. */ + override fun unmute(): Boolean { + val audioOk = unmuteMedia("audio", "isAudioMuted", "toggleAudio") + val videoOk = unmuteMedia("video", "isVideoMuted", "toggleVideo") + return audioOk && videoOk + } + + private fun unmuteMedia(mediaType: String, isMutedMethod: String, toggleCommand: String): Boolean { + val result = toggleMute(mediaType, isMutedMethod, toggleCommand, requiredInitialState = true) + val success = result is String && result.startsWith("ok") + if (!success) { + logger.error("Failed to unmute $mediaType: $result") + } + return success + } + + private fun executeRecorderCommand(command: String, vararg args: Any?): Boolean = try { + driver.executeScript( + """ + if (!window.jibriRecorderApi) return false; + window.jibriRecorderApi.executeCommand('$command', ...arguments); + return true; + """, + *args + ) as? Boolean ?: false + } catch (t: Throwable) { + logger.error("Error executing recorder command $command", t) + false + } + + override fun getNumParticipants(): Int { + val result = callRecorderApiAsync( + "getRoomsInfo", + """ + api.getRoomsInfo().then(roomsData => { + const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom); + cb(mainRoom?.participants?.length || 1); + }).catch(() => cb(1)); + """ + ) as? Number + val count = result?.toInt() ?: 1 + logger.debug("Number of participants: $count") + return count + } + + override fun isCallEmpty() = getNumParticipants() <= 1 + + @Suppress("UNCHECKED_CAST") + override fun getBitrates(): Map { + val result = callRecorderApiAsync( + "getConnectionStats", + """ + api.getConnectionStats().then(stats => cb(stats || {})).catch(() => cb({})); + """ + ) + val stats = result as? Map ?: mapOf() + return stats["bitrate"] as? Map ?: mapOf() + } + + override fun injectParticipantTrackerScript(): Boolean = true + + override fun injectLocalParticipantTrackerScript(): Boolean = true + + override fun getParticipants(): List> { + val result = callRecorderApiAsync( + "getRoomsInfo", + """ + api.getRoomsInfo().then(roomsData => { + const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom); + const participants = (mainRoom?.participants || []) + .filter(p => p?.userContext?.id || p?.userContext?.name || p?.userContext?.email) + .map(p => ({ user: p.userContext })); + cb(participants); + }).catch(() => cb([])); + """ + ) + @Suppress("UNCHECKED_CAST") + return result as? List> ?: listOf() + } + + override fun numRemoteParticipantsJigasi(): Int { + val result = callRecorderApiAsync( + "getRoomsInfo", + """ + api.getRoomsInfo().then(roomsData => { + const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom); + const numJigasiParticipants = (mainRoom?.participants || []).filter(p => p?.isJigasi === true).length; + cb(numJigasiParticipants); + }).catch(() => cb(0)); + """ + ) as? Number + val numJigasiParticipants = result?.toInt() ?: 0 + logger.debug("Number of Jigasi participants: $numJigasiParticipants") + return numJigasiParticipants + } + + override fun numHiddenParticipants(): Int { + val result = callRecorderApiAsync( + "getRoomsInfo", + """ + api.getRoomsInfo(true).then(roomsData => { + const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom); + const numHidden = (mainRoom?.participants || []).filter(p => p?.isHidden === true).length; + cb(numHidden); + }).catch(() => cb(0)); + """ + ) as? Number + val numHidden = result?.toInt() ?: 0 + logger.debug("Number of hidden participants: $numHidden") + return numHidden + } + + override fun isIceConnected(): Boolean { + val result = callRecorderApiAsync( + "getConnectionStats", + """ + api.getConnectionStats() + .then(stats => cb(stats?.iceConnected === true)) + .catch(() => cb(false)); + """ + ) + val iceConnected = result as? Boolean ?: false + logger.debug("ICE connected: $iceConnected") + return iceConnected + } + + override fun isLocalParticipantKicked(): Boolean = try { + driver.executeScript( + "return window.jibriPageState.localParticipantKicked === true;" + ) as? Boolean ?: false + } catch (t: Throwable) { + logger.error("Error checking isLocalParticipantKicked", t) + false + } + + /** + * Returns a count of how many remote participants are totally muted (audio + * and video). We ignore jigasi participants as they may be muted in their presence + * but also hard muted via the device, and we later ignore their state. + * Note: Excludes hidden participants. + */ + override fun numRemoteParticipantsMuted(): Int { + val result = callRecorderApiAsync( + "getRoomsInfo", + """ + api.getRoomsInfo().then(roomsData => { + const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom); + const numMutedParticipants = (mainRoom?.participants || []).filter(p => { + return p?.audioMuted === true && p?.videoMuted === true && p?.isJigasi !== true; + }).length; + cb(numMutedParticipants); + }).catch(() => cb(0)); + """ + ) as? Number + val numMutedParticipants = result?.toInt() ?: 0 + logger.debug("Number of muted participants: $numMutedParticipants") + return numMutedParticipants + } + + override fun isVisitor(): Boolean = try { + driver.executeScript( + "return window.jibriRecorderApi && window.jibriRecorderApi.isVisitor() === true;" + ) as? Boolean ?: false + } catch (t: Throwable) { + logger.error("Error calling jibriRecorderApi.isVisitor", t) + false + } + + override fun isLocalAudioMuted(): Boolean { + val result = callRecorderApiAsync( + "isAudioMuted", + """ + api.isAudioMuted().then(muted => cb(muted === true)).catch(() => cb(true)); + """ + ) as? Boolean + return result ?: true + } + + override fun isLocalVideoMuted(): Boolean { + val result = callRecorderApiAsync( + "isVideoMuted", + """ + api.isVideoMuted().then(muted => cb(muted === true)).catch(() => cb(true)); + """ + ) as? Boolean + return result ?: true + } + + override fun isAudioForceMuted(): Boolean = isForceMuted("audio") + + override fun isVideoForceMuted(): Boolean = isForceMuted("video") + + /** Returns true if AV moderation is enabled for [mediaType] and the local participant is not approved to unmute. */ + private fun isForceMuted(mediaType: String): Boolean { + val result = callRecorderApiAsync( + "isParticipantForceMuted", + """ + const localId = window.jibriPageState.localParticipantId; + if (!localId) { + cb(false); + return; + } + api.isParticipantForceMuted(localId, '$mediaType').then(forceMuted => cb(forceMuted === true)).catch(() => cb(false)); + """ + ) + return result as? Boolean ?: false + } + + /** + * Toggles audio/video mute, then polls [isMutedMethod] until it + * reflects the change. Unmuting (re-acquiring mic/camera) is slower than muting, so it + * gets more time. Returns the last known state on timeout. + * + * If [requiredInitialState] is set, the current state is checked and the toggle only + * executes if it matches. + */ + private fun toggleMute( + mediaType: String, + isMutedMethod: String, + toggleCommand: String, + requiredInitialState: Boolean? = null, + ): Any? { + val guard = if (requiredInitialState != null) { + "if (initialMuted !== $requiredInitialState) { cb('ok skipped alreadyMuted=' + initialMuted); return; }" + } else { + "" + } + val result = callRecorderApiAsync( + isMutedMethod, + """ + (async () => { + try { + const initialMuted = await api.$isMutedMethod(); + $guard + api.executeCommand('$toggleCommand'); + const deadline = Date.now() + (initialMuted ? 12000 : 5000); + while (Date.now() < deadline) { + const nowMuted = await api.$isMutedMethod(); + if (nowMuted !== initialMuted) { + return cb('ok wasMuted=' + initialMuted + ' nowMuted=' + nowMuted); + } + await new Promise(r => setTimeout(r, 200)); + } + cb('timeout wasMuted=' + initialMuted); + } catch (e) { + cb('error: ' + e); + } + })(); + """ + ) + if (result is String && result.startsWith("ok")) { + logger.debug("Toggle $mediaType mute result: $result") + } else { + logger.warn("Toggle $mediaType mute did not complete cleanly: $result") + } + return result + } + + override fun toggleVideoMute(): Any? = toggleMute("video", "isVideoMuted", "toggleVideo") + + override fun toggleAudioMute(): Any? = toggleMute("audio", "isAudioMuted", "toggleAudio") + + override fun raiseHand(): Boolean = executeRecorderCommand("toggleRaiseHand") + + override fun addToPresence(key: String, value: String): Boolean = setParticipantProperties(mapOf(key to value)) + + override fun sendPresence(): Boolean = true + + override fun leave(): Boolean { + if (!executeRecorderCommand("hangup")) return false + + return try { + // videoConferenceLeft flips conferenceJoined back to false once the XMPP leave completes. + WebDriverWait(driver, Duration.ofSeconds(5)).until { + driver.executeScript("return window.jibriPageState?.conferenceJoined === false;") as? Boolean ?: false + } + true + } catch (e: TimeoutException) { + logger.error("Timed out waiting for videoConferenceLeft after hangup") + false + } + } + + override fun setParticipantProperties(properties: Map): Boolean = try { + val result = executeRecorderCommand("setParticipantProperties", properties, true) + if (!result) { + logger.warn("Could not set participant properties, External API not ready") + } + result + } catch (t: Throwable) { + logger.error("Error setting participant properties", t) + false + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/HomePage.kt b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/HomePage.kt new file mode 100644 index 0000000..3bbe2b1 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/HomePage.kt @@ -0,0 +1,26 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.selenium.pageobjects + +import org.openqa.selenium.remote.RemoteWebDriver + +/** + * This class represents a page object for the home page (i.e. on the domain + * but not in a call) for a jitsi-meet server + */ +class HomePage(driver: RemoteWebDriver) : AbstractPageObject(driver) diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/CallStatusCheck.kt b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/CallStatusCheck.kt new file mode 100644 index 0000000..8cfc8bd --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/CallStatusCheck.kt @@ -0,0 +1,16 @@ +package org.jitsi.jibri.selenium.status_checks + +import org.jitsi.jibri.selenium.SeleniumEvent +import org.jitsi.jibri.selenium.pageobjects.CallPage + +/** + * [CallStatusCheck]s are executed periodically and perform checks via javascripe on the call page to make sure + * everything is running correctly. + */ +interface CallStatusCheck { + /** + * Run a check via [callPage], return a [SeleniumEvent] if something notable has been detected (i.e. an error), + * null if whatever the check is looking for was fine. + */ + fun run(callPage: CallPage): SeleniumEvent? +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/EmptyCallStatusCheck.kt b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/EmptyCallStatusCheck.kt new file mode 100644 index 0000000..ac5b67f --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/EmptyCallStatusCheck.kt @@ -0,0 +1,56 @@ +package org.jitsi.jibri.selenium.status_checks + +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.selenium.SeleniumEvent +import org.jitsi.jibri.selenium.pageobjects.CallPage +import org.jitsi.metaconfig.config +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import java.time.Clock +import java.time.Duration + +/** + * Verify that there are other participants in the call; if the call is + * empty for more than [callEmptyTimeout], then return [SeleniumEvent.CallEmpty]. + * + * NOTE: this class doesn't perform the check automatically, it will only + * check for call empty state when [run] is called. + */ +class EmptyCallStatusCheck( + parentLogger: Logger, + private val callEmptyTimeout: Duration = defaultCallEmptyTimeout, + private val clock: Clock = Clock.systemUTC() +) : CallStatusCheck { + private val logger = createChildLogger(parentLogger) + init { + logger.info("Starting empty call check with a timeout of $callEmptyTimeout") + } + + // The timestamp at which we last saw the call transition from + // non-empty to empty + private val callWentEmptyTime = StateTransitionTimeTracker(clock) + override fun run(callPage: CallPage): SeleniumEvent? { + val now = clock.instant() + callWentEmptyTime.maybeUpdate(callPage.isCallEmpty()) + + return when (callWentEmptyTime.exceededTimeout(callEmptyTimeout)) { + true -> { + logger.info( + "Call has been empty since " + + "${callWentEmptyTime.timestampTransitionOccured} " + + "(${Duration.between(callWentEmptyTime.timestampTransitionOccured, now)} ago). " + + "Returning CallEmpty event" + ) + SeleniumEvent.CallEmpty + } + + false -> null + } + } + + companion object { + val defaultCallEmptyTimeout: Duration by config { + "jibri.call-status-checks.default-call-empty-timeout".from(Config.configSource) + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/IceConnectionStatusCheck.kt b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/IceConnectionStatusCheck.kt new file mode 100644 index 0000000..47efa48 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/IceConnectionStatusCheck.kt @@ -0,0 +1,46 @@ +package org.jitsi.jibri.selenium.status_checks + +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.selenium.SeleniumEvent +import org.jitsi.jibri.selenium.pageobjects.CallPage +import org.jitsi.metaconfig.config +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import java.time.Clock +import java.time.Duration + +/** + * A check for the ICE connection. If ICE is not in the "connected" state for more than [iceConnectionTimeout] then a + * [SeleniumEvent.IceFailedEvent] is fired. + */ +class IceConnectionStatusCheck( + parentLogger: Logger, + private val clock: Clock = Clock.systemDefaultZone() +) : CallStatusCheck { + private val logger = createChildLogger(parentLogger) + + // The last timestamp when ICE was connected. Initialized to give the same timeout for the initial connection. + private var timeOfLastSuccess = clock.instant() + + override fun run(callPage: CallPage): SeleniumEvent? { + val now = clock.instant() + + if (callPage.isCallEmpty() || callPage.isIceConnected()) { + // If there are no other participants we don't expect to have an ICE connection. + timeOfLastSuccess = now + return null + } + + if (Duration.between(timeOfLastSuccess, now) > iceConnectionTimeout) { + logger.warn("ICE has failed and not recovered in $iceConnectionTimeout.") + return SeleniumEvent.IceFailedEvent + } + return null + } + + companion object { + val iceConnectionTimeout: Duration by config { + "jibri.call-status-checks.ice-connection-timeout".from(Config.configSource) + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/LocalParticipantKickedStatusCheck.kt b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/LocalParticipantKickedStatusCheck.kt new file mode 100644 index 0000000..ad014a7 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/LocalParticipantKickedStatusCheck.kt @@ -0,0 +1,23 @@ +package org.jitsi.jibri.selenium.status_checks + +import org.jitsi.jibri.selenium.SeleniumEvent +import org.jitsi.jibri.selenium.pageobjects.CallPage +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger + +class LocalParticipantKickedStatusCheck( + parentLogger: Logger +) : CallStatusCheck { + private val logger = createChildLogger(parentLogger) + + init { + logger.info("Starting local participant kicked out call check") + } + + override fun run(callPage: CallPage): SeleniumEvent? = if (callPage.isLocalParticipantKicked()) { + logger.info("Local participant was kicked, returning LocalParticipantKicked event") + SeleniumEvent.LocalParticipantKicked + } else { + null + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/MediaReceivedStatusCheck.kt b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/MediaReceivedStatusCheck.kt new file mode 100644 index 0000000..935cd77 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/MediaReceivedStatusCheck.kt @@ -0,0 +1,90 @@ +package org.jitsi.jibri.selenium.status_checks + +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.selenium.SeleniumEvent +import org.jitsi.jibri.selenium.pageobjects.CallPage +import org.jitsi.metaconfig.config +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import java.time.Clock +import java.time.Duration + +/** + * Verify that the Jibri web client is receiving media from the other participants. There's two different cases: + * 1. If some participants have audio and/or video enabled (as advertised in signaling). In this case, if jibri + * receives no audio or video for [noMediaTimeout] it will fire a [SeleniumEvent.NoMediaReceived]. This should not + * happen in normal circumstances because if anyone is sending media, it should be forwarded to and received by jibri, + * and we have a separate [IceConnectionStatusCheck] for the connection to the bridge. The check is here as a safety + * net. + * 2. No participant has audio or video enabled (as advertised in signaling). In this case we timeout after + * [allMutedTimeout] and fire a [SeleniumEvent.CallEmpty] to avoid wasting resources. + */ +class MediaReceivedStatusCheck( + parentLogger: Logger, + private val clock: Clock = Clock.systemDefaultZone() +) : CallStatusCheck { + private val logger = createChildLogger(parentLogger) + + // The last timestamp where we saw non-zero media. We default with the + // assumption we're receiving media. + private var timeOfLastMedia = clock.instant() + + // The timestamp at which we last saw that all clients transitioned to muted + private val clientsAllMutedTransitionTime = StateTransitionTimeTracker(clock) + + override fun run(callPage: CallPage): SeleniumEvent? { + val now = clock.instant() + val bitrates = callPage.getBitrates() + // getNumParticipants includes Jibri, so subtract 1 + val numParticipants = callPage.getNumParticipants() - 1 + val numMutedParticipants = callPage.numRemoteParticipantsMuted() + val numJigasiParticipants = callPage.numRemoteParticipantsJigasi() + val numHiddenParticipants = callPage.numHiddenParticipants() + // We don't get any mute state for Jigasi participants, so to prevent timing out when only Jigasi participants + // may be speaking, always count them as "muted" + val allClientsMuted = (numMutedParticipants + numJigasiParticipants) == numParticipants + logger.info( + "Jibri client receive bitrates: $bitrates, num participants: $numParticipants, " + + "numMutedParticipants: $numMutedParticipants, numJigasis: $numJigasiParticipants, " + + "numHiddenParticipants: $numHiddenParticipants, all clients muted? $allClientsMuted" + ) + clientsAllMutedTransitionTime.maybeUpdate(allClientsMuted) + val downloadBitrate = bitrates.getOrDefault("download", 0L) as Long + // If all clients are muted, register it as 'receiving media': that way when clients unmute + // we'll get the full noMediaTimeout duration before timing out due to lack of media. + if (downloadBitrate != 0L || allClientsMuted) { + timeOfLastMedia = now + } + val timeSinceLastMedia = Duration.between(timeOfLastMedia, now) + + // There are a couple possible outcomes here: + // 1) All clients are muted, but have been muted for longer than allMutedTimeout so + // we'll exit the call gracefully (CallEmpty) + // 2) No media has flowed for longer than noMediaTimeout and all clients are not + // muted so we'll exit with an error (NoMediaReceived) + // 3) If neither of the above are true, we're fine and no event has occurred + return when { + clientsAllMutedTransitionTime.exceededTimeout(allMutedTimeout) -> SeleniumEvent.CallEmpty + timeSinceLastMedia > noMediaTimeout && !allClientsMuted -> SeleniumEvent.NoMediaReceived + else -> null + } + } + + companion object { + /** + * How long we'll stay in the call if we're not receiving any incoming media (assuming not all participants + * not muted). This should be long enough to allow for a participant losing their connection and re-connecting + * without restarting the recording. + */ + val noMediaTimeout: Duration by config { + "jibri.call-status-checks.no-media-timeout".from(Config.configSource) + } + + /** + * How long we'll stay in the call if all participants are muted + */ + val allMutedTimeout: Duration by config { + "jibri.call-status-checks.all-muted-timeout".from(Config.configSource) + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/StateTransitionTimeTracker.kt b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/StateTransitionTimeTracker.kt new file mode 100644 index 0000000..1f6f0b2 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/status_checks/StateTransitionTimeTracker.kt @@ -0,0 +1,44 @@ +/* + * 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.jibri.selenium.status_checks + +import java.time.Clock +import java.time.Duration +import java.time.Instant + +/** + * Track the most recent timestamp at which we transitioned from + * an event having not occurred to when it did occur. Note this + * tracks the timestamp of that *transition*, not the most recent + * time the event itself occurred. + */ +class StateTransitionTimeTracker(private val clock: Clock) { + var timestampTransitionOccured: Instant? = null + private set + + fun maybeUpdate(eventOccurred: Boolean) { + if (eventOccurred && timestampTransitionOccured == null) { + timestampTransitionOccured = clock.instant() + } else if (!eventOccurred) { + timestampTransitionOccured = null + } + } + + fun exceededTimeout(timeout: Duration): Boolean = timestampTransitionOccured?.let { + Duration.between(it, clock.instant()) > timeout + } ?: false +} diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/util/BrowserFileHandler.kt b/src/main/kotlin/org/jitsi/jibri/selenium/util/BrowserFileHandler.kt new file mode 100644 index 0000000..455ecc7 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/selenium/util/BrowserFileHandler.kt @@ -0,0 +1,21 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.selenium.util + +import java.util.logging.FileHandler + +class BrowserFileHandler : FileHandler() diff --git a/src/main/kotlin/org/jitsi/jibri/service/Errors.kt b/src/main/kotlin/org/jitsi/jibri/service/Errors.kt new file mode 100644 index 0000000..1579b49 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/service/Errors.kt @@ -0,0 +1,26 @@ + + +/* + * 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. + */ + +@file:Suppress("ktlint:standard:filename") + +package org.jitsi.jibri.service + +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.status.ErrorScope + +object ErrorSettingPresenceFields : JibriError(ErrorScope.SESSION, "Unable to set presence values") diff --git a/src/main/kotlin/org/jitsi/jibri/service/JibriService.kt b/src/main/kotlin/org/jitsi/jibri/service/JibriService.kt new file mode 100644 index 0000000..f07247d --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/service/JibriService.kt @@ -0,0 +1,70 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.service + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.StatusPublisher + +enum class JibriServiceStatus { + FINISHED, + ERROR +} + +/** + * Arbitrary Jibri specific data that can be passed in the + * [JibriIq#appData] field. This entire structure will be parsed + * from a JSON-encoded string (the [JibriIq#appData] field). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class AppData( + /** + * A JSON map representing arbitrary data to be written + * to the metadata file when doing a recording. + */ + @JsonProperty("file_recording_metadata") + val fileRecordingMetadata: Map? +) + +/** + * Parameters needed for starting any [JibriService] + */ +data class ServiceParams( + val usageTimeoutMinutes: Int, + val appData: AppData? = null +) + +typealias JibriServiceStatusHandler = (ComponentState) -> Unit + +/** + * Interface implemented by all implemented [JibriService]s. A [JibriService] + * is responsible for an entire feature of Jibri, such as recording a call + * to a file or streaming a call to a url. + */ +abstract class JibriService : StatusPublisher() { + /** + * Starts this [JibriService] + */ + abstract fun start() + + /** + * Stops this [JibriService] + */ + abstract fun stop() +} diff --git a/src/main/kotlin/org/jitsi/jibri/service/JibriServiceFinalizer.kt b/src/main/kotlin/org/jitsi/jibri/service/JibriServiceFinalizer.kt new file mode 100644 index 0000000..c1f1037 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/service/JibriServiceFinalizer.kt @@ -0,0 +1,22 @@ +/* + * 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.jibri.service + +interface JibriServiceFinalizer { + + fun doFinalize() +} diff --git a/src/main/kotlin/org/jitsi/jibri/service/JibriServiceStateMachine.kt b/src/main/kotlin/org/jitsi/jibri/service/JibriServiceStateMachine.kt new file mode 100644 index 0000000..bb26325 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/service/JibriServiceStateMachine.kt @@ -0,0 +1,111 @@ +/* + * 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.jibri.service + +import com.tinder.StateMachine +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.NotifyingStateMachine + +sealed class JibriServiceEvent { + class SubComponentStartingUp(val componentId: String, val subState: ComponentState.StartingUp) : JibriServiceEvent() + class SubComponentRunning(val componentId: String, val subState: ComponentState.Running) : JibriServiceEvent() + class SubComponentError(val componentId: String, val subState: ComponentState.Error) : JibriServiceEvent() + class SubComponentFinished(val componentId: String, val subState: ComponentState.Finished) : JibriServiceEvent() +} + +fun ComponentState.toJibriServiceEvent(componentId: String): JibriServiceEvent = when (this) { + is ComponentState.StartingUp -> JibriServiceEvent.SubComponentStartingUp(componentId, this) + is ComponentState.Running -> JibriServiceEvent.SubComponentRunning(componentId, this) + is ComponentState.Error -> JibriServiceEvent.SubComponentError(componentId, this) + is ComponentState.Finished -> JibriServiceEvent.SubComponentFinished(componentId, this) +} + +sealed class JibriServiceSideEffect + +/** + * A state machine for the services to use to handle status updates from their subcomponents. Subcomponents should + * be registered via [JibriServiceStateMachine.registerSubComponent] such that the [JibriService]'s overall state + * can be computed as a function of the subcomponents' states. + */ +class JibriServiceStateMachine : NotifyingStateMachine() { + private val stateMachine = StateMachine.create { + initialState(ComponentState.StartingUp) + + state { + on { + subComponentStates[it.componentId] = it.subState + transitionTo(ComponentState.Error(it.subState.error)) + } + on { + subComponentStates[it.componentId] = it.subState + transitionTo(ComponentState.Finished) + } + on { + subComponentStates[it.componentId] = it.subState + if (subComponentStates.values.all { it is ComponentState.Running }) { + transitionTo(ComponentState.Running) + } else { + dontTransition() + } + } + on { + dontTransition() + } + } + + state { + on { + subComponentStates[it.componentId] = it.subState + transitionTo(ComponentState.Error(it.subState.error)) + } + on { + subComponentStates[it.componentId] = it.subState + transitionTo(ComponentState.Finished) + } + } + + state { + on(any()) { + dontTransition() + } + } + + state { + on(any()) { + dontTransition() + } + } + + onTransition { + val validTransition = it as? StateMachine.Transition.Valid ?: run { + throw Exception("Invalid state transition: $it") + } + if (validTransition.fromState::class != validTransition.toState::class) { + notify(validTransition.fromState, validTransition.toState) + } + } + } + + private val subComponentStates = mutableMapOf() + + fun registerSubComponent(componentKey: String) { + // TODO: we'll assume everything starts in 'starting up' ? + subComponentStates[componentKey] = ComponentState.StartingUp + } + + fun transition(event: JibriServiceEvent): StateMachine.Transition<*, *, *> = stateMachine.transition(event) +} diff --git a/src/main/kotlin/org/jitsi/jibri/service/impl/FileRecordingJibriService.kt b/src/main/kotlin/org/jitsi/jibri/service/impl/FileRecordingJibriService.kt new file mode 100644 index 0000000..30f8df8 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/service/impl/FileRecordingJibriService.kt @@ -0,0 +1,252 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.service.impl + +import com.fasterxml.jackson.annotation.JsonAnyGetter +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.jitsi.jibri.capture.ffmpeg.FfmpegCapturer +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.config.XmppCredentials +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.selenium.CallParams +import org.jitsi.jibri.selenium.JibriSelenium +import org.jitsi.jibri.selenium.RECORDING_URL_OPTIONS +import org.jitsi.jibri.service.ErrorSettingPresenceFields +import org.jitsi.jibri.service.JibriService +import org.jitsi.jibri.service.JibriServiceFinalizer +import org.jitsi.jibri.sink.Sink +import org.jitsi.jibri.sink.impl.FileSink +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.status.ErrorScope +import org.jitsi.jibri.util.ProcessFactory +import org.jitsi.jibri.util.createIfDoesNotExist +import org.jitsi.jibri.util.whenever +import org.jitsi.metaconfig.config +import org.jitsi.xmpp.extensions.jibri.JibriIq +import java.nio.file.FileSystem +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.StandardOpenOption + +/** + * Parameters needed for starting a [FileRecordingJibriService] + */ +data class FileRecordingParams( + /** + * Which call we'll join + */ + val callParams: CallParams, + /** + * The ID of this session + */ + val sessionId: String, + /** + * The login information needed to appear invisible in + * the call + */ + val callLoginParams: XmppCredentials, + /** + * A map of arbitrary key, value metadata that will be written + * to the metadata file. + */ + val additionalMetadata: Map? = null +) + +/** + * Set of metadata we'll put alongside the recording file(s) + */ +data class RecordingMetadata( + @JsonProperty("meeting_url") + val meetingUrl: String, + val participants: List>, + @JsonIgnore val additionalMetadata: Map? = null +) { + /** + * We tell the JSON serializer to ignore the additionalMetadata map (above) + * and use this to expose each of its individual fields, that way they + * are serialized at the top level (rather than being nested within a + * 'additionalMetadata' JSON object) + */ + @JsonAnyGetter + fun get(): Map? = additionalMetadata +} + +/** + * [FileRecordingJibriService] is the [JibriService] responsible for joining + * a web call, capturing its audio and video, and writing that audio and video + * to a file to be replayed later. + */ +class FileRecordingJibriService( + private val fileRecordingParams: FileRecordingParams, + jibriSelenium: JibriSelenium? = null, + capturer: FfmpegCapturer? = null, + processFactory: ProcessFactory = ProcessFactory(), + fileSystem: FileSystem = FileSystems.getDefault(), + private var jibriServiceFinalizer: JibriServiceFinalizer? = null +) : StatefulJibriService("File recording") { + init { + logger.addContext("session_id", fileRecordingParams.sessionId) + } + + private val capturer = capturer ?: FfmpegCapturer(logger) + private val jibriSelenium = jibriSelenium ?: JibriSelenium(logger) + + /** + * The [Sink] this class will use to model the file on the filesystem + */ + private var sink: FileSink + private val recordingsDirectory: String by config { + "JibriConfig::recordingDirectory" { Config.legacyConfigSource.recordingDirectory!! } + "jibri.recording.recordings-directory".from(Config.configSource) + } + private val finalizeScriptPath: String by config { + "JibriConfig::finalizeRecordingScriptPath" { + Config.legacyConfigSource.finalizeRecordingScriptPath!! + } + "jibri.recording.finalize-script".from(Config.configSource) + } + + /** + * The directory in which we'll store recordings for this particular session. This is a directory that will + * be nested within [recordingsDirectory]. + */ + private val sessionRecordingDirectory = + fileSystem.getPath(recordingsDirectory).resolve(sanitizeSessionId(fileRecordingParams.sessionId)) + + /** + * Sanitize the session ID to ensure it's safe for use as a directory name. + */ + private fun sanitizeSessionId(sessionId: String): String { + // Replace any character that is not alphanumeric, dash, or underscore with underscore + return sessionId.replace(Regex("[^a-zA-Z0-9_-]"), "_") + } + + init { + logger.info("Writing recording to $sessionRecordingDirectory, finalize script path $finalizeScriptPath") + sink = FileSink( + sessionRecordingDirectory, + fileRecordingParams.callParams.callUrlInfo.callName + ) + + registerSubComponent(JibriSelenium.COMPONENT_ID, this.jibriSelenium) + registerSubComponent(FfmpegCapturer.COMPONENT_ID, this.capturer) + + jibriServiceFinalizer = JibriServiceFinalizeCommandRunner( + processFactory, + listOf( + finalizeScriptPath, + sessionRecordingDirectory.toString() + ) + ) + } + + override fun start() { + if (!createIfDoesNotExist(sessionRecordingDirectory, logger)) { + publishStatus(ComponentState.Error(ErrorCreatingRecordingsDirectory)) + return + } + if (!Files.isWritable(sessionRecordingDirectory)) { + logger.error("Unable to write to $recordingsDirectory") + publishStatus(ComponentState.Error(RecordingsDirectoryNotWritable)) + return + } + jibriSelenium.joinCall( + fileRecordingParams.callParams.callUrlInfo.copy( + urlParams = RECORDING_URL_OPTIONS + fileRecordingParams.callParams.extraUrlParams + ), + fileRecordingParams.callLoginParams + ) + + whenever(jibriSelenium).transitionsTo(ComponentState.Running) { + logger.info("Selenium joined the call, starting the capturer") + try { + jibriSelenium.setParticipantProperties( + mapOf( + "session_id" to fileRecordingParams.sessionId, + "mode" to JibriIq.RecordingMode.FILE.toString() + ) + ) + capturer.start(sink) + } catch (t: Throwable) { + logger.error("Error while setting fields in presence", t) + publishStatus(ComponentState.Error(ErrorSettingPresenceFields)) + } + } + } + + override fun stop() { + logger.info("Stopping capturer") + capturer.stop() + logger.info("Quitting selenium") + + // It's possible that the service was stopped before we even wrote anything, so check if we actually wrote + // any data to disk. If not, we'll skip writing the metadata and running the finalize script and instead + // just delete the directory + val recordedMedia = Files.exists(sink.file) + if (!recordedMedia) { + logger.info("No media was recorded, deleting directory and skipping metadata file & finalize") + try { + Files.delete(sessionRecordingDirectory) + } catch (t: Throwable) { + logger.error("Problem deleting session recording directory", t) + } + jibriSelenium.leaveCallAndQuitBrowser() + return + } + + val participants = try { + jibriSelenium.getParticipants() + } catch (t: Throwable) { + logger.error( + "An error occurred while trying to get the participants list, proceeding with " + + "an empty participants list", + t + ) + listOf>() + } + logger.info("Participants in this recording: $participants") + if (Files.isWritable(sessionRecordingDirectory)) { + val metadataFile = sessionRecordingDirectory.resolve("metadata.json") + val metadata = RecordingMetadata( + fileRecordingParams.callParams.callUrlInfo.callUrl, + participants, + fileRecordingParams.additionalMetadata + ) + try { + Files.newBufferedWriter(metadataFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + .use { + jacksonObjectMapper().writeValue(it, metadata) + } + } catch (t: Throwable) { + logger.error("Error writing metadata", t) + publishStatus(ComponentState.Error(CouldntWriteMeetingMetadata)) + } + } else { + logger.error("Unable to write metadata file to recording directory $recordingsDirectory") + } + jibriSelenium.leaveCallAndQuitBrowser() + logger.info("Finalizing the recording") + jibriServiceFinalizer?.doFinalize() + } +} + +object ErrorCreatingRecordingsDirectory : JibriError(ErrorScope.SYSTEM, "Could not creat recordings director") +object RecordingsDirectoryNotWritable : JibriError(ErrorScope.SYSTEM, "Recordings directory is not writable") +object CouldntWriteMeetingMetadata : JibriError(ErrorScope.SYSTEM, "Could not write meeting metadata") diff --git a/src/main/kotlin/org/jitsi/jibri/service/impl/JibriServiceFinalizeCommandRunner.kt b/src/main/kotlin/org/jitsi/jibri/service/impl/JibriServiceFinalizeCommandRunner.kt new file mode 100644 index 0000000..c283347 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/service/impl/JibriServiceFinalizeCommandRunner.kt @@ -0,0 +1,66 @@ +/* + * 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.jibri.service.impl + +import org.jitsi.jibri.service.JibriServiceFinalizer +import org.jitsi.jibri.util.LoggingUtils +import org.jitsi.jibri.util.ProcessFactory +import org.jitsi.utils.logging2.createLogger +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +class JibriServiceFinalizeCommandRunner( + private val processFactory: ProcessFactory = ProcessFactory(), + private val finalizeCommand: List +) : JibriServiceFinalizer { + + private val logger = createLogger() + + /** + * Helper to execute the finalize script and wait for its completion. + * NOTE that this will block for however long the finalize script takes + * to complete (by design) + */ + override fun doFinalize() { + if (finalizeCommand.isEmpty()) { + logger.debug("Finalize command is empty, there is nothing to be run") + return + } + + logger.info("Finalizing the jibri service operation using command $finalizeCommand") + try { + with(processFactory.createProcess(finalizeCommand, logger)) { + start() + val streamDone = LoggingUtils.logOutputOfProcess(this, logger) + waitFor() + // Make sure we get all the logs + try { + streamDone.get(10, TimeUnit.SECONDS) + } catch (e: TimeoutException) { + logger.error("Timed out waiting for process logger task to complete") + streamDone.cancel(true) + } catch (e: Exception) { + logger.error("Exception while waiting for process logger task to complete", e) + streamDone.cancel(true) + } + logger.info("Finalize script finished with exit value $exitValue") + } + } catch (e: Exception) { + logger.error("Failed to run finalize script", e) + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/service/impl/SipGatewayJibriService.kt b/src/main/kotlin/org/jitsi/jibri/service/impl/SipGatewayJibriService.kt new file mode 100644 index 0000000..b4e712c --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/service/impl/SipGatewayJibriService.kt @@ -0,0 +1,182 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.service.impl + +import org.jitsi.jibri.config.XmppCredentials +import org.jitsi.jibri.selenium.CallParams +import org.jitsi.jibri.selenium.JibriSelenium +import org.jitsi.jibri.selenium.JibriSeleniumOptions +import org.jitsi.jibri.selenium.SIP_GW_URL_OPTIONS +import org.jitsi.jibri.service.ErrorSettingPresenceFields +import org.jitsi.jibri.service.JibriService +import org.jitsi.jibri.service.JibriServiceFinalizer +import org.jitsi.jibri.sipgateway.SipClientParams +import org.jitsi.jibri.sipgateway.getSipAddress +import org.jitsi.jibri.sipgateway.getSipScheme +import org.jitsi.jibri.sipgateway.pjsua.PjsuaClient +import org.jitsi.jibri.sipgateway.pjsua.PjsuaClientParams +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.ProcessFactory +import org.jitsi.jibri.util.whenever +import java.time.Duration +import java.util.concurrent.ScheduledFuture + +data class SipGatewayServiceParams( + /** + * The params needed to join the web call + */ + val callParams: CallParams, + /** + * The login information needed to use when establishing the call + */ + val callLoginParams: XmppCredentials?, + /** + * The params needed for bringing a SIP client into + * the call + */ + val sipClientParams: SipClientParams +) + +private const val FINALIZE_SCRIPT_LOCATION = "/opt/jitsi/jibri/finalize_sip.sh" + +/** + * A [JibriService] responsible for joining both a web call + * and a SIP call, capturing the audio and video from each, and + * forwarding them to the other side. + */ +class SipGatewayJibriService( + private val sipGatewayServiceParams: SipGatewayServiceParams, + jibriSelenium: JibriSelenium? = null, + pjsuaClient: PjsuaClient? = null, + processFactory: ProcessFactory = ProcessFactory(), + private val jibriServiceFinalizer: JibriServiceFinalizer = JibriServiceFinalizeCommandRunner( + processFactory, + listOf(FINALIZE_SCRIPT_LOCATION) + ) +) : StatefulJibriService("SIP gateway") { + /** + * Used for the selenium interaction + */ + private val jibriSelenium = jibriSelenium ?: JibriSelenium( + logger, + JibriSeleniumOptions( + displayName = if (sipGatewayServiceParams.callParams.displayName.isNotBlank()) { + sipGatewayServiceParams.callParams.displayName + } else if (sipGatewayServiceParams.sipClientParams.sipAddress.isNotBlank()) { + sipGatewayServiceParams.sipClientParams.sipAddress.substringBeforeLast("@") + } else { + sipGatewayServiceParams.sipClientParams.displayName + }, + email = sipGatewayServiceParams.callParams.email, + callStatsUsernameOverride = sipGatewayServiceParams.callParams.callStatsUsernameOverride, + // by default we wait 30 minutes alone in the call before deciding to hangup + emptyCallTimeout = Duration.ofMinutes(30), + extraChromeCommandLineFlags = listOf("--alsa-input-device=plughw:1,1"), + enableLocalParticipantStatusChecks = true + ) + ) + + /** + * The SIP client we'll use to connect to the SIP call (currently only a + * pjsua implementation exists) + */ + private val pjsuaClient = pjsuaClient ?: PjsuaClient( + logger, + PjsuaClientParams(sipGatewayServiceParams.sipClientParams), + onDtmfCommand = { command -> + when (command) { + "*6" -> { + logger.info("Received *6 DTMF command from pjsua") + this.jibriSelenium.handleDtmfStar6() + } + + "*7" -> { + logger.info("Received *7 DTMF command from pjsua") + this.jibriSelenium.handleDtmfStar7() + } + } + } + ) + + /** + * The handle to the scheduled process monitor task, which we use to + * cancel the task + */ + private var processMonitorTask: ScheduledFuture<*>? = null + + init { + registerSubComponent(JibriSelenium.COMPONENT_ID, this.jibriSelenium) + registerSubComponent(PjsuaClient.COMPONENT_ID, this.pjsuaClient) + } + + /** + * Starting a [SipGatewayServiceParams] involves the following steps: + * 1) Start selenium and join the web call on display :0 + * 2) Start the SIP client to join the SIP call on display :1 + * There are already ffmpeg daemons running which are capturing from + * each of the displays and writing to video devices which selenium + * and pjsua will use + */ + override fun start() { + jibriSelenium.joinCall( + sipGatewayServiceParams.callParams.callUrlInfo.copy( + urlParams = SIP_GW_URL_OPTIONS + sipGatewayServiceParams.callParams.extraUrlParams + ), + sipGatewayServiceParams.callLoginParams, + sipGatewayServiceParams.callParams.passcode, + unmute = true + ) + + // when in auto-answer mode we want to start as quick as possible as + // we will be waiting for a sip call to come + if (sipGatewayServiceParams.sipClientParams.autoAnswer) { + pjsuaClient.start() + whenever(jibriSelenium).transitionsTo(ComponentState.Running) { + logger.info("Selenium joined the call") + addSipMetadataToPresence() + } + } else { + whenever(jibriSelenium).transitionsTo(ComponentState.Running) { + logger.info("Selenium joined the call, starting pjsua") + pjsuaClient.start() + addSipMetadataToPresence() + } + } + } + + private fun addSipMetadataToPresence() { + try { + val sipAddress = sipGatewayServiceParams.sipClientParams.sipAddress.getSipAddress() + val sipScheme = sipGatewayServiceParams.sipClientParams.sipAddress.getSipScheme() + if (!jibriSelenium.setParticipantProperties(mapOf("sip_address" to "$sipScheme:$sipAddress"))) { + logger.error("Error while setting SIP address in presence") + publishStatus(ComponentState.Error(ErrorSettingPresenceFields)) + } + } catch (t: Throwable) { + logger.error("Error while setting fields in presence", t) + publishStatus(ComponentState.Error(ErrorSettingPresenceFields)) + } + } + + override fun stop() { + processMonitorTask?.cancel(false) + pjsuaClient.stop() + jibriSelenium.leaveCallAndQuitBrowser() + jibriServiceFinalizer.doFinalize() + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/service/impl/StatefulJibriService.kt b/src/main/kotlin/org/jitsi/jibri/service/impl/StatefulJibriService.kt new file mode 100644 index 0000000..54a4669 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/service/impl/StatefulJibriService.kt @@ -0,0 +1,49 @@ +/* + * 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.jibri.service.impl + +import org.jitsi.jibri.service.JibriService +import org.jitsi.jibri.service.JibriServiceStateMachine +import org.jitsi.jibri.service.toJibriServiceEvent +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.StatusPublisher +import org.jitsi.utils.logging2.createLogger + +abstract class StatefulJibriService(private val name: String) : JibriService() { + private val stateMachine = JibriServiceStateMachine() + + /** + * The [Logger] for this class + */ + protected val logger = createLogger() + + init { + stateMachine.onStateTransition { oldState, newState -> this.onServiceStateChange(oldState, newState) } + } + + private fun onServiceStateChange(oldState: ComponentState, newState: ComponentState) { + logger.info("$name service transitioning from state $oldState to $newState") + publishStatus(newState) + } + + protected fun registerSubComponent(subComponentId: String, subComponent: StatusPublisher) { + stateMachine.registerSubComponent(subComponentId) + subComponent.addStatusHandler { stateUpdate -> + stateMachine.transition(stateUpdate.toJibriServiceEvent(subComponentId)) + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/service/impl/StreamingJibriService.kt b/src/main/kotlin/org/jitsi/jibri/service/impl/StreamingJibriService.kt new file mode 100644 index 0000000..0dec533 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/service/impl/StreamingJibriService.kt @@ -0,0 +1,143 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.service.impl + +import org.jitsi.jibri.capture.ffmpeg.FfmpegCapturer +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.config.XmppCredentials +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.selenium.CallParams +import org.jitsi.jibri.selenium.JibriSelenium +import org.jitsi.jibri.selenium.RECORDING_URL_OPTIONS +import org.jitsi.jibri.service.ErrorSettingPresenceFields +import org.jitsi.jibri.service.JibriService +import org.jitsi.jibri.sink.Sink +import org.jitsi.jibri.sink.impl.StreamSink +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.status.ErrorScope +import org.jitsi.jibri.util.whenever +import org.jitsi.metaconfig.config +import org.jitsi.xmpp.extensions.jibri.JibriIq +import java.util.regex.Pattern + +const val YOUTUBE_URL = "rtmp://a.rtmp.youtube.com/live2" + +/** + * Parameters needed for starting a [StreamingJibriService] + */ +data class StreamingParams( + /** + * Which call we'll join + */ + val callParams: CallParams, + /** + * The ID of this session + */ + val sessionId: String, + /** + * The login information needed to appear invisible in + * the call + */ + val callLoginParams: XmppCredentials, + /** + * The RTMP URL we'll stream to + */ + val rtmpUrl: String, + /** + * The URL at which the stream can be viewed + */ + val viewingUrl: String? = null +) + +/** + * [StreamingJibriService] is the [JibriService] responsible for joining a + * web call, capturing its audio and video, and streaming that audio and video + * to a url + */ +class StreamingJibriService( + private val streamingParams: StreamingParams +) : StatefulJibriService("Streaming") { + init { + logger.addContext("session_id", streamingParams.sessionId) + } + private val capturer = FfmpegCapturer(logger) + private val sink: Sink + private val jibriSelenium = JibriSelenium(logger) + + private val rtmpAllowList: List by config { + "jibri.streaming.rtmp-allow-list".from(Config.configSource) + .convertFrom> { it.map(Pattern::compile) } + } + + init { + sink = StreamSink(url = streamingParams.rtmpUrl) + + registerSubComponent(JibriSelenium.COMPONENT_ID, jibriSelenium) + registerSubComponent(FfmpegCapturer.COMPONENT_ID, capturer) + } + + override fun start() { + if (rtmpAllowList.none { it.matcher(streamingParams.rtmpUrl).matches() }) { + logger.error("RTMP url ${streamingParams.rtmpUrl} is not allowed") + publishStatus( + ComponentState.Error( + JibriError( + ErrorScope.SESSION, + "RTMP URL ${streamingParams.rtmpUrl} is not allowed" + ) + ) + ) + return + } + jibriSelenium.joinCall( + streamingParams.callParams.callUrlInfo.copy( + urlParams = RECORDING_URL_OPTIONS + streamingParams.callParams.extraUrlParams + ), + streamingParams.callLoginParams + ) + + whenever(jibriSelenium).transitionsTo(ComponentState.Running) { + logger.info("Selenium joined the call, starting capturer") + try { + val properties = mutableMapOf( + "session_id" to streamingParams.sessionId, + "mode" to JibriIq.RecordingMode.STREAM.toString() + ) + streamingParams.viewingUrl?.let { viewingUrl -> + properties["live-stream-view-url"] = viewingUrl + } + if (!jibriSelenium.setParticipantProperties(properties)) { + logger.error("Error setting presence properties") + } + capturer.start(sink) + } catch (t: Throwable) { + logger.error("Error while setting fields in presence", t) + publishStatus(ComponentState.Error(ErrorSettingPresenceFields)) + } + } + } + + override fun stop() { + logger.info("Stopping capturer") + capturer.stop() + logger.info("Stopped capturer") + logger.info("Quitting selenium") + jibriSelenium.leaveCallAndQuitBrowser() + logger.info("Quit selenium") + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/sink/Sink.kt b/src/main/kotlin/org/jitsi/jibri/sink/Sink.kt new file mode 100644 index 0000000..d45ad07 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/sink/Sink.kt @@ -0,0 +1,32 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.sink + +/** + * [Sink] describes a class which data will be 'written to'. It contains + * a destination (via [path]), a format (via [format]) and a set + * of options which each [Sink] implementation may provide. + * TODO: currently this is modeled as generic, but really it's an + * "FfmpegSink", so maybe it should be named as such? + */ +interface Sink { + /** + * The path to which this [Sink] has been designated to write + */ + val path: String +} diff --git a/src/main/kotlin/org/jitsi/jibri/sink/impl/FileSink.kt b/src/main/kotlin/org/jitsi/jibri/sink/impl/FileSink.kt new file mode 100644 index 0000000..92fc5b1 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/sink/impl/FileSink.kt @@ -0,0 +1,49 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.sink.impl + +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.sink.Sink +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.from +import java.nio.file.Path +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * [FileSink] represents a sink which will write to a media file on the + * filesystem. A maximum length of 125 characters is enforced for the filename. + * NOTE: I considered letting the maximum length be configurable, but we require that we + * are able to append a timestamp to the filename, so we can't give the caller full control + * over the value anyway. Because of that I just made the value hard-coded. + */ +class FileSink(recordingsDirectory: Path, callName: String) : Sink { + val file: Path + init { + val suffix = "_${LocalDateTime.now().format(TIMESTAMP_FORMATTER)}.$recordingExtension" + val filename = "${callName.take(MAX_FILENAME_LENGTH - suffix.length)}$suffix" + file = recordingsDirectory.resolve(filename) + } + override val path: String = file.toString() + + companion object { + val recordingExtension: String by config("jibri.ffmpeg.recording-extension".from(Config.configSource)) + private val TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss") + const val MAX_FILENAME_LENGTH = 125 + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/sink/impl/StreamSink.kt b/src/main/kotlin/org/jitsi/jibri/sink/impl/StreamSink.kt new file mode 100644 index 0000000..a6288b8 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/sink/impl/StreamSink.kt @@ -0,0 +1,27 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.sink.impl + +import org.jitsi.jibri.sink.Sink + +/** + * [StreamSink] represents a sink which will write to a network stream + */ +class StreamSink(val url: String) : Sink { + override val path: String = url +} diff --git a/src/main/kotlin/org/jitsi/jibri/sipgateway/SipClient.kt b/src/main/kotlin/org/jitsi/jibri/sipgateway/SipClient.kt new file mode 100644 index 0000000..531ee07 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/sipgateway/SipClient.kt @@ -0,0 +1,109 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.sipgateway + +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.StatusPublisher + +data class SipClientParams( + /** + * The SIP address we'll be connecting to + */ + val sipAddress: String = "", + + /** + * The display name used by pjsua as identity when listening for or sending an invite + * For sending an invite, this should be the name of the entity initiating the invite + */ + val displayName: String = "", + + /** + * Whether auto-answer is enabled, if it is, the client will listen for + * incoming invites and will auto answer the first one. + */ + val autoAnswer: Boolean = false, + + /** + * The optional auto-answer-timer in seconds. + * If auto-answer is enabled, the client will listen for incoming invites + * during this time. + */ + val autoAnswerTimer: Long? = 30, + + /** + * The username to use if registration is needed. + */ + val userName: String? = null, + + /** + * The password to use if registration is needed. + */ + val password: String? = null, + + /** + * The optional contact address the invitee will be connecting to + */ + val contact: String? = null, + + /** + * The optional address of proxy server + */ + val proxy: String? = null +) { + override fun toString() = + "SipClientParams(sipAddress: $sipAddress, displayName: $displayName, autoAnswer: $autoAnswer, " + + "autoAnswerTimer: $autoAnswerTimer, userName: $userName, " + + "password: ${if (password.isNullOrEmpty()) "empty" else "****"} contact: $contact, proxy: $proxy)" +} + +abstract class SipClient : StatusPublisher() { + /** + * Start the [SipClient] + */ + abstract fun start() + + /** + * Stop the [SipClient] + */ + abstract fun stop() +} + +/** + * A SIP address is written in user@domain.tld format in a similar fashion to an email address. + * An address like: sip:1-999-123-4567@voip-provider.example.net + */ +fun String.getSipAddress(): String { + if (this.isNotEmpty() && this.hasSipSchemeEmbedded()) { + return this.substringAfter(":") + } + return this +} + +/** + * Valid options would be `sip` or `sips` + * The default sip scheme is `sip` if none is specified + */ +fun String.getSipScheme(): String { + if (this.isNotEmpty() && this.hasSipSchemeEmbedded()) { + return this.substringBefore(":") + } + return "sip" +} + +private fun String.hasSipSchemeEmbedded(): Boolean = + contains("sip:", ignoreCase = true) || contains("sips:", ignoreCase = true) diff --git a/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/PjsuaClient.kt b/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/PjsuaClient.kt new file mode 100644 index 0000000..ae2a79c --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/PjsuaClient.kt @@ -0,0 +1,219 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.sipgateway.pjsua + +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.sipgateway.SipClient +import org.jitsi.jibri.sipgateway.SipClientParams +import org.jitsi.jibri.sipgateway.getSipAddress +import org.jitsi.jibri.sipgateway.getSipScheme +import org.jitsi.jibri.sipgateway.pjsua.util.PjsuaExitedPrematurely +import org.jitsi.jibri.sipgateway.pjsua.util.RemoteSipClientBusy +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.JibriSubprocess +import org.jitsi.jibri.util.ProcessExited +import org.jitsi.jibri.util.TaskPools +import org.jitsi.metaconfig.config +import org.jitsi.metaconfig.from +import org.jitsi.metaconfig.optionalconfig +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import java.io.BufferedReader +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStreamReader +import java.nio.file.Files +import java.nio.file.Paths +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit + +data class PjsuaClientParams( + val sipClientParams: SipClientParams +) + +private const val PJSUA_SCRIPT_FILE_LOCATION = "/opt/jitsi/jibri/pjsua.sh" +private const val X_DISPLAY = ":1" + +class PjsuaClient( + parentLogger: Logger, + private val pjsuaClientParams: PjsuaClientParams, + private val onDtmfCommand: ((String) -> Unit)? = null +) : SipClient() { + private val logger = createChildLogger(parentLogger) + private val pjsua: JibriSubprocess = JibriSubprocess(logger, "pjsua") + private val sipOutboundPrefix: String? by optionalconfig( + "jibri.sip.outbound-prefix".from(Config.configSource) + ) + private val dtmfFifoPath: String by config( + "jibri.sip.dtmf-fifo-path".from(Config.configSource) + ) + private var dtmfReaderTask: Future<*>? = null + + companion object { + const val COMPONENT_ID = "Pjsua" + } + + init { + pjsua.addStatusHandler { processState -> + when { + processState.runningState is ProcessExited -> { + when (processState.runningState.exitCode) { + // TODO: add detail? + // Remote side hung up + 0 -> publishStatus(ComponentState.Finished) + + 2 -> publishStatus(ComponentState.Error(RemoteSipClientBusy)) + + else -> publishStatus( + ComponentState.Error(PjsuaExitedPrematurely(processState.runningState.exitCode)) + ) + } + } + + // TODO: i think everything else just counts as running? + else -> publishStatus(ComponentState.Running) + } + } + } + + override fun start() { + if (onDtmfCommand != null) { + createDtmfFifo() + startDtmfFifoReader() + } + + val command = mutableListOf( + PJSUA_SCRIPT_FILE_LOCATION + ) + + val sipAddress = pjsuaClientParams.sipClientParams.sipAddress.getSipAddress() + val sipScheme = pjsuaClientParams.sipClientParams.sipAddress.getSipScheme() + + if (pjsuaClientParams.sipClientParams.userName != null && + pjsuaClientParams.sipClientParams.password != null + ) { + command.add( + "--id=${pjsuaClientParams.sipClientParams.displayName} " + + "<$sipScheme:${pjsuaClientParams.sipClientParams.userName}>" + ) + command.add( + "--registrar=$sipScheme:${ + pjsuaClientParams.sipClientParams.userName.substringAfter('@') + }" + ) + command.add("--realm=*") + command.add("--username=${pjsuaClientParams.sipClientParams.userName.substringBefore('@')}") + command.add("--password=${pjsuaClientParams.sipClientParams.password}") + } + + if (!pjsuaClientParams.sipClientParams.contact.isNullOrEmpty()) { + command.add("--contact=${pjsuaClientParams.sipClientParams.contact}") + } + + if (!pjsuaClientParams.sipClientParams.proxy.isNullOrEmpty()) { + command.add("--proxy=${pjsuaClientParams.sipClientParams.proxy}") + } + + if (pjsuaClientParams.sipClientParams.autoAnswer) { + command.add("--auto-answer-timer=${pjsuaClientParams.sipClientParams.autoAnswerTimer}") + command.add("--auto-answer=200") + } else { + // The proxy we'll use for all the outgoing SIP requests; + // This proxy will be enabled if --proxy is not set explicitly through API + // The client should not specify a Route header in the sip INVITE message. Using hide will let the server set the Route header + if (pjsuaClientParams.sipClientParams.proxy == null && + pjsuaClientParams.sipClientParams.userName != null + ) { + command.add( + "--proxy=$sipScheme:${pjsuaClientParams.sipClientParams.userName.substringAfter('@')};" + + "transport=tcp;hide" + ) + } + + if (sipOutboundPrefix.isNullOrEmpty()) { + command.add("$sipScheme:$sipAddress") + } else { + command.add("$sipScheme:${sipOutboundPrefix}$sipAddress") + } + } + + pjsua.launch(command, mapOf("DISPLAY" to X_DISPLAY)) + } + + private fun createDtmfFifo() { + try { + Files.deleteIfExists(Paths.get(dtmfFifoPath)) + val process = Runtime.getRuntime().exec(arrayOf("mkfifo", dtmfFifoPath)) + process.waitFor() + logger.info("Created DTMF FIFO at $dtmfFifoPath") + } catch (e: Exception) { + logger.error("Failed to create DTMF FIFO", e) + } + } + + private fun startDtmfFifoReader() { + dtmfReaderTask = TaskPools.ioPool.submit { + try { + logger.info("DTMF FIFO reader waiting for pjsua to connect") + FileInputStream(File(dtmfFifoPath)).use { fis -> + BufferedReader(InputStreamReader(fis)).use { reader -> + var line = reader.readLine() + while (line != null) { + if (line.startsWith("DTMF_COMMAND:")) { + onDtmfCommand?.invoke(line.removePrefix("DTMF_COMMAND:")) + } + line = reader.readLine() + } + } + } + logger.info("DTMF FIFO reader exited (EOF)") + } catch (e: Exception) { + logger.info("DTMF FIFO reader exited: ${e.message}") + } + } + } + + override fun stop() { + pjsua.stop() + stopDtmfFifoReader() + } + + private fun stopDtmfFifoReader() { + val fifoFile = File(dtmfFifoPath) + if (!fifoFile.exists()) return + + // Open the write end to unblock the reader task if it's blocking on open() + val unblockTask = TaskPools.ioPool.submit { + try { + FileOutputStream(fifoFile).close() + } catch (e: Exception) {} + } + + try { + dtmfReaderTask?.get(2000, TimeUnit.MILLISECONDS) + } catch (e: Exception) {} + try { + unblockTask.get(2000, TimeUnit.MILLISECONDS) + } catch (e: Exception) {} + + try { + Files.deleteIfExists(Paths.get(dtmfFifoPath)) + } catch (e: Exception) {} + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/util/Error.kt b/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/util/Error.kt new file mode 100644 index 0000000..d012c90 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/util/Error.kt @@ -0,0 +1,25 @@ +/* + * 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.jibri.sipgateway.pjsua.util + +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.status.ErrorScope + +object RemoteSipClientBusy : JibriError(ErrorScope.SESSION, "Remote side busy") + +class PjsuaExitedPrematurely(exitCode: Int) : + JibriError(ErrorScope.SESSION, "Pjsua exited with code $exitCode") diff --git a/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/util/OutputParser.kt b/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/util/OutputParser.kt new file mode 100644 index 0000000..1dc13c1 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/util/OutputParser.kt @@ -0,0 +1,30 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.sipgateway.pjsua.util + +import org.jitsi.jibri.util.ProcessWrapper + +enum class PjsuaStatus { + HEALTHY, + EXITED +} + +fun ProcessWrapper.getPjsuaStatus(): Pair { + val mostRecentLine = getMostRecentLine() + val status = if (isAlive) PjsuaStatus.HEALTHY else PjsuaStatus.EXITED + return Pair(status, mostRecentLine) +} diff --git a/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/util/PjsuaFileHandler.kt b/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/util/PjsuaFileHandler.kt new file mode 100644 index 0000000..87a87d4 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/sipgateway/pjsua/util/PjsuaFileHandler.kt @@ -0,0 +1,21 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.sipgateway.pjsua.util + +import java.util.logging.FileHandler + +class PjsuaFileHandler : FileHandler() diff --git a/src/main/kotlin/org/jitsi/jibri/status/ComponentBusyStatus.kt b/src/main/kotlin/org/jitsi/jibri/status/ComponentBusyStatus.kt new file mode 100644 index 0000000..1e29352 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/status/ComponentBusyStatus.kt @@ -0,0 +1,28 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.status + +enum class ComponentBusyStatus { + BUSY, + IDLE, + + /** + * This Jibri has exhausted its 'use' and needs action + * (e.g. a restart) before it can be used again + */ + EXPIRED +} diff --git a/src/main/kotlin/org/jitsi/jibri/status/ComponentHealthStatus.kt b/src/main/kotlin/org/jitsi/jibri/status/ComponentHealthStatus.kt new file mode 100644 index 0000000..5092e4f --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/status/ComponentHealthStatus.kt @@ -0,0 +1,41 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.status + +/** + * A simple model of whether or not a component is healthy or not. This enum is used to represent health at + * multiple levels: sub-components within Jibri and Jibri's overall health. + */ +enum class ComponentHealthStatus { + HEALTHY, + UNHEALTHY; + + /** + * Performs a logical 'and' of statuses, where: + * HEALTHY.and(HEALTHY) -> HEALTHY + * HEALTHY.and(UNHEALTHY) -> UNHEALTHY + * UNHEALTHY.and(HEALTHY) -> UNHEALTHY + * UNHEALTHY.and(UNHEALTHY) -> UNHEALTHY + */ + fun and(other: ComponentHealthStatus): ComponentHealthStatus { + if (this == HEALTHY && other == HEALTHY) { + return HEALTHY + } + return UNHEALTHY + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/status/ComponentStates.kt b/src/main/kotlin/org/jitsi/jibri/status/ComponentStates.kt new file mode 100644 index 0000000..84bd47d --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/status/ComponentStates.kt @@ -0,0 +1,47 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jibri.status + +import org.jitsi.jibri.error.JibriError + +sealed class ComponentState { + object StartingUp : ComponentState() { + override fun toString(): String = "Starting up" + } + object Running : ComponentState() { + override fun toString(): String = "Running" + } + class Error(val error: JibriError) : ComponentState() { + override fun toString(): String = error.toString() + } + object Finished : ComponentState() { + override fun toString(): String = "Finished" + } +} + +enum class ErrorScope { + /** + * [SESSION] errors are errors which only affect the current session. A session error still leaves Jibri as a + * whole 'healthy' + */ + SESSION, + + /** + * [SYSTEM] errors are unrecoverable, and will put Jibri in an unhealthy state + */ + SYSTEM +} diff --git a/src/main/kotlin/org/jitsi/jibri/status/JibriStatusManager.kt b/src/main/kotlin/org/jitsi/jibri/status/JibriStatusManager.kt new file mode 100644 index 0000000..9d87fcc --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/status/JibriStatusManager.kt @@ -0,0 +1,129 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.status + +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.util.StatusPublisher +import org.jitsi.utils.logging.Logger +import org.jitsi.xmpp.extensions.jibri.JibriIq +import java.util.concurrent.ConcurrentHashMap +import kotlin.properties.Delegates + +/** + * The combination of a [ComponentHealthStatus] and an optional detail string to elaborate on the status + */ +data class ComponentHealthDetails( + val healthStatus: ComponentHealthStatus, + val detail: String = "" +) + +/** + * The [ComponentHealthStatus] representing the overall health of the entire Jibri, as well as the + * [ComponentHealthDetails] for each sub component + */ +data class OverallHealth( + val healthStatus: ComponentHealthStatus, + val details: Map +) + +/** + * The overall status of this Jibri. This includes both its [ComponentBusyStatus] and its [OverallHealth] + */ +data class JibriStatus( + val busyStatus: ComponentBusyStatus, + val health: OverallHealth +) + +data class JibriFailure( + val reason: JibriIq.FailureReason? = null, + val error: JibriError? = null +) + +data class JibriSessionStatus( + val sessionId: String, + val status: JibriIq.Status, + val sipAddress: String? = null, + val failure: JibriFailure? = null, + val shouldRetry: Boolean? = null +) + +/** + * Models Jibri's overall status which currently consists of 2 things: + * 1) Its 'busy' status: whether or not this Jibri is currently 'busy' + * 2) Its health: whether or not this Jibri is capable of handling requests (regardless of + * its busy status) + */ +class JibriStatusManager : StatusPublisher() { + private val logger = Logger.getLogger(this::class.qualifiedName) + private val subComponentHealth: MutableMap = ConcurrentHashMap() + + /** + * The overall [ComponentHealthStatus] for the entire Jibri, calculated by aggregating all sub-component + * health status + */ + private val overallHealthStatus: ComponentHealthStatus + get() { + return subComponentHealth.values.fold(ComponentHealthStatus.HEALTHY) { overallStatus, currDetails -> + overallStatus.and(currDetails.healthStatus) + } + } + + /** + * The overall health status plus any health details for each component + */ + private val overallHealth: OverallHealth + get() { + return OverallHealth( + overallHealthStatus, + subComponentHealth.toMap() + ) + } + + /** + * The overall status contains both the health and busy statuses + */ + val overallStatus: JibriStatus + get() = JibriStatus(busyStatus, overallHealth) + + /** + * The busy status for this Jibri + */ + var busyStatus: ComponentBusyStatus by Delegates.observable(ComponentBusyStatus.IDLE) { _, old, new -> + if (old != new) { + logger.info("Busy status has changed: $old -> $new") + publishStatus(overallStatus) + } + } + + /** + * API for sub-components to update their health status + */ + @Synchronized + fun updateHealth(componentName: String, healthStatus: ComponentHealthStatus, detail: String = "") { + logger.info( + "Received component health update: $componentName has status $healthStatus " + + "(detail: $detail)" + ) + val oldHealthStatus = overallHealthStatus + subComponentHealth[componentName] = ComponentHealthDetails(healthStatus, detail) + val newHealthStatus = overallHealthStatus + if (oldHealthStatus != newHealthStatus) { + logger.info("Health status has changed: $oldHealthStatus -> $newHealthStatus") + publishStatus(overallStatus) + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/FileUtils.kt b/src/main/kotlin/org/jitsi/jibri/util/FileUtils.kt new file mode 100644 index 0000000..764c6b6 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/FileUtils.kt @@ -0,0 +1,33 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.util + +import org.jitsi.utils.logging2.Logger +import java.nio.file.Files +import java.nio.file.Path + +fun createIfDoesNotExist(path: Path, logger: Logger? = null): Boolean { + if (!Files.exists(path)) { + try { + Files.createDirectories(path) + } catch (e: Exception) { + logger?.error("Error creating directory", e) + return false + } + } + return true +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/JibriSubprocess.kt b/src/main/kotlin/org/jitsi/jibri/util/JibriSubprocess.kt new file mode 100644 index 0000000..e43cc03 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/JibriSubprocess.kt @@ -0,0 +1,110 @@ +/* + * 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.jibri.util + +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import java.io.BufferedReader +import java.io.InputStreamReader +import java.time.Duration +import java.util.concurrent.Callable +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +class JibriSubprocess( + parentLogger: Logger, + private val name: String, + private val processOutputLogger: Logger? = null, + private val outputLineHandler: ((String) -> Unit)? = null, + private val processFactory: ProcessFactory = ProcessFactory(), + processStatePublisherProvider: ((ProcessWrapper) -> ProcessStatePublisher)? = null +) : StatusPublisher() { + private val logger = createChildLogger(parentLogger) + private val processStatePublisherProvider = + processStatePublisherProvider ?: { process -> ProcessStatePublisher(logger, name, process) } + private var processLoggerTask: Future? = null + + private var process: ProcessWrapper? = null + private var processStatePublisher: ProcessStatePublisher? = null + + fun launch(command: List, env: Map = mapOf()) { + logger.info( + "Starting $name with: ${command.map { if (it.startsWith("--password")) "--password=****" else it }}" + ) + process = processFactory.createProcess(command, logger, env) + try { + process?.let { + it.start() + processStatePublisher = processStatePublisherProvider(it) + processStatePublisher!!.addStatusHandler(this::publishStatus) + if (processOutputLogger != null || outputLineHandler != null) { + processLoggerTask = TaskPools.ioPool.submit( + Callable { + val reader = BufferedReader(InputStreamReader(it.getOutput())) + while (true) { + val line = reader.readLine() ?: break + processOutputLogger?.info(line) + outputLineHandler?.invoke(line) + } + return@Callable true + } + ) + } + } ?: run { + throw Exception("Process was null") + } + } catch (t: Throwable) { + logger.error("Error starting $name") + process = null + publishStatus(ProcessState(ProcessFailedToStart(), "")) + } + } + + private fun waitForProcessLoggerTaskToFinish(timeout: Duration) { + try { + processLoggerTask?.get(timeout.toMillis(), TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + logger.error("Timed out waiting for process logger task to complete") + processLoggerTask?.cancel(true) + } catch (e: Exception) { + logger.error("Exception while waiting for process logger task to complete", e) + processLoggerTask?.cancel(true) + } + } + + fun stop() { + logger.info("Stopping $name process") + processStatePublisher?.stop() + + process?.apply { + if (!stopAndWaitFor(Duration.ofSeconds(10))) { + logger.error("Error trying to gracefully stop $name, destroying forcibly") + if (!destroyForciblyAndWaitFor(Duration.ofSeconds(10))) { + logger.error("Error trying to destroy $name forcibly") + } + } + } + waitForProcessLoggerTaskToFinish(Duration.ofSeconds(10)) + + try { + logger.info("$name exited with value ${process?.exitValue}") + } catch (e: IllegalThreadStateException) { + logger.error("$name has still not exited! Unable to stop it") + } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/LoggingUtils.kt b/src/main/kotlin/org/jitsi/jibri/util/LoggingUtils.kt new file mode 100644 index 0000000..e83efcb --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/LoggingUtils.kt @@ -0,0 +1,72 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.util + +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.LoggerImpl +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import java.util.concurrent.Callable +import java.util.concurrent.Future +import java.util.logging.FileHandler + +class LoggingUtils { + companion object { + var createPublishingTail: (InputStream, (String) -> Unit) -> PublishingTail = ::PublishingTail + + /** + * The default implementation of the process output logger + */ + val OutputLogger: (ProcessWrapper, Logger) -> Future = { process, logger -> + TaskPools.ioPool.submit( + Callable { + val reader = BufferedReader(InputStreamReader(process.getOutput())) + + while (true) { + val line = reader.readLine() ?: break + logger.info(line) + } + + return@Callable true + } + ) + } + + /** + * A variable pointing to the current impl of the process output logger function. + * Overridable so tests can change it. + */ + var logOutput = OutputLogger + + /** + * A helper function to log a given [ProcessWrapper]'s output to the given [Logger]. + * A future is returned which will be completed when the end of the given + * stream is reached. + */ + fun logOutputOfProcess(process: ProcessWrapper, logger: Logger): Future = logOutput(process, logger) + } +} + +/** + * Create a logger with [name] and all of its inherited + * handlers cleared, adding only the given handler + */ +fun getLoggerWithHandler(name: String, handler: FileHandler): Logger = LoggerImpl(name).apply { + setUseParentHandlers(false) + addHandler(handler) +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/NameableThreadFactory.kt b/src/main/kotlin/org/jitsi/jibri/util/NameableThreadFactory.kt new file mode 100644 index 0000000..07557ae --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/NameableThreadFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +import java.util.concurrent.ThreadFactory + +/** + * A helper to create a [ThreadFactory] where all threads will + * be given [name] + */ +class NameableThreadFactory(private val name: String) : ThreadFactory { + private var threadNum = 1 + override fun newThread(r: Runnable?): Thread = Thread(r, "$name-${threadNum++}") +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/NotifyingStateMachine.kt b/src/main/kotlin/org/jitsi/jibri/util/NotifyingStateMachine.kt new file mode 100644 index 0000000..d307451 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/NotifyingStateMachine.kt @@ -0,0 +1,33 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jibri.util + +import org.jitsi.jibri.status.ComponentState + +abstract class NotifyingStateMachine { + private val stateTranstionHandlers = mutableListOf<(ComponentState, ComponentState) -> Unit>() + + protected fun notify(fromState: ComponentState, toState: ComponentState) { + stateTranstionHandlers.forEach { handler -> + handler(fromState, toState) + } + } + + fun onStateTransition(handler: (ComponentState, ComponentState) -> Unit) { + stateTranstionHandlers.add(handler) + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/OsDetector.kt b/src/main/kotlin/org/jitsi/jibri/util/OsDetector.kt new file mode 100644 index 0000000..cd4afbf --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/OsDetector.kt @@ -0,0 +1,31 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.util + +enum class OsType { + MAC, + LINUX, + UNSUPPORTED +} + +class OsDetector { + fun getOsType(): OsType = when (System.getProperty("os.name")) { + "Mac OS X" -> OsType.MAC + "Linux" -> OsType.LINUX + else -> OsType.UNSUPPORTED + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/ProcessFactory.kt b/src/main/kotlin/org/jitsi/jibri/util/ProcessFactory.kt new file mode 100644 index 0000000..8566154 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/ProcessFactory.kt @@ -0,0 +1,27 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.util + +import org.jitsi.utils.logging2.Logger + +class ProcessFactory { + fun createProcess( + command: List, + parentLogger: Logger, + environment: Map = mapOf() + ): ProcessWrapper = ProcessWrapper(command, parentLogger, environment) +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/ProcessState.kt b/src/main/kotlin/org/jitsi/jibri/util/ProcessState.kt new file mode 100644 index 0000000..79b6d05 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/ProcessState.kt @@ -0,0 +1,65 @@ +/* + * 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.jibri.util + +/** + * Models 2 things about a process: + * 1) Whether or not it is 'alive' (vs. having exited) and, if it has exited, what its exit code was + * 2) Its most recent line of output (via stdout/stderr) + */ +data class ProcessState( + val runningState: AliveState, + val mostRecentOutput: String +) + +/** + * Models whether a process is alive or has exited + */ +sealed class AliveState(val runningStatus: RunningStatus) { + override fun toString(): String = with(StringBuffer()) { + append("$runningStatus") + toString() + } +} + +class ProcessRunning : AliveState(RunningStatus.RUNNING) + +class ProcessExited(val exitCode: Int) : AliveState(RunningStatus.EXITED) { + override fun toString(): String = buildString { + append("${super.toString()} exit code: $exitCode") + } +} + +class ProcessFailedToStart() : AliveState(RunningStatus.FAILED) + +enum class RunningStatus { + /** + * The component is currently 'running'. Note that this does not mean it _should_ still be running, i.e. its + * state could be [Status.FINISHED] and it's now ready to be shutdown cleanly. + */ + RUNNING, + + /** + * The process has exited + */ + EXITED, + + /** + * The process failed to start + */ + FAILED +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/ProcessStatePublisher.kt b/src/main/kotlin/org/jitsi/jibri/util/ProcessStatePublisher.kt new file mode 100644 index 0000000..bb22f43 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/ProcessStatePublisher.kt @@ -0,0 +1,113 @@ +/* + * 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.jibri.util + +import org.jitsi.jibri.util.extensions.scheduleAtFixedRate +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import java.time.Duration +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +/** + * [ProcessStatePublisher] is responsible for publishing a [ProcessState] every time: + * 1) The process writes a line of output (to stdout/stderr) + * 2) If the process hasn't written any output in [NO_OUTPUT_TIMEOUT], then we'll check its alive state + * and publish a [ProcessState] with its current alive state and its most recent line of output + */ +class ProcessStatePublisher( + parentLogger: Logger, + private val name: String, + private val process: ProcessWrapper +) : StatusPublisher() { + private val logger = createChildLogger(parentLogger) + private val tail: PublishingTail + private var recurringProcessAliveTask: ScheduledFuture<*>? = null + private var lastStatusUpdateTimestamp = AtomicLong(0) + private val processRunningState: AliveState + get() { + return if (process.isAlive) { + ProcessRunning() + } else { + ProcessExited(process.exitValue) + } + } + + companion object { + private val NO_OUTPUT_TIMEOUT = Duration.ofSeconds(2) + } + + init { + tail = LoggingUtils.createPublishingTail(process.getOutput(), this::onProcessOutput) + startProcessAliveChecks() + } + + private fun onProcessOutput(output: String) { + lastStatusUpdateTimestamp.set(System.currentTimeMillis()) + // In this event-driven process output handler (which is invoked for every line of the process' output + // we read) we always denote the process' state as running. The reason for this is that there is an out-of-sync + // problem between the log line we read and the process' current state: the log lines will always be delayed + // in comparison to the process' current state (running or exited). Rather than be misleading and publish + // a status with a state of exited but a log line that was written at some point when the process was still + // running, we always state that the process is running here. When the process actually is dead, it won't + // write anymore and the timer-based check (from startProcessAliveChecks below) will get the process' last line + // and publish its true running/exited state. Note that this does not prevent other code from parsing an + // output line that clearly denotes the process has exited and reacting appropriately. + publishStatus(ProcessState(ProcessRunning(), output)) + } + + /** + * We publish status updates based on every line the process writes to stdout. But it's possible it could be + * killed and no longer write anything, so we need to separately monitor if the process is alive or not. + * + * We'll publish an update on whether or not it's alive if we haven't based on the stdout checks for over 2 + * seconds. + */ + private fun startProcessAliveChecks() { + recurringProcessAliveTask = TaskPools.recurringTasksPool.scheduleAtFixedRate(2, TimeUnit.SECONDS, 5) { + val timeSinceLastStatusUpdate = + Duration.ofMillis(System.currentTimeMillis() - lastStatusUpdateTimestamp.get()) + if (timeSinceLastStatusUpdate > Duration.ofSeconds(2)) { + logger.debug { "Process $name hasn't written in 2 seconds, publishing periodic update" } + ProcessState(processRunningState, tail.mostRecentLine).also { + TaskPools.ioPool.submit { + // We fire all state transitions in the ioPool, otherwise we may try and cancel the + // processAliveChecks from within the thread it was executing in. Another solution would've been + // to pass 'false' to recurringProcessAliveTask.cancel, but it felt cleaner to separate the threads + publishStatus(it) + } + } + } + } + } + + fun stop() { + recurringProcessAliveTask?.cancel(true) + // TODO: not calling 'tail.stop()' results in us processing ffmpeg's--for example--successful + // exit, which causes a 'finished' state update to propagate up and results in a duplicate 'stopService' + // call in JibriManager, since we have to call stopService after we receive finish (to handle the case + // of the service detecting an empty call, for example) and we can't distinguish a 'finished' state + // from an actual jibri service finishing on its own and the one that results from any call to + // 'stop' when ffmpeg exits. Calling 'tail.stop()' here fixes that behavior, but, i don't + // think that's what we want to do: instead we should be processing every log message a process + // writes to ensure that it exits correctly. In addition to that, we should technically be modeling + // the 'stop' flow differently to be something more like: tell everything to stop and then *ensure* + // it all stopped cleanly and correctly and, if anything didn't, log an error (and perhaps update + // the health state) + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/ProcessWrapper.kt b/src/main/kotlin/org/jitsi/jibri/util/ProcessWrapper.kt new file mode 100644 index 0000000..e98a72a --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/ProcessWrapper.kt @@ -0,0 +1,147 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.util + +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.logging2.createChildLogger +import java.io.InputStream +import java.time.Duration +import java.util.concurrent.TimeUnit + +typealias ProcessStopper = (pid: Long) -> Unit + +/** + * A wrapper around [Process] that implements + * behaviors more useful to Jibri. This isn't done + * as a subclass because [Process] is abstract + * and the actual implementation varies by platform. + * + * NOTE that many methods in this class throw exceptions + * which must be handled + */ +class ProcessWrapper( + command: List, + parentLogger: Logger, + val environment: Map = mapOf(), + private val processStarter: () -> Process = { + ProcessBuilder().apply { + command(command) + redirectErrorStream(true) + environment().putAll(environment) + }.start() + }, + private val stopper: ProcessStopper = { pid -> + Runtime.getRuntime().exec( + arrayOf("kill", "-s", "SIGINT", pid.toString()) + ) + } +) { + private val logger = createChildLogger(parentLogger) + + /** + * The actual underlying [Process] this wrapper wraps + */ + private lateinit var process: Process + + /** + * A 'Tee' which allows us to split the stdout + * stream coming from the process into multiple + * independent streams which can be read from independently + */ + private lateinit var tee: Tee + + /** + * Observes the most recent line of output from the wrapped process + */ + private lateinit var tail: Tail + + /** + * Whether or not the wrapped process is alive + */ + val isAlive: Boolean + get() = process.isAlive + + /** + * Returns the exit value of the wrapped process. + * Throws if the wrapped process has not exited, so check + * that [isAlive] is false before calling to avoid that. + */ + val exitValue: Int + get() = process.exitValue() + + /** + * Starts this Process. Will throw if there's an error. + */ + fun start() { + process = processStarter() + tee = Tee(process.inputStream) + tail = Tail(getOutput()) + } + + fun waitFor() = process.waitFor() + + fun waitFor(timeout: Long, unit: TimeUnit): Boolean = process.waitFor(timeout, unit) + + fun stop() { + // Note that we specifically do NOT call stop on tee or tail here + // because we want them to read everything available from the + // process' inputstream. Once it's done, they'll read + // the EOF and close things up correctly + stopper(process.pid()) + } + + /** + * A convenience method on top of [stop] and [waitFor] which + * not only combines the two calls but handles any exceptions + * thrown. 'true' is returned if the process was successfully + * ended, false otherwise + */ + fun stopAndWaitFor(timeout: Duration): Boolean = try { + stop() + waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS) + } catch (t: Throwable) { + logger.error("Error stopping process", t) + false + } + + fun destroyForcibly(): Process = process.destroyForcibly() + + /** + * A convenience method on top of [destroyForcibly] and [waitFor] which + * not only combines the two calls but handles any exceptions + * thrown. 'true' is returned if the process was successfully + * ended, false otherwise + */ + fun destroyForciblyAndWaitFor(timeout: Duration): Boolean = try { + destroyForcibly() + waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS) + } catch (t: Throwable) { + logger.error("Error forcibly destroying process", t) + false + } + + /** + * Returns an [InputStream] representing the output of the wrapped + * process. The [InputStream] is unique, meaning + * that the [InputStream] returned from each + * instance of this call can be read from independently, + * without affecting the reads on other [InputStream]s + */ + fun getOutput(): InputStream = tee.addBranch() + + fun getMostRecentLine(): String = tail.mostRecentLine +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/RandomUtils.kt b/src/main/kotlin/org/jitsi/jibri/util/RandomUtils.kt new file mode 100644 index 0000000..592b5b3 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/RandomUtils.kt @@ -0,0 +1,21 @@ +/* + * 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.jibri.util + +import kotlin.random.Random + +val alphaNum = ('a'..'z') + ('0'..'9') +fun randomAlphaNum(len: Int): String = List(len) { alphaNum[Random.nextInt(0, alphaNum.size)] }.joinToString("") diff --git a/src/main/kotlin/org/jitsi/jibri/util/RegexUtils.kt b/src/main/kotlin/org/jitsi/jibri/util/RegexUtils.kt new file mode 100644 index 0000000..141dabe --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/RegexUtils.kt @@ -0,0 +1,34 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +// Regex definitions for parsing an ffmpeg output line +const val DIGIT = """\d""" +const val ONE_OR_MORE_DIGITS = "$DIGIT+" + +// "1" is treated as a valid decimal (the decimal point and any trailing numbers are not required) +const val DECIMAL = """$ONE_OR_MORE_DIGITS(\.$ONE_OR_MORE_DIGITS)?""" +const val STRING = """[a-zA-Z]+""" +const val DATA_SIZE = "$ONE_OR_MORE_DIGITS$STRING" +const val TIMESTAMP = """$ONE_OR_MORE_DIGITS\:$ONE_OR_MORE_DIGITS\:$ONE_OR_MORE_DIGITS\.$ONE_OR_MORE_DIGITS""" +const val BITRATE = """$DECIMAL$STRING\/$STRING""" +const val SPEED = "${DECIMAL}x" +const val SPACE = """\s""" +const val NON_SPACE = """\S""" +const val ZERO_OR_MORE_SPACES = "$SPACE*" +const val ONE_OR_MORE_NON_SPACES = "$NON_SPACE+" diff --git a/src/main/kotlin/org/jitsi/jibri/util/StateUtils.kt b/src/main/kotlin/org/jitsi/jibri/util/StateUtils.kt new file mode 100644 index 0000000..83224a8 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/StateUtils.kt @@ -0,0 +1,61 @@ +/* + * 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. + */ +@file:Suppress("ktlint:standard:filename") + +package org.jitsi.jibri.util + +import org.jitsi.jibri.status.ComponentState + +/** + * These helpers make it easy to do work based on a component transitioning to a particular state. + * For example, if we have subcomponents Foo and Bar, and Bar shouldn't start until Foo has reached + * [ComponentState.Running], you can do: + * + * whenever(bar).transitionsTo(ComponentState.Running) { + * foo.start() + * } + */ +class ComponentStateTransitioner(private val statusPublisher: StatusPublisher) { + init { + } + + fun transitionsTo(desiredState: ComponentState, block: () -> Unit) { + statusPublisher.addTemporaryHandler { state -> + if (state == desiredState) { + block() + return@addTemporaryHandler false + } + true + } + } + + /** + * We need a special method for the error state, since the error state is stateful and we don't use a single + * instance to model the state. + */ + fun transitionsToError(block: () -> Unit) { + statusPublisher.addTemporaryHandler { state -> + if (state is ComponentState.Error) { + block() + return@addTemporaryHandler false + } + true + } + } +} + +fun whenever(statusPublisher: StatusPublisher): ComponentStateTransitioner = + ComponentStateTransitioner(statusPublisher) diff --git a/src/main/kotlin/org/jitsi/jibri/util/StatusPublisher.kt b/src/main/kotlin/org/jitsi/jibri/util/StatusPublisher.kt new file mode 100644 index 0000000..c3812ef --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/StatusPublisher.kt @@ -0,0 +1,56 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Class for publishing and subscribing to status. Handles the management + * of all subscribers (via [addStatusHandler]) and pushes updates to + * those subscribers (synchronously) in [publishStatus]. Classes + * interested in publishing their status should inherit from + * [StatusPublisher]. + */ +open class StatusPublisher { + private val handlers: MutableList<(T) -> Boolean> = CopyOnWriteArrayList() + + /** + * Add a status handler for this [StatusPublisher]. Handlers + * will be notified synchronously in the order they were added. + */ + fun addStatusHandler(handler: (T) -> Unit) { + handlers.add { status -> + handler(status) + true + } + } + + fun addTemporaryHandler(handler: (T) -> Boolean) { + handlers.add(handler) + } + + /** + * The function a [StatusPublisher] subclass should call when it has + * a new status to publish. Note that handlers are notified synchronously + * in the context of the thread which calls [publishStatus]. + */ + protected fun publishStatus(status: T) { + // This will run all handlers, but only keep the ones that return true + handlers.retainAll { it(status) } + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/Tail.kt b/src/main/kotlin/org/jitsi/jibri/util/Tail.kt new file mode 100644 index 0000000..66af1a1 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/Tail.kt @@ -0,0 +1,95 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import java.util.concurrent.Future + +/** + * Read from an infinite [InputStream] and make available the most + * recent line that was read via [mostRecentLine]. NOTE: this class + * will not read from the stream automatically, its [readLine] + * method must be called. + */ +class TailLogic(inputStream: InputStream) { + private val reader = BufferedReader(InputStreamReader(inputStream)) + + @Volatile var mostRecentLine: String = "" + + fun readLine() { + mostRecentLine = reader.readLine() + } +} + +/** + * A wrapper around [TailLogic] which Spins up a thread to constantly + * read from the given [InputStream] and save the most-recently-read + * line as [mostRecentLine] to be read by whomever is interested. + */ +class Tail(inputStream: InputStream) { + private val tailLogic = TailLogic(inputStream) + private var task: Future<*> + val mostRecentLine: String + get() { + return tailLogic.mostRecentLine + } + + init { + task = TaskPools.ioPool.submit { + while (true) { + tailLogic.readLine() + } + } + } + + fun stop() { + task.cancel(true) + } +} + +/** + * Read every line from the given [InputStream] and invoke the + * [onOutput] handler with each line. + */ +class PublishingTail( + inputStream: InputStream, + private val onOutput: (String) -> Unit +) { + private val tailLogic = TailLogic(inputStream) + private var task: Future<*> + + init { + task = TaskPools.ioPool.submit { + while (true) { + tailLogic.readLine() + onOutput(tailLogic.mostRecentLine) + } + } + } + + fun stop() { + task.cancel(true) + } + + val mostRecentLine: String + get() { + return tailLogic.mostRecentLine + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/TaskPools.kt b/src/main/kotlin/org/jitsi/jibri/util/TaskPools.kt new file mode 100644 index 0000000..114f1fa --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/TaskPools.kt @@ -0,0 +1,39 @@ +/* + * 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.jibri.util + +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService + +/** + * Stores task pools to be used for various tasks at a global level. Globals are not great + * for lots of reasons, but I think for the uses here they are appropriate. Plus, the + * fact that the variables here are 'vars' allows them to be overwritten by tests if + * they want to sub in mock executors. + */ +class TaskPools { + companion object { + val DefaultIoPool: ExecutorService = + Executors.newCachedThreadPool(NameableThreadFactory("IO Pool")) + val DefaultRecurringTaskPool: ScheduledExecutorService = + Executors.newSingleThreadScheduledExecutor(NameableThreadFactory("Recurring Tasks Pool")) + + var ioPool: ExecutorService = DefaultIoPool + var recurringTasksPool: ScheduledExecutorService = DefaultRecurringTaskPool + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/Tee.kt b/src/main/kotlin/org/jitsi/jibri/util/Tee.kt new file mode 100644 index 0000000..7f2030e --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/Tee.kt @@ -0,0 +1,111 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Future + +class EndOfStreamException : Exception() + +private const val EOF = -1 + +/** + * Reads from the given [InputStream] and mirrors the read + * data to all of the created 'branches' off of it. + * All branches will 'receive' all data from the original + * [InputStream] starting at the the point of + * the branch's creation. + * NOTE: This class will not read from the given [InputStream] + * automatically, its [read] must be invoked + * to read the data from the original stream and write it to + * the branches + */ +class TeeLogic(inputStream: InputStream) { + private val reader = BufferedReader(InputStreamReader(inputStream)) + private var branches = CopyOnWriteArrayList() + + /** + * Reads a byte from the original [InputStream] and + * writes it to all of the branches. If EOF is detected, + * all branches will be closed and [EndOfStreamException] + * will be thrown, so that any callers can know not + * to bother calling again. + */ + fun read() { + val c = reader.read() + if (c == EOF) { + branches.forEach(OutputStream::close) + throw EndOfStreamException() + } else { + branches.forEach { it.write(c) } + } + } + + /** + * If you want to close the Tee before the underlying + * [InputStream] closes, you'll need to call [close] to + * properly close all downstream branches. Note that + * calling [read] after [close] when there are branches + * will result in [java.io.IOException]. + */ + fun close() { + branches.forEach(OutputStream::close) + } + + /** + * Returns an [InputStream] that will receive + * all data from the original [InputStream] + * starting from the time of its creation + */ + fun addBranch(): InputStream { + with(PipedOutputStream()) { + branches.add(this) + return PipedInputStream(this) + } + } +} + +/** + * A wrapper around [TeeLogic] which spins up its own thread + * to do the reading automatically + */ +class Tee(inputStream: InputStream) { + private val teeLogic = TeeLogic(inputStream) + private val task: Future<*> + + init { + task = TaskPools.ioPool.submit { + while (true) { + teeLogic.read() + } + } + } + + fun addBranch(): InputStream = teeLogic.addBranch() + + fun stop() { + task.cancel(true) + teeLogic.close() + } +} diff --git a/src/main/kotlin/org/jitsi/jibri/util/XmppUtils.kt b/src/main/kotlin/org/jitsi/jibri/util/XmppUtils.kt new file mode 100644 index 0000000..1a4d9cb --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/XmppUtils.kt @@ -0,0 +1,65 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +import org.jitsi.jibri.CallUrlInfo +import org.jitsi.xmpp.extensions.jibri.JibriIq +import org.jxmpp.jid.EntityBareJid + +/** + * When we get a start [JibriIq] message, the room is given to us an [EntityBareJid] that we need to translate + * into a URL for selenium to join. This method translates that jid into the url. + */ +fun getCallUrlInfoFromJid( + roomJid: EntityBareJid, + stripFromRoomDomain: String, + xmppDomain: String, + baseUrl: String? +): CallUrlInfo { + try { + // The url domain is pulled from the xmpp domain of the connection sending the request + var domain = roomJid.domain.toString() + // But the room jid domain may have a subdomain that shouldn't be applied to the url, so strip out any + // string we've been told to remove from the domain + domain = domain.replaceFirst(stripFromRoomDomain, "", ignoreCase = true) + // Now we need to extract a potential call subdomain, which will be anything that's left in the domain + // at this point before the configured xmpp domain. + val subdomain = domain.subSequence(0, domain.indexOf(xmppDomain, ignoreCase = true)).trim('.') + // Now just grab the call name + val callName = roomJid.localpart.toString() + + // The call url is constructed from the baseCallUrl (base-url if any, or the xmpp domain), an optional subdomain, and a callname like so: + // https://baseCallUrl/subdomain/callName + var baseCallUrl = "https://$xmppDomain" + if (!baseUrl.isNullOrEmpty()) { + baseCallUrl = baseUrl + } + + return when { + subdomain.isEmpty() -> CallUrlInfo(baseCallUrl, callName, "") + else -> CallUrlInfo("$baseCallUrl/$subdomain", callName, subdomain.toString()) + } + } catch (e: Exception) { + throw CallUrlInfoFromJidException( + "Unable to extract call url info from Jid $roomJid (stripFromRoomDomain = $stripFromRoomDomain, " + + "xmppDomain = $xmppDomain)" + ) + } +} + +class CallUrlInfoFromJidException(message: String) : Exception(message) diff --git a/src/main/kotlin/org/jitsi/jibri/util/extensions/SchedulerExecutorServiceExts.kt b/src/main/kotlin/org/jitsi/jibri/util/extensions/SchedulerExecutorServiceExts.kt new file mode 100644 index 0000000..0757014 --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/util/extensions/SchedulerExecutorServiceExts.kt @@ -0,0 +1,44 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util.extensions + +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +/** + * A version of [ScheduledExecutorService.scheduleAtFixedRate] that takes + * a lambda instead of requiring a [Runnable] (and moves it to the last + * argument) + */ +fun ScheduledExecutorService.scheduleAtFixedRate( + period: Long, + unit: TimeUnit, + delay: Long = 0, + action: () -> Unit +): ScheduledFuture<*> = this.scheduleAtFixedRate(action, delay, period, unit) + +/** + * A version of [ScheduledExecutorService.schedule] that takes a lambda + * instead of requiring a [Runnable] (and moves it to the last argument) + */ +fun ScheduledExecutorService.schedule( + delay: Long = 0, + unit: TimeUnit = TimeUnit.SECONDS, + action: () -> Unit +): ScheduledFuture<*> = this.schedule(action, delay, unit) diff --git a/src/main/kotlin/org/jitsi/jibri/webhooks/v1/Events.kt b/src/main/kotlin/org/jitsi/jibri/webhooks/v1/Events.kt new file mode 100644 index 0000000..6d49c7f --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/webhooks/v1/Events.kt @@ -0,0 +1,29 @@ + + +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("ktlint:standard:filename") + +package org.jitsi.jibri.webhooks.v1 + +import org.jitsi.jibri.status.JibriSessionStatus +import org.jitsi.jibri.status.JibriStatus + +sealed class JibriEvent(val jibriId: String) { + class HealthEvent(jibriId: String, val status: JibriStatus) : JibriEvent(jibriId) + class SessionEvent(jibriId: String, val session: JibriSessionStatus) : JibriEvent(jibriId) +} diff --git a/src/main/kotlin/org/jitsi/jibri/webhooks/v1/README.md b/src/main/kotlin/org/jitsi/jibri/webhooks/v1/README.md new file mode 100644 index 0000000..357c3bb --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/webhooks/v1/README.md @@ -0,0 +1,47 @@ +# Jibri 'Webhooks' + +Jibri supports being configured with a list of base URLs on which it will hit certain endpoints with data. These are +not "true" webhooks as the endpoints are hard-coded, instead Jibri defines a "contract" which it expect a subscriber +to implement at the given base URL. Information about this contract is below. + +### Status updates +Jibri pushes status updates consisting of its "busy status" (whether it is busy or idle) and its health. These updates +are sent periodically every minute and every time the status changes. + +URL: `/v1/status` + +method: `POST` + +Data constraints: +```$json +{ + "jibriId":"[String]", + "status":{ + "busyStatus":"[a String value of ComponentBusyStatus: (BUSY|IDLE|EXPIRED)]", + "health": { + "healthStatus":"[a String value of ComponentHealthStatus: (HEALTHY|UNHEALTHY)", + "details": [A map of String to ComponentHealthDetails giving optional details of sub-component's health] + } + } +} +``` + +Data example: +```$json +{ + "jibriId":"jibri_id", + "status":{ + "busyStatus":"IDLE", + "health":{ + "healthStatus":"HEALTHY", + "details":{} + } + } +} +``` + +Success response: + +Code: `200 OK` + +Data: body will be ignored diff --git a/src/main/kotlin/org/jitsi/jibri/webhooks/v1/WebhookClient.kt b/src/main/kotlin/org/jitsi/jibri/webhooks/v1/WebhookClient.kt new file mode 100644 index 0000000..8c2f43c --- /dev/null +++ b/src/main/kotlin/org/jitsi/jibri/webhooks/v1/WebhookClient.kt @@ -0,0 +1,141 @@ +/* + * 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.jibri.webhooks.v1 + +import io.ktor.client.HttpClient +import io.ktor.client.engine.apache5.Apache5 +import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.jackson.jackson +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.jitsi.jibri.config.Config +import org.jitsi.jibri.status.JibriSessionStatus +import org.jitsi.jibri.status.JibriStatus +import org.jitsi.jibri.util.TaskPools +import org.jitsi.jwt.JwtInfo +import org.jitsi.jwt.RefreshingJwt +import org.jitsi.metaconfig.optionalconfig +import org.jitsi.utils.logging2.createLogger +import java.util.concurrent.CopyOnWriteArraySet +import javax.net.ssl.SSLContext + +/** + * A client for notifying subscribers of Jibri events + */ +class WebhookClient( + private val jibriId: String, + client: HttpClient = HttpClient(Apache5) { + engine { + sslContext = SSLContext.getDefault() + } + } +) { + private val logger = createLogger() + private val webhookSubscribers: MutableSet = CopyOnWriteArraySet() + private val jwtInfo: JwtInfo? by optionalconfig { + "jibri.jwt-info".from(Config.configSource) + .convertFrom(JwtInfo.Companion::fromConfig) + } + private val jwt: String? by RefreshingJwt(jwtInfo) + + private val client = client.config { + expectSuccess = false + install(ContentNegotiation) { + jackson {} + } + install(HttpTimeout) { + requestTimeoutMillis = 2000 + } + jwt?.let { + defaultRequest { + bearerAuth(it) + } + } + } + + fun addSubscriber(subscriberBaseUrl: String) { + webhookSubscribers.add(subscriberBaseUrl) + } + + fun removeSubscriber(subscriberBaseUrl: String) { + webhookSubscribers.remove(subscriberBaseUrl) + } + + fun updateStatus(status: JibriStatus) = runBlocking { + logger.debug { "Updating ${webhookSubscribers.size} subscribers of status" } + webhookSubscribers.forEach { subscriberBaseUrl -> + launch(TaskPools.ioPool.asCoroutineDispatcher()) { + logger.debug { "Sending request to $subscriberBaseUrl" } + try { + val resp = client.postJson("$subscriberBaseUrl/v1/status") { + setBody(JibriEvent.HealthEvent(jibriId, status)) + } + logger.debug { "Got response from $subscriberBaseUrl: $resp" } + if (resp.status != HttpStatusCode.OK) { + logger.error("Error updating health for webhook subscriber $subscriberBaseUrl: $resp") + } + } catch (e: HttpRequestTimeoutException) { + logger.error("Request to $subscriberBaseUrl timed out") + } + } + } + } + + fun updateSessionStatus(session: JibriSessionStatus) = runBlocking { + logger.debug { "Updating ${webhookSubscribers.size} subscribers of the session status" } + webhookSubscribers.forEach { subscriberBaseUrl -> + launch(TaskPools.ioPool.asCoroutineDispatcher()) { + logger.debug { "Sending request to $subscriberBaseUrl" } + try { + val resp = client.postJson("$subscriberBaseUrl/v1/session/status") { + setBody(JibriEvent.SessionEvent(jibriId, session)) + } + logger.debug { "Got response from $subscriberBaseUrl: $resp" } + if (resp.status != HttpStatusCode.OK) { + logger.error("Error updating session for webhook subscriber $subscriberBaseUrl: $resp") + } + } catch (e: HttpRequestTimeoutException) { + logger.error("Request to $subscriberBaseUrl timed out") + } + } + } + } +} + +/** + * Just like [HttpClient.post], but automatically sets the content type to + * [ContentType.Application.Json]. + */ +private suspend inline fun HttpClient.postJson( + urlString: String, + block: HttpRequestBuilder.() -> Unit = {} +): HttpResponse = post(urlString) { + block() + contentType(ContentType.Application.Json) +} diff --git a/src/main/resources/recorder.html b/src/main/resources/recorder.html new file mode 100644 index 0000000..5230ea1 --- /dev/null +++ b/src/main/resources/recorder.html @@ -0,0 +1,126 @@ + + + + + Jibri Recorder + + + +
+ + + diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf new file mode 100644 index 0000000..7699b37 --- /dev/null +++ b/src/main/resources/reference.conf @@ -0,0 +1,200 @@ +jibri { + // A unique identifier for this Jibri + // TODO: eventually this will be required with no default + id = "" + // Whether or not Jibri should return to idle state after handling + // (successfully or unsuccessfully) a request. A value of 'true' + // here means that a Jibri will NOT return back to the IDLE state + // and will need to be restarted in order to be used again. + single-use-mode = false + api { + http { + external-api-port = 2222 + external-api-host = "127.0.0.1" + internal-api-port = 3333 + internal-api-host = "127.0.0.1" + } + xmpp { + // See example_xmpp_envs.conf for an example of what is expected here + environments = [] + } + } + recording { + recordings-directory = "/tmp/recordings" + # TODO: make this an optional param and remove the default + finalize-script = "/path/to/finalize" + } + streaming { + // A list of regex patterns for allowed RTMP URLs. The RTMP URL used + // when starting a stream must match at least one of the patterns in + // this list. + rtmp-allow-list = [ + // By default, all services are allowed + ".*" + ] + } + sip { + // The routing rule for the outbound scenario in VoxImplant is based on this prefix + outbound-prefix = "out_" + dtmf-fifo-path = "/tmp/jibri_pjsua_dtmf" + } + // These options control how Jibri generates the ffmpeg command for recording or streaming. Most options are internal + // to this config file, meaning that they are not used directly by the jibri code, which just uses the 4 + // "command-os-type" options defined at the bottom. It also uses "recording-format" to determine the filename. + ffmpeg { + resolution = "1920x1080" + framerate = 30 + gop-size = 60 + // Encoding speed to compression ratio (slower preset -> better compression) + // Available presets: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo + video-encode-preset-streaming = "veryfast" + // Use the legacy property if it's defined. + video-encode-preset-streaming = ${?jibri.ffmpeg.video-encode-preset} + // Prefer larger size at lower CPU cost for file recording. + video-encode-preset-recording = "ultrafast" + // Use the legacy property if it's defined. + video-encode-preset-recording = ${?jibri.ffmpeg.video-encode-preset} + queue-size = 4096 + streaming-max-bitrate = "2976k" + streaming-buf-size = "5952k" + streaming-format = "flv" + recording-format = "mp4" + recording-extension = ${jibri.ffmpeg.recording-format} + // The range of the CRF scale is 0-51, where 0 is lossless, + // 23 is the default, and 51 is worst quality possible. A lower value + // generally leads to higher quality, and a subjectively sane range is + // 17-28. Consider 17 or 18 to be visually lossless or nearly so; + // it should look the same or nearly the same as the input but it + // isn't technically lossless. + // https://trac.ffmpeg.org/wiki/Encode/H.264#crf + h264-constant-rate-factor = 25 + // The audio source that will be used to capture audio on Linux + audio-source = "alsa" + // The audio device that will be used to capture audio on Linux + audio-device = "plug:bsnoop" + + // The path to the ffmpeg binary. + ffmpeg-binary = "ffmpeg" + ffmpeg = [ ${jibri.ffmpeg.ffmpeg-binary}, "-y", "-v", "info" ] + // Input parameters specific to linux + input-linux = [ + "-f", "x11grab" + "-draw_mouse", "0" + "-r", ${jibri.ffmpeg.framerate} + "-s", ${jibri.ffmpeg.resolution} + "-thread_queue_size", ${jibri.ffmpeg.queue-size} + "-i", ":0.0+0,0" + "-f", ${jibri.ffmpeg.audio-source} + "-thread_queue_size", ${jibri.ffmpeg.queue-size} + "-i", ${jibri.ffmpeg.audio-device} + ] + // Input parameters specific to macOS + input-mac = [ + "-thread_queue_size", ${jibri.ffmpeg.queue-size} + "-f", "avfoundation" + "-framerate", ${jibri.ffmpeg.framerate} + "-video_size", ${jibri.ffmpeg.resolution} + // Note the values passed here will need to be changed based on the output of + // ffmpeg -f avfoundation -list_devices true -i "" + "-i", "0:0" + ] + // Audio encoding parameters + audio-params = [ "-acodec", "aac", "-strict", "-2", "-ar", "44100", "-b:a", "128k", "-af", "aresample=async=1" ] + // Video encoding parameters + video-params = [ + "-c:v", "libx264" + "-pix_fmt", "yuv420p", + "-r", ${jibri.ffmpeg.framerate} + "-crf", ${jibri.ffmpeg.h264-constant-rate-factor} + ] + // Video encoding parameters specific to file recording + video-params-recording = ${jibri.ffmpeg.video-params} [ + "-preset", ${jibri.ffmpeg.video-encode-preset-recording} + "-profile:v", "main" + "-level", "3.1" + // TODO: this ffmpeg option is deprecated + "-timestamp", "now" + "-f", ${jibri.ffmpeg.recording-format} + ] + // Video encoding parameters specific to streaming + video-params-streaming = ${jibri.ffmpeg.video-params} [ + "-preset", ${jibri.ffmpeg.video-encode-preset-streaming} + "-tune", "zerolatency" + "-g", ${jibri.ffmpeg.gop-size} + "-maxrate", ${jibri.ffmpeg.streaming-max-bitrate} + "-bufsize", ${jibri.ffmpeg.streaming-buf-size} + // TODO: this ffmpeg option is deprecated (and probably not needed for streaming?) + "-timestamp", "now" + "-f", ${jibri.ffmpeg.streaming-format} + ] + + // These are the final commands that jibri uses. + command-linux-recording = ${jibri.ffmpeg.ffmpeg} ${jibri.ffmpeg.input-linux} ${jibri.ffmpeg.audio-params} ${jibri.ffmpeg.video-params-recording} + command-linux-streaming = ${jibri.ffmpeg.ffmpeg} ${jibri.ffmpeg.input-linux} ${jibri.ffmpeg.audio-params} ${jibri.ffmpeg.video-params-streaming} + command-mac-recording = ${jibri.ffmpeg.ffmpeg} ${jibri.ffmpeg.input-mac} ${jibri.ffmpeg.audio-params} ${jibri.ffmpeg.video-params-recording} + command-mac-streaming = ${jibri.ffmpeg.ffmpeg} ${jibri.ffmpeg.input-mac} ${jibri.ffmpeg.audio-params} ${jibri.ffmpeg.video-params-streaming} + } + // WIP + selenium { + use-external-api = false + } + chrome { + // The flags which will be passed to chromium when launching + flags = [ + "--use-fake-ui-for-media-stream", + "--start-maximized", + "--kiosk", + "--enabled", + "--autoplay-policy=no-user-gesture-required", + "--disable-infobars" + ] + } + stats { + enable-stats-d = true + host = "localhost" + port = 8125 + prometheus { + // Enable a prometheus interface at /metrics + enabled = false + } + } + webhook { + // A list of subscribers interested in receiving webhook events + subscribers = [] + } + jwt-info { + // The path to a .pem file which will be used to sign JWT tokens used in webhook + // requests. If not set, no JWT will be added to webhook requests. + # signing-key-path = "/path/to/key.pem" + + // The kid to use as part of the JWT + # kid = "key-id" + + // The issuer of the JWT + # issuer = "issuer" + + // The audience of the JWT + # audience = "audience" + + // The TTL of each generated JWT. Can't be less than 10 minutes. + # ttl = 1 hour + } + call-status-checks { + // If not all clients have their audio and video muted, but Jibri does not + // detect any data stream (audio or video) comming in, it will stop + // recording after NO_MEDIA_TIMEOUT expires. + no-media-timeout = 3 minutes + + // If all clients have their audio and video muted, Jibri consideres this + // as an empty call and stops the recording after ALL_MUTED_TIMEOUT expires. + all-muted-timeout = 10 minutes + + // When detecting if a call is empty, Jibri takes into consideration for how + // long the call has been empty already. If it has been empty for more than + // DEFAULT_CALL_EMPTY_TIMEOUT, it will consider it empty and stop the recording. + default-call-empty-timeout = 30 seconds + + // If ICE hasn't completed, or stays in a state other than "connected" for this amount of time, Jibri will stop. + ice-connection-timeout = 30 seconds + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/CallUrlInfoTest.kt b/src/test/kotlin/org/jitsi/jibri/CallUrlInfoTest.kt new file mode 100644 index 0000000..5815df9 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/CallUrlInfoTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.data.forAll +import io.kotest.data.headers +import io.kotest.data.row +import io.kotest.data.table +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe + +class CallUrlInfoTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + init { + context("creating a CallUrlInfo") { + context("without url params") { + val info = CallUrlInfo("baseUrl", "callName") + should("assign the fields correctly") { + info.baseUrl shouldBe "baseUrl" + info.callName shouldBe "callName" + info.callUrl shouldBe "baseUrl/callName" + } + } + context("with url params") { + val info = CallUrlInfo("baseUrl", "callName", "", listOf("one", "two", "three")) + should("assign the fields correctly") { + info.baseUrl shouldBe "baseUrl" + info.callName shouldBe "callName" + info.callUrl shouldBe "baseUrl/callName#one&two&three" + } + } + } + context("a nullable CallUrlInfo instance") { + should("not equal null") { + val nullableInfo: CallUrlInfo? = CallUrlInfo("baseUrl", "callName") + nullableInfo shouldNotBe null + } + } + context("equality and hashcode") { + val info = CallUrlInfo("baseUrl", "callName") + context("a CallUrlInfo instance") { + should("not equal another type") { + @Suppress("ReplaceCallWithBinaryOperator") + info.equals("string") shouldBe false + } + context("when compared to other variations") { + should("be equal/not equal where appropriate") { + val duplicateInfo = CallUrlInfo("baseUrl", "callName") + val differentBaseUrl = CallUrlInfo("differentBaseUrl", "callName") + val differentCallName = CallUrlInfo("differentUrl", "differentCallName") + val differentBaseUrlCase = CallUrlInfo("BASEURL", "callName") + val differentCallNameCase = CallUrlInfo("baseUrl", "CALLNAME") + val withUrlParams = CallUrlInfo("baseUrl", "callName", "", listOf("one", "two", "three")) + + val t = table( + headers("left", "right", "shouldEqual"), + row(info, info, true), + row(info, duplicateInfo, true), + row(info, differentBaseUrl, false), + row(info, differentCallName, false), + row(info, differentBaseUrlCase, true), + row(info, differentCallNameCase, true), + row(info, withUrlParams, true) + ) + forAll(t) { left, right, shouldEqual -> + if (shouldEqual) { + left shouldBe right + left.hashCode() shouldBe right.hashCode() + } else { + left shouldNotBe right + left.hashCode() shouldNotBe right.hashCode() + } + } + } + } + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/KotestProjectConfig.kt b/src/test/kotlin/org/jitsi/jibri/KotestProjectConfig.kt new file mode 100644 index 0000000..a84bf97 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/KotestProjectConfig.kt @@ -0,0 +1,37 @@ +/* + * 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.jibri + +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.extensions.Extension +import io.kotest.extensions.junitxml.JunitXmlReporter + +class KotestProjectConfig : AbstractProjectConfig() { + override fun extensions(): List = listOf( + /* + * The JunitXmlReporter writes a junit5 compatible unit test output + * but with the full scope of tests as their name, unlike the default + * one which only includes the 'should' block from kotest tests as + * the name. See https://kotest.io/docs/extensions/junit_xml.html. + */ + JunitXmlReporter( + includeContainers = false, + useTestPathAsName = true, + outputDir = "full-test-name-test-results" + ) + ) +} diff --git a/src/test/kotlin/org/jitsi/jibri/api/http/HttpApiTest.kt b/src/test/kotlin/org/jitsi/jibri/api/http/HttpApiTest.kt new file mode 100644 index 0000000..bf80864 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/api/http/HttpApiTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.api.http + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.runTestApplication +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.jitsi.jibri.CallUrlInfo +import org.jitsi.jibri.JibriManager +import org.jitsi.jibri.RecordingSinkType +import org.jitsi.jibri.config.XmppCredentials +import org.jitsi.jibri.health.EnvironmentContext +import org.jitsi.jibri.health.JibriHealth +import org.jitsi.jibri.selenium.CallParams +import org.jitsi.jibri.service.ServiceParams +import org.jitsi.jibri.status.ComponentBusyStatus +import org.jitsi.jibri.status.ComponentHealthStatus +import org.jitsi.jibri.status.JibriStatus +import org.jitsi.jibri.status.JibriStatusManager +import org.jitsi.jibri.status.OverallHealth +import org.jitsi.jibri.webhooks.v1.WebhookClient + +class HttpApiTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + private val jibriManager: JibriManager = mockk() + private val jibriStatusManager: JibriStatusManager = mockk() + private val webhookClient: WebhookClient = mockk() + + private val api = HttpApi(jibriManager, jibriStatusManager, webhookClient) + + init { + context("health") { + context("when jibri isn't busy") { + val expectedStatus = + JibriStatus(ComponentBusyStatus.IDLE, OverallHealth(ComponentHealthStatus.HEALTHY, mapOf())) + val expectedHealth = JibriHealth(expectedStatus) + + every { jibriManager.currentEnvironmentContext } returns null + every { jibriStatusManager.overallStatus } returns expectedStatus + + apiTest { + val response = client.get("/jibri/api/v1.0/health") + + should("call JibriStatusManager#overallStatus") { + verify { jibriStatusManager.overallStatus } + } + should("call JibriManager#currentEnvironmentContext") { + verify { jibriManager.currentEnvironmentContext } + } + should("return a status of 200") { + response.status shouldBe HttpStatusCode.OK + } + should("return the right json body") { + // The json should not include the 'environmentContext' field at all, since it + // will be null + response.bodyAsText() shouldNotContain "environmentContext" + val health = jacksonObjectMapper().readValue(response.bodyAsText(), JibriHealth::class.java) + health shouldBe expectedHealth + } + } + } + context("when jibri is busy and has an environmentContext") { + val expectedStatus = + JibriStatus(ComponentBusyStatus.BUSY, OverallHealth(ComponentHealthStatus.HEALTHY, mapOf())) + val expectedEnvironmentContext = EnvironmentContext("meet.jit.si") + val expectedHealth = JibriHealth(expectedStatus, expectedEnvironmentContext) + + every { jibriManager.currentEnvironmentContext } returns expectedEnvironmentContext + every { jibriStatusManager.overallStatus } returns expectedStatus + + apiTest { + val response = client.get("/jibri/api/v1.0/health") + + should("return a status of 200") { + response.status shouldBe HttpStatusCode.OK + } + should("return the right json body") { + response.bodyAsText() shouldContain "environmentContext" + val health = jacksonObjectMapper().readValue(response.bodyAsText(), JibriHealth::class.java) + health shouldBe expectedHealth + } + } + } + } + context("startService") { + context("start file recording") { + val capturedServiceParams = slot() + every { + jibriManager.startFileRecording( + capture(capturedServiceParams), + any(), + any(), + any() + ) + } just Runs + val startServiceRequest = StartServiceParams( + sessionId = "session_id", + callParams = CallParams( + callUrlInfo = CallUrlInfo("https://meet.jit.si", "callName") + ), + callLoginParams = XmppCredentials( + domain = "xmpp_domain", + username = "xmpp_username", + password = "xmpp_password" + ), + sinkType = RecordingSinkType.FILE + ) + val json = jacksonObjectMapper().writeValueAsString(startServiceRequest) + apiTest { + client.post("/jibri/api/v1.0/startService") { + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(json) + } + + should("call JibriManager#startFileRecording with the right params") { + capturedServiceParams.captured.usageTimeoutMinutes shouldBe 0 + } + } + } + } + } + private suspend fun apiTest(block: suspend ApplicationTestBuilder.() -> Unit) { + runTestApplication { + application { + with(api) { + apiModule() + } + } + block() + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/api/http/internal/InternalHttpApiTest.kt b/src/test/kotlin/org/jitsi/jibri/api/http/internal/InternalHttpApiTest.kt new file mode 100644 index 0000000..25aacee --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/api/http/internal/InternalHttpApiTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.api.http.internal + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.ktor.client.request.post +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication + +class InternalHttpApiTest : FunSpec() { + + private var gracefulShutdownHandlerCalls = 0 + private var shutdownHandlerCalls = 0 + private var configChangedHandlerCalls = 0 + + private val internalApi = InternalHttpApi( + { configChangedHandlerCalls++ }, + { gracefulShutdownHandlerCalls++ }, + { shutdownHandlerCalls++ } + ) + + init { + isolationMode = IsolationMode.InstancePerLeaf + + test("gracefulShutdown should return a 200 and invoke the graceful shutdown handler") { + apiTest { + val response = client.post("/jibri/api/internal/v1.0/gracefulShutdown") + response.status shouldBe HttpStatusCode.OK + gracefulShutdownHandlerCalls shouldBe 1 + configChangedHandlerCalls shouldBe 0 + shutdownHandlerCalls shouldBe 0 + } + } + + test("notifyConfigChanged should return a 200 and invoke the config changed handler") { + apiTest { + val response = client.post("/jibri/api/internal/v1.0/notifyConfigChanged") + response.status shouldBe HttpStatusCode.OK + gracefulShutdownHandlerCalls shouldBe 0 + configChangedHandlerCalls shouldBe 1 + shutdownHandlerCalls shouldBe 0 + } + } + + test("shutdown should return a 200 and invoke the shutdown handler") { + apiTest { + val response = client.post("/jibri/api/internal/v1.0/shutdown") + response.status shouldBe HttpStatusCode.OK + gracefulShutdownHandlerCalls shouldBe 0 + configChangedHandlerCalls shouldBe 0 + shutdownHandlerCalls shouldBe 1 + } + } + } + private fun apiTest(block: suspend ApplicationTestBuilder.() -> Unit) { + with(internalApi) { + testApplication { + application { + internalApiModule() + } + block() + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/api/xmpp/XmppApiTest.kt b/src/test/kotlin/org/jitsi/jibri/api/xmpp/XmppApiTest.kt new file mode 100644 index 0000000..d03a63d --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/api/xmpp/XmppApiTest.kt @@ -0,0 +1,220 @@ +/* + * 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.jibri.api.xmpp + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.beInstanceOf +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.jitsi.jibri.JibriBusyException +import org.jitsi.jibri.JibriManager +import org.jitsi.jibri.config.XmppCredentials +import org.jitsi.jibri.config.XmppEnvironmentConfig +import org.jitsi.jibri.config.XmppMuc +import org.jitsi.jibri.helpers.inPlaceExecutor +import org.jitsi.jibri.helpers.resetIoPool +import org.jitsi.jibri.helpers.setIoPool +import org.jitsi.jibri.service.AppData +import org.jitsi.jibri.service.JibriServiceStatusHandler +import org.jitsi.jibri.service.ServiceParams +import org.jitsi.jibri.status.ComponentBusyStatus +import org.jitsi.jibri.status.ComponentHealthStatus +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.status.JibriStatus +import org.jitsi.jibri.status.JibriStatusManager +import org.jitsi.jibri.status.OverallHealth +import org.jitsi.jibri.util.TaskPools +import org.jitsi.xmpp.extensions.jibri.JibriIq +import org.jitsi.xmpp.mucclient.MucClient +import org.jitsi.xmpp.mucclient.MucClientManager +import org.jivesoftware.smack.packet.IQ +import org.jivesoftware.smack.packet.Stanza +import org.jivesoftware.smack.packet.StanzaError +import org.jxmpp.jid.impl.JidCreate + +class XmppApiTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + private fun createJibriIq(iqAction: JibriIq.Action, iqMode: JibriIq.RecordingMode? = null): JibriIq = + JibriIq().apply { + iqMode?.let { + recordingMode = it + } + action = iqAction + // Note that the domain used below must match the ones in the xmpp env config + from = JidCreate.from("from_jid@xmppDomain") + to = JidCreate.from("to_jid@xmppDomain") + room = JidCreate.entityBareFrom("room_jid@xmppDomain") + sessionId = "session_id" + } + + init { + val jibriManager: JibriManager = mockk(relaxed = true) + val xmppConfig = XmppEnvironmentConfig( + name = "xmppEnvName", + xmppServerHosts = listOf("xmppServerHost1", "xmppServerHost2:6222"), + xmppDomain = "xmppDomain", + baseUrl = "baseUrl", + controlLogin = XmppCredentials( + domain = "controlXmppDomain", + username = "xmppUsername", + password = "xmppPassword", + port = 5222 + ), + controlMuc = XmppMuc( + domain = "xmppMucDomain", + roomName = "xmppMucRoomName", + nickname = "xmppMucNickname" + ), + sipControlMuc = XmppMuc( + domain = "xmppSipMucDomain", + roomName = "xmppSipMucRoomName", + nickname = "xmppSipMucNickname" + ), + callLogin = XmppCredentials( + domain = "callXmppDomain", + username = "xmppCallUsername", + password = "xmppCallPassword" + ), + stripFromRoomDomain = "", + usageTimeoutMins = 0, + trustAllXmppCerts = true + ) + val jibriStatusManager: JibriStatusManager = mockk(relaxed = true) + // the initial status is idle + val expectedStatus = + JibriStatus(ComponentBusyStatus.IDLE, OverallHealth(ComponentHealthStatus.HEALTHY, mapOf())) + every { jibriStatusManager.overallStatus } returns expectedStatus + + beforeSpec { + TaskPools.setIoPool(inPlaceExecutor) + } + + afterSpec { + TaskPools.resetIoPool() + } + + context("xmppApi") { + val xmppApi = XmppApi(jibriManager, listOf(xmppConfig), jibriStatusManager) + val mucClientManager: MucClientManager = mockk(relaxed = true) + // A dummy MucClient we'll use to be the one incoming messages are received on + val mucClient: MucClient = mockk(relaxed = true) + every { mucClient.id } returns xmppConfig.xmppServerHosts.first() + xmppApi.start(mucClientManager) + should("add itself as the IQ listener for the MUC client manager") { + verify { mucClientManager.setIQListener(xmppApi) } + } + should("create a muc client for each xmpp host") { + verify(exactly = 1) { mucClientManager.addMucClient(match { it.port == "5222" }) } + verify(exactly = 1) { mucClientManager.addMucClient(match { it.port == "6222" }) } + } + + context("when receiving a start recording iq") { + val jibriIq = createJibriIq(JibriIq.Action.START, JibriIq.RecordingMode.FILE) + context("and jibri is idle") { + val statusHandler = slot() + every { jibriManager.startFileRecording(any(), any(), any(), capture(statusHandler)) } just Runs + val response = xmppApi.handleIq(jibriIq, mucClient) + should("send a pending response to the original IQ request") { + response shouldNotBe null + response should beInstanceOf(JibriIq::class) + response as JibriIq + response.status shouldBe JibriIq.Status.PENDING + } + context("after the service starts up") { + statusHandler.captured(ComponentState.Running) + should("send a success response") { + val sentStanzas = mutableListOf() + verify { mucClient.sendStanza(capture(sentStanzas)) } + sentStanzas.size shouldBe 1 + val stanza = sentStanzas.first() + stanza should beInstanceOf() + stanza as JibriIq + stanza.status shouldBe JibriIq.Status.ON + } + context("and it is stopped") { +// whenever(jibriManager.stopService()) doAnswer {} + val stopIq = createJibriIq(JibriIq.Action.STOP) + val stopResponse = xmppApi.handleIq(stopIq, mucClient) + should("respond correctly") { + verify { jibriManager.stopService() } + stopResponse shouldBeResponseTo stopIq + stopResponse should beInstanceOf() + stopResponse as JibriIq + stopResponse.status shouldBe JibriIq.Status.OFF + } + } + } + } + context("and jibri is busy") { + every { jibriManager.startFileRecording(any(), any(), any(), any()) } throws JibriBusyException() + should("send an error IQ") { + val response = xmppApi.handleIq(jibriIq, mucClient) + response should beInstanceOf() + response as JibriIq + response.status shouldBe JibriIq.Status.OFF + response.failureReason shouldBe JibriIq.FailureReason.BUSY + response.shouldRetry shouldBe true + } + } + context("with application data") { + val fileMetaData = mapOf( + "file_recording_metadata" to mapOf( + "upload_credentials" to mapOf( + "service_name" to "file_service", + "token" to "file_service_token" + ) + ) + ) + val appData = AppData(fileRecordingMetadata = fileMetaData) + val jsonString = jacksonObjectMapper().writeValueAsString(appData) + jibriIq.appData = jsonString + + val serviceParams = mutableListOf() + every { jibriManager.startFileRecording(capture(serviceParams), any(), any(), any()) } just Runs + xmppApi.handleIq(jibriIq, mucClient) + should("parse and pass the app data") { + serviceParams.size shouldBe 1 + serviceParams.first().appData shouldBe appData + } + } + context("from a muc client it doesn't recognize") { + val unknownMucClient: MucClient = mockk() + every { unknownMucClient.id } returns "unknown name" + val result = xmppApi.handleIq(jibriIq, unknownMucClient) + result.error shouldNotBe null + result.error.condition shouldBe StanzaError.Condition.bad_request + } + } + } + } +} + +private infix fun IQ.shouldBeResponseTo(req: IQ) { + from.toString() shouldBe req.to.toString() + to.toString() shouldBe req.from.toString() +} diff --git a/src/test/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegCapturerTest.kt b/src/test/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegCapturerTest.kt new file mode 100644 index 0000000..028cfca --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegCapturerTest.kt @@ -0,0 +1,185 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.capture.ffmpeg + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.contain +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.beInstanceOf +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.jitsi.jibri.capture.UnsupportedOsException +import org.jitsi.jibri.sink.Sink +import org.jitsi.jibri.sink.impl.FileSink +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.status.ErrorScope +import org.jitsi.jibri.util.JibriSubprocess +import org.jitsi.jibri.util.OsDetector +import org.jitsi.jibri.util.OsType +import org.jitsi.jibri.util.ProcessExited +import org.jitsi.jibri.util.ProcessFailedToStart +import org.jitsi.jibri.util.ProcessRunning +import org.jitsi.jibri.util.ProcessState +import org.jitsi.utils.logging2.Logger + +internal class FfmpegCapturerTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + private val osDetector: OsDetector = mockk() + private val ffmpeg: JibriSubprocess = mockk(relaxed = true) + private val ffmpegStateHandler = slot<(ProcessState) -> Unit>() + private val capturerStateUpdates = mutableListOf() + private val sink: Sink = FileSink(mockk(relaxed = true), "path") + private val logger: Logger = mockk(relaxed = true) + + private val ffmpegEncodingState = ProcessState(ProcessRunning(), "frame=42") + private val ffmpegErrorState = ProcessState(ProcessExited(255), "rtmp://blah Input/output error") + private val ffmpegFailedToStart = ProcessState(ProcessFailedToStart(), "Failed to start") + + private fun createCapturer(): FfmpegCapturer { + val capturer = FfmpegCapturer(logger, osDetector, ffmpeg) + capturer.addStatusHandler { status -> + capturerStateUpdates.add(status) + } + return capturer + } + + init { + beforeSpec { + every { ffmpeg.addStatusHandler(capture(ffmpegStateHandler)) } just Runs + } + context("on any supported platform") { + // We arbitrarily use linux + every { osDetector.getOsType() } returns OsType.LINUX + val ffmpegCapturer = createCapturer() + context("when launching ffmpeg succeeds") { + every { ffmpeg.launch(any(), any()) } answers { + ffmpegStateHandler.captured(ffmpegEncodingState) + } + ffmpegCapturer.start(sink) + context("capturer") { + should("report its status as running") { + capturerStateUpdates.shouldNotBeEmpty() + capturerStateUpdates.last().shouldBeInstanceOf() + } + } + context("and then finishes cleanly") { + ffmpegStateHandler.captured(ProcessState(ProcessRunning(), "Exiting with signal 2")) + context("capturer") { + should("report its status as finished") { + capturerStateUpdates.last().shouldBeInstanceOf() + } + } + } + context("and then encounters an error") { + ffmpegStateHandler.captured(ffmpegErrorState) + context("capturer") { + should("report its status as error") { + capturerStateUpdates.last().shouldBeInstanceOf() + } + } + } + context("and quits abruptly") { + context("with a normal last output line") { + ffmpegStateHandler.captured(ProcessState(ProcessExited(139), "frame=42")) + context("capture") { + should("report its stats as error") { + val error = capturerStateUpdates.last() + error should beInstanceOf() + error as ComponentState.Error + error.error.scope shouldBe ErrorScope.SESSION + } + } + } + context("with an unknown output line") { + ffmpegStateHandler.captured(ProcessState(ProcessExited(139), "something!")) + context("capture") { + should("report its stats as error") { + val error = capturerStateUpdates.last() + error should beInstanceOf() + error as ComponentState.Error + error.error.scope shouldBe ErrorScope.SESSION + } + } + } + } + } + context("when ffmpeg fails to start") { + every { ffmpeg.launch(any(), any()) } answers { + ffmpegStateHandler.captured(ffmpegFailedToStart) + } + ffmpegCapturer.start(sink) + context("capturer") { + should("report its status as a system error") { + capturerStateUpdates.shouldNotBeEmpty() + val status = capturerStateUpdates.last() + status should beInstanceOf() + status as ComponentState.Error + status.error.scope shouldBe ErrorScope.SYSTEM + } + } + } + context("stop") { + should("call stop on the executor") { + ffmpegCapturer.stop() + verify { ffmpeg.stop() } + } + } + } + context("on linux") { + every { osDetector.getOsType() } returns OsType.LINUX + val ffmpegCapturer = createCapturer() + context("the command") { + should("be correct for linux") { + ffmpegCapturer.start(sink) + val commandCaptor = slot>() + verify { ffmpeg.launch(capture(commandCaptor), any()) } + commandCaptor.captured should contain("x11grab") + commandCaptor.captured should contain("alsa") + } + } + } + context("on mac") { + every { osDetector.getOsType() } returns OsType.MAC + val ffmpegCapturer = createCapturer() + context("the command") { + should("be correct for mac") { + ffmpegCapturer.start(sink) + val commandCaptor = slot>() + verify { ffmpeg.launch(capture(commandCaptor), any()) } + commandCaptor.captured should contain("avfoundation") + } + } + } + context("on an unsupported platform") { + every { osDetector.getOsType() } returns OsType.UNSUPPORTED + shouldThrow { + FfmpegCapturer(logger, osDetector, ffmpeg) + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/capture/ffmpeg/executor/OutputParserTest.kt b/src/test/kotlin/org/jitsi/jibri/capture/ffmpeg/executor/OutputParserTest.kt new file mode 100644 index 0000000..680d1e0 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/capture/ffmpeg/executor/OutputParserTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.capture.ffmpeg.executor + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.beInstanceOf +import org.jitsi.jibri.capture.ffmpeg.FfmpegErrorStatus +import org.jitsi.jibri.capture.ffmpeg.OutputLineClassification +import org.jitsi.jibri.capture.ffmpeg.OutputParser +import org.jitsi.jibri.status.ErrorScope + +internal class OutputParserTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + init { + context("An encoding output line") { + val outputLine = + "frame= 95 fps= 31 q=27.0 size= 584kB time=00:00:03.60 bitrate=1329.4kbits/s speed=1.19x" +// val expectedValues = mapOf( +// "frame" to "95", +// "fps" to "31", +// "q" to "27.0", +// "size" to "584kB", +// "time" to "00:00:03.60", +// "bitrate" to "1329.4kbits/s", +// "speed" to "1.19x" +// ) + + should("be parsed correctly") { + val status = OutputParser.parse(outputLine) + status.lineType shouldBe OutputLineClassification.ENCODING + status.detail shouldBe outputLine + } + } + context("A warning output line") { + val outputLine = "Past duration 0.622368 too large" + should("be parsed correctly") { + val status = OutputParser.parse(outputLine) + status.lineType shouldBe OutputLineClassification.UNKNOWN + status.detail.shouldBe(outputLine) + } + } + context("An error output line") { + context("with an error on the session scope") { + val outputLine = "rtmp://a.rtmp.youtube.com/live2/dkafkjlafkjhsadf: Input/output error" + should("be parsed correctly") { + val status = OutputParser.parse(outputLine) + status should beInstanceOf() + status as FfmpegErrorStatus + status.detail.shouldBe(outputLine) + status.error.scope shouldBe ErrorScope.SESSION + } + } + } + context("A broken pipe output line") { + val outputLine = "av_interleaved_write_frame(): Broken pipe" + should("be parsed correctly") { + val status = OutputParser.parse(outputLine) + status should beInstanceOf() + status as FfmpegErrorStatus + status.detail.shouldBe(outputLine) + status.error.scope shouldBe ErrorScope.SESSION + } + } + context("An unexpected exit output line") { + val outputLine = "Exiting normally, received signal 15." + should("be parsed correctly") { + val status = OutputParser.parse(outputLine) + status should beInstanceOf() + status as FfmpegErrorStatus + status.detail.shouldBe(outputLine) + status.error.scope shouldBe ErrorScope.SESSION + } + } + context("An expected exit output line") { + val outputLine = "Exiting normally, received signal 2." + should("be parsed correctly") { + val status = OutputParser.parse(outputLine) + status.lineType shouldBe OutputLineClassification.FINISHED + status.detail.shouldBe(outputLine) + } + } + context("An unknonwn output line") { + val outputLine = "some unknown ffmpeg status" + should("be parsed correctly") { + val status = OutputParser.parse(outputLine) + status.lineType shouldBe OutputLineClassification.UNKNOWN + status.detail.shouldBe(outputLine) + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/helpers/FinalizeMockHelper.kt b/src/test/kotlin/org/jitsi/jibri/helpers/FinalizeMockHelper.kt new file mode 100644 index 0000000..ace3620 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/helpers/FinalizeMockHelper.kt @@ -0,0 +1,51 @@ +/* + * 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.jibri.helpers + +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jibri.util.ProcessWrapper +import java.io.PipedInputStream +import java.io.PipedOutputStream + +fun createFinalizeProcessMock(shouldSucceed: Boolean): ProcessWrapper { + val op = PipedOutputStream() + val stdOut = PipedInputStream(op) + return mockk { + every { getOutput() } returns stdOut + every { waitFor() } answers { + if (shouldSucceed) { + 0 + } else { + 1 + } + } + every { exitValue } answers { + if (shouldSucceed) { + 0 + } else { + 1 + } + } + + every { start() } answers { + // Finish instantly and close the output stream so the task waiting on it to finish logging + // doesn't have to block for long. + op.close() + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/helpers/Helpers.kt b/src/test/kotlin/org/jitsi/jibri/helpers/Helpers.kt new file mode 100644 index 0000000..20d8831 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/helpers/Helpers.kt @@ -0,0 +1,93 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.jitsi.jibri.helpers + +import io.mockk.every +import io.mockk.mockk +import org.jitsi.jibri.util.LoggingUtils +import org.jitsi.jibri.util.ProcessWrapper +import org.jitsi.jibri.util.TaskPools +import org.jitsi.utils.logging2.Logger +import java.time.Duration +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService +import java.util.concurrent.Future +import java.util.concurrent.ScheduledExecutorService + +/** + * Custom version of kotlin.test's [io.kotlintest.eventually] which uses milliseconds + * and adds a wait between checks (and gives me much more consistent results than + * kotlin.test's version) + */ +fun within(duration: Duration, func: () -> T): T { + val end = System.currentTimeMillis() + duration.toMillis() + var times = 0 + while (System.currentTimeMillis() < end) { + try { + return func() + } catch (e: Throwable) { + if (!AssertionError::class.java.isAssignableFrom(e.javaClass)) { + // Not the kind of exception we were prepared to tolerate + throw e + } + // else ignore and continue + } + times++ + Thread.sleep(500) + } + throw AssertionError("Test failed after ${duration.seconds} seconds; attempted $times times") +} + +val Int.seconds: Duration + get() = Duration.ofSeconds(this.toLong()) +val Int.minutes: Duration + get() = Duration.ofMinutes(this.toLong()) + +fun LoggingUtils.Companion.setTestOutputLogger(outputLogger: (ProcessWrapper, Logger) -> Future) { + logOutput = outputLogger +} + +fun LoggingUtils.Companion.resetOutputLogger() { + logOutput = OutputLogger +} + +fun TaskPools.Companion.setIoPool(pool: ExecutorService) { + ioPool = pool +} + +// Execute tasks in place (in the current thread, blocking) +val inPlaceExecutor: ExecutorService = mockk { + every { submit(any()) } answers { + firstArg().run() + CompletableFuture() + } + every { execute(any()) } answers { + firstArg().run() + } +} + +fun TaskPools.Companion.resetIoPool() { + ioPool = DefaultIoPool +} + +fun TaskPools.Companion.setScheduledPool(pool: ScheduledExecutorService) { + recurringTasksPool = pool +} + +fun TaskPools.Companion.resetScheduledPool() { + recurringTasksPool = DefaultRecurringTaskPool +} diff --git a/src/test/kotlin/org/jitsi/jibri/helpers/SeleniumMockHelper.kt b/src/test/kotlin/org/jitsi/jibri/helpers/SeleniumMockHelper.kt new file mode 100644 index 0000000..0b4b9c9 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/helpers/SeleniumMockHelper.kt @@ -0,0 +1,33 @@ +package org.jitsi.jibri.helpers + +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.selenium.JibriSelenium +import org.jitsi.jibri.status.ComponentState + +class SeleniumMockHelper { + private val eventHandlers = mutableListOf<(ComponentState) -> Boolean>() + + val mock: JibriSelenium = mockk(relaxed = true) { + every { addTemporaryHandler(capture(eventHandlers)) } just Runs + every { addStatusHandler(captureLambda()) } answers { + // This behavior mimics what's done in StatusPublisher#addStatusHandler + eventHandlers.add { + lambda<(ComponentState) -> Unit>().captured(it) + true + } + } + every { setParticipantProperties(any()) } returns true + } + + fun startSuccessfully() { + eventHandlers.forEach { it(ComponentState.Running) } + } + + fun error(error: JibriError) { + eventHandlers.forEach { it(ComponentState.Error(error)) } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/selenium/SeleniumStateMachineTest.kt b/src/test/kotlin/org/jitsi/jibri/selenium/SeleniumStateMachineTest.kt new file mode 100644 index 0000000..613b9cd --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/selenium/SeleniumStateMachineTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright @ 2019 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jibri.selenium + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.haveSize +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.beInstanceOf +import org.jitsi.jibri.status.ComponentState + +internal class SeleniumStateMachineTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + private val stateUpdates = mutableListOf>() + private val seleniumStateMachine = SeleniumStateMachine() + + init { + beforeSpec { + seleniumStateMachine.onStateTransition { fromState, toState -> + stateUpdates.add((fromState to toState)) + } + } + + context("When starting up") { + context("and the call is joined") { + seleniumStateMachine.transition(SeleniumEvent.CallJoined) + should("transition to running") { + stateUpdates should haveSize(1) + stateUpdates.first() shouldBe (ComponentState.StartingUp to ComponentState.Running) + } + } + context("and an error occurs") { + seleniumStateMachine.transition(SeleniumEvent.FailedToJoinCall) + should("transition to error") { + stateUpdates should haveSize(1) + stateUpdates.first().second should beInstanceOf() + } + context("and then another event occurs") { + seleniumStateMachine.transition(SeleniumEvent.CallEmpty) + should("not fire another update") { + stateUpdates should haveSize(1) + } + } + } + context("and an invalid event occurs") { + should("throw an exception") { + shouldThrow { + seleniumStateMachine.transition(SeleniumEvent.NoMediaReceived) + } + } + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPageTest.kt b/src/test/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPageTest.kt new file mode 100644 index 0000000..711862f --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPageTest.kt @@ -0,0 +1,126 @@ +package org.jitsi.jibri.selenium.pageobjects + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.data.forAll +import io.kotest.data.headers +import io.kotest.data.row +import io.kotest.data.table +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.jitsi.jibri.CallUrlInfo +import org.openqa.selenium.remote.RemoteWebDriver + +internal class ExternalAPIPageTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + init { + val driver = mockk(relaxed = true) + val page = ExternalAPIPage(driver) + val callUrl = CallUrlInfo("https://meet.example.com", "testroom", "") + + should("return true when conference joined") { + every { driver.get(any()) } returns Unit + every { driver.executeScript("return window.jibriPageState?.apiError;") } returns null + every { driver.executeScript("return window.jibriPageState?.conferenceJoined === true;") } returns true + + page.visit(callUrl) shouldBe true + } + + should("return false when conference not joined") { + every { driver.get(any()) } returns Unit + every { driver.executeScript("return window.jibriPageState?.apiError;") } returns null + every { driver.executeScript("return window.jibriPageState?.conferenceJoined === true;") } returns false + + page.visit(callUrl) shouldBe false + } + + should("extract room name and pass tenant from URL path") { + val t = table( + headers("input", "room", "tenant"), + row("roomname", "roomname", ""), + row("tenant/roomname", "roomname", "tenant"), + row("tenant/abc/roomname", "roomname", "abc"), + row("org/tenant/abc/test", "test", "abc") + ) + forAll(t) { input, room, tenant -> + val urlSlot = slot() + every { driver.get(capture(urlSlot)) } returns Unit + every { driver.executeScript("return window.jibriPageState?.apiError;") } returns null + every { driver.executeScript("return window.jibriPageState?.conferenceJoined === true;") } returns true + + page.visit(CallUrlInfo("https://meet.example.com", input, tenant)) shouldBe true + urlSlot.captured.shouldContain("room=$room") + if (tenant.isNotEmpty()) { + urlSlot.captured.shouldContain("tenant=$tenant") + } + } + } + + should("derive tenant from baseUrl path when tenant is not explicitly set") { + // The HTTP startService API only sets baseUrl/callName, leaving tenant empty + // even when baseUrl's path carries the tenant segment. + val urlSlot = slot() + every { driver.get(capture(urlSlot)) } returns Unit + every { driver.executeScript("return window.jibriPageState?.apiError;") } returns null + every { driver.executeScript("return window.jibriPageState?.conferenceJoined === true;") } returns true + + page.visit(CallUrlInfo("https://meet.example.com/tenant-test", "room-test", "")) shouldBe true + urlSlot.captured.shouldContain("room=room-test") + urlSlot.captured.shouldContain("tenant=tenant-test") + } + + should("return false if apiError occurs") { + val t = table( + headers("errorAtCall", "scenario"), + row(1, "at initialization"), + row(4, "during polling after 3 cycles") + ) + forAll(t) { errorAtCall, scenario -> + every { driver.get(any()) } returns Unit + var callCount = 0 + every { driver.executeScript("return window.jibriPageState?.apiError;") } answers { + callCount++ + if (callCount >= errorAtCall) "API error" else null + } + every { driver.executeScript("return window.jibriPageState?.conferenceJoined === true;") } returns false + + page.visit(callUrl) shouldBe false + callCount shouldBe errorAtCall + } + } + + should("return participant count") { + every { driver.executeAsyncScript(any()) } returns 3L + + page.getNumParticipants() shouldBe 3 + } + + should("return true when only recorder present") { + every { driver.executeAsyncScript(any()) } returns 1L + + page.isCallEmpty() shouldBe true + } + + should("return false when participants present") { + every { driver.executeAsyncScript(any()) } returns 2L + + page.isCallEmpty() shouldBe false + } + + should("return number of jigasi participants") { + every { driver.executeAsyncScript(any()) } returns 2L + + page.numRemoteParticipantsJigasi() shouldBe 2 + } + + should("return 0 jigasi participants when script fails") { + every { driver.executeAsyncScript(any()) } throws RuntimeException("script error") + + page.numRemoteParticipantsJigasi() shouldBe 0 + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/EmptyCallStatusCheckTest.kt b/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/EmptyCallStatusCheckTest.kt new file mode 100644 index 0000000..43e329b --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/EmptyCallStatusCheckTest.kt @@ -0,0 +1,107 @@ +/* + * 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.jibri.selenium.status_checks + +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.spyk +import org.jitsi.jibri.helpers.minutes +import org.jitsi.jibri.helpers.seconds +import org.jitsi.jibri.selenium.SeleniumEvent +import org.jitsi.jibri.selenium.pageobjects.CallPage +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.time.FakeClock +import java.time.Duration + +internal class EmptyCallStatusCheckTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + private val clock: FakeClock = spyk() + private val callPage: CallPage = mockk() + private val logger: Logger = mockk(relaxed = true) + + private val check = EmptyCallStatusCheck(logger, clock = clock) + + init { + context("when the call was always empty") { + every { callPage.isCallEmpty() } returns true + context("the check") { + should("return empty after the timeout") { + check.run(callPage) shouldBe null + clock.elapse(15.seconds) + check.run(callPage) shouldBe null + clock.elapse(20.seconds) + check.run(callPage) shouldBe SeleniumEvent.CallEmpty + } + } + } + context("when the call has participants") { + every { callPage.isCallEmpty() } returns false + clock.elapse(5.minutes) + context("the check") { + should("never return empty") { + check.run(callPage) shouldBe null + clock.elapse(10.minutes) + check.run(callPage) shouldBe null + } + } + context("and then goes empty") { + every { callPage.isCallEmpty() } returns true + clock.elapse(20.seconds) + context("the check") { + should("return empty after the timeout") { + check.run(callPage) shouldBe null + clock.elapse(31.seconds) + check.run(callPage) shouldBe SeleniumEvent.CallEmpty + } + } + context("and then has participants again") { + every { callPage.isCallEmpty() } returns false + // Some time passed and the check ran once with no participants + clock.elapse(30.seconds) + context("the check") { + should("never return empty") { + check.run(callPage) shouldBe null + clock.elapse(10.minutes) + check.run(callPage) shouldBe null + } + } + } + } + } + context("when a custom timeout is passed") { + every { callPage.isCallEmpty() } returns true + val customTimeoutCheck = EmptyCallStatusCheck(logger, Duration.ofMinutes(10), clock) + context("the check") { + should("return empty after the timeout") { + customTimeoutCheck.run(callPage) shouldBe null + clock.elapse(15.seconds) + customTimeoutCheck.run(callPage) shouldBe null + clock.elapse(45.seconds) + customTimeoutCheck.run(callPage) shouldBe null + clock.elapse(8.minutes) + customTimeoutCheck.run(callPage) shouldBe null + clock.elapse(61.seconds) + customTimeoutCheck.run(callPage) shouldBe SeleniumEvent.CallEmpty + } + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/IceConnectionStatusCheckTest.kt b/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/IceConnectionStatusCheckTest.kt new file mode 100644 index 0000000..b16c17f --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/IceConnectionStatusCheckTest.kt @@ -0,0 +1,65 @@ +package org.jitsi.jibri.selenium.status_checks + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import org.jitsi.jibri.selenium.SeleniumEvent +import org.jitsi.jibri.selenium.pageobjects.CallPage +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.secs +import org.jitsi.utils.time.FakeClock + +class IceConnectionStatusCheckTest : ShouldSpec() { + private val clock: FakeClock = spyk() + private val callPage: CallPage = mockk { + every { isCallEmpty() } returns false + } + private val logger: Logger = mockk(relaxed = true) + + private val check = IceConnectionStatusCheck(logger, clock) + + init { + context("When ICE connects") { + every { callPage.isIceConnected() } returns true + should("not report any event") { + repeat(10) { + check.run(callPage) shouldBe null + clock.elapse(1.secs) + } + } + } + context("When ICE disconnects") { + every { callPage.isIceConnected() } returns false + should("not fire an event immediately") { + repeat(10) { + check.run(callPage) shouldBe null + clock.elapse(1.secs) + } + } + should("fire an event eventually") { + clock.elapse(20.secs) + check.run(callPage) shouldBe SeleniumEvent.IceFailedEvent + } + } + context("When ICE disconnects but recovers") { + every { callPage.isIceConnected() } returns true + check.run(callPage) shouldBe null + every { callPage.isIceConnected() } returns false + should("not fire an event immediately") { + repeat(10) { + check.run(callPage) shouldBe null + clock.elapse(1.secs) + } + } + every { callPage.isIceConnected() } returns true + should("not fire an event") { + repeat(10) { + check.run(callPage) shouldBe null + clock.elapse(10.secs) + } + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/LocalParticipantKickedStatusCheckTest.kt b/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/LocalParticipantKickedStatusCheckTest.kt new file mode 100644 index 0000000..de415d7 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/LocalParticipantKickedStatusCheckTest.kt @@ -0,0 +1,38 @@ +package org.jitsi.jibri.selenium.status_checks + +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.jibri.selenium.SeleniumEvent +import org.jitsi.jibri.selenium.pageobjects.CallPage +import org.jitsi.utils.logging2.Logger + +class LocalParticipantKickedStatusCheckTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + private val callPage: CallPage = mockk() + private val logger: Logger = mockk(relaxed = true) + + private val check = LocalParticipantKickedStatusCheck(logger) + + init { + context("when local participant is kicked") { + every { callPage.isLocalParticipantKicked() } returns true + context("the check") { + should("return LocalParticipantKicked immediately") { + check.run(callPage) shouldBe SeleniumEvent.LocalParticipantKicked + } + } + } + context("when local participant is not kicked") { + every { callPage.isLocalParticipantKicked() } returns false + context("the check") { + should("returns null state") { + check.run(callPage) shouldBe null + } + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/MediaReceivedStatusCheckTest.kt b/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/MediaReceivedStatusCheckTest.kt new file mode 100644 index 0000000..7b8d733 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/selenium/status_checks/MediaReceivedStatusCheckTest.kt @@ -0,0 +1,112 @@ +package org.jitsi.jibri.selenium.status_checks + +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.spyk +import org.jitsi.jibri.helpers.seconds +import org.jitsi.jibri.selenium.SeleniumEvent +import org.jitsi.jibri.selenium.pageobjects.CallPage +import org.jitsi.utils.logging2.Logger +import org.jitsi.utils.time.FakeClock +import java.time.Duration + +class MediaReceivedStatusCheckTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + private val clock: FakeClock = spyk() + private val callPage: CallPage = mockk { + every { numHiddenParticipants() } returns 0 + } + private val logger: Logger = mockk(relaxed = true) + + private val check = MediaReceivedStatusCheck(logger, clock) + + init { + every { callPage.getNumParticipants() } returns 4 + context("when media is flowing") { + every { callPage.getBitrates() } returns mapOf("download" to 1024L) + every { callPage.numRemoteParticipantsMuted() } returns 0 + every { callPage.numRemoteParticipantsJigasi() } returns 0 + should("not report any event") { + repeat(10) { + check.run(callPage) shouldBe null + clock.elapse(Duration.ofSeconds(30)) + } + } + } + context("no media is flowing") { + every { callPage.getBitrates() } returns mapOf("download" to 0L) + context("but all participants are muted") { + every { callPage.numRemoteParticipantsMuted() } returns 3 + every { callPage.numRemoteParticipantsJigasi() } returns 0 + check.run(callPage) shouldBe null + context("before the clients-muted timeout") { + clock.elapse(Duration.ofSeconds(45)) + should("not report any event") { + check.run(callPage) shouldBe null + } + } + context("and then when clients unmute after the no-media timeout has elapsed") { + // Go past NO_MEDIA_TIMEOUT, but everyone still muted + clock.elapse(35.seconds) + every { callPage.numRemoteParticipantsMuted() } returns 3 + every { callPage.numRemoteParticipantsJigasi() } returns 0 + check.run(callPage) + // Now go another second, but none are muted + clock.elapse(1.seconds) + every { callPage.numRemoteParticipantsMuted() } returns 0 + should("wait for the full no-media timeout duration before dropping") { + check.run(callPage) shouldBe null + } + } + context("after the clients-muted timeout") { + clock.elapse(Duration.ofMinutes(15)) + should("report an empty call") { + check.run(callPage) shouldBe SeleniumEvent.CallEmpty + } + } + } + context("and not all participants are muted") { + every { callPage.numRemoteParticipantsMuted() } returns 0 + every { callPage.numRemoteParticipantsJigasi() } returns 0 + context("before the no-media timeout") { + clock.elapse(Duration.ofSeconds(15)) + should("not report any event") { + check.run(callPage) shouldBe null + } + context("and media starts back up before the no-media timeout") { + every { callPage.getBitrates() } returns mapOf("download" to 1024L) + clock.elapse(Duration.ofMinutes(1)) + should("not report any event") { + check.run(callPage) shouldBe null + } + } + } + context("after the no-media timeout") { + clock.elapse(MediaReceivedStatusCheck.noMediaTimeout) + clock.elapse(Duration.ofSeconds(1)) + should("report a no media error") { + check.run(callPage) shouldBe SeleniumEvent.NoMediaReceived + } + } + } + context("and all participants are jigasis") { + every { callPage.numRemoteParticipantsMuted() } returns 0 + every { callPage.numRemoteParticipantsJigasi() } returns 3 + should("not fire any event") { + check.run(callPage) shouldBe null + } + } + context("and some of the participants are jigasi") { + every { callPage.numRemoteParticipantsMuted() } returns 1 + every { callPage.numRemoteParticipantsJigasi() } returns 2 + should("not fire any event") { + check.run(callPage) shouldBe null + } + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/service/AppDataTest.kt b/src/test/kotlin/org/jitsi/jibri/service/AppDataTest.kt new file mode 100644 index 0000000..2c399e9 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/service/AppDataTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.maps.shouldContainExactly +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.shouldNotBe + +internal class AppDataTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + init { + context("a json-encoded app data structure") { + val appDataJsonStr = """ + { + "file_recording_metadata": + { + "upload_credentials": + { + "service_name":"dropbox", + "token":"XXXXXXXXYYYYYYYYYZZZZZZAAAAAAABBBBBBCCCDDD" + } + } + } + """.trimIndent() + should("be parsed correctly") { + val appData = jacksonObjectMapper().readValue(appDataJsonStr) + appData.fileRecordingMetadata shouldNotBe null + appData.fileRecordingMetadata?.shouldContainKey("upload_credentials") + appData.fileRecordingMetadata?.get("upload_credentials") shouldNotBe null + @Suppress("UNCHECKED_CAST") + (appData.fileRecordingMetadata?.get("upload_credentials") as Map) + .shouldContainExactly( + mapOf( + "service_name" to "dropbox", + "token" to "XXXXXXXXYYYYYYYYYZZZZZZAAAAAAABBBBBBCCCDDD" + ) + ) + } + } + context("a json-encoded app data structure with an extra top-level field") { + val appDataJsonStr = """ + { + "file_recording_metadata": + { + "upload_credentials": + { + "service_name":"dropbox", + "token":"XXXXXXXXYYYYYYYYYZZZZZZAAAAAAABBBBBBCCCDDD" + } + }, + "other_new_field": "hello" + } + """.trimIndent() + should("be parsed correctly and ignore unknown fields") { + val appData = jacksonObjectMapper().readValue(appDataJsonStr) + appData.fileRecordingMetadata shouldNotBe null + appData.fileRecordingMetadata?.shouldContainKey("upload_credentials") + appData.fileRecordingMetadata?.get("upload_credentials") shouldNotBe null + @Suppress("UNCHECKED_CAST") + (appData.fileRecordingMetadata?.get("upload_credentials") as Map) + .shouldContainExactly( + mapOf( + "service_name" to "dropbox", + "token" to "XXXXXXXXYYYYYYYYYZZZZZZAAAAAAABBBBBBCCCDDD" + ) + ) + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/service/impl/FileRecordingJibriServiceTest.kt b/src/test/kotlin/org/jitsi/jibri/service/impl/FileRecordingJibriServiceTest.kt new file mode 100644 index 0000000..5138454 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/service/impl/FileRecordingJibriServiceTest.kt @@ -0,0 +1,262 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.service.impl + +import com.github.marschall.memoryfilesystem.MemoryFileSystemBuilder +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.jitsi.jibri.CallUrlInfo +import org.jitsi.jibri.capture.ffmpeg.FfmpegCapturer +import org.jitsi.jibri.capture.ffmpeg.FfmpegFailedToStart +import org.jitsi.jibri.config.XmppCredentials +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.helpers.SeleniumMockHelper +import org.jitsi.jibri.helpers.createFinalizeProcessMock +import org.jitsi.jibri.selenium.CallParams +import org.jitsi.jibri.selenium.FailedToJoinCall +import org.jitsi.jibri.sink.Sink +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.ProcessFactory +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions + +@Suppress("BlockingMethodInNonBlockingContext") +internal class FileRecordingJibriServiceTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + private val fs = MemoryFileSystemBuilder.newLinux().build() + + private val callParams = CallParams( + CallUrlInfo("baseUrl", "callName") + ) + private val callLoginParams = XmppCredentials( + "domain", + 8080, + "username", + "password" + ) + private val recordingsDir = fs.getPath("/tmp/recordings") + private val sessionId = "session_id" + private val additionalMetadata: Map = mapOf( + "token" to "my_token", + "other_info" to "info" + ) + private val fileRecordingParams = FileRecordingParams( + callParams, + sessionId, + callLoginParams, + additionalMetadata + ) + private val seleniumMockHelper = SeleniumMockHelper() + private val capturerMockHelper = CapturerMockHelper() + private val processFactory: ProcessFactory = mockk() + private val statusUpdates = mutableListOf() + private val fileRecordingJibriService = FileRecordingJibriService( + fileRecordingParams, + seleniumMockHelper.mock, + capturerMockHelper.mock, + processFactory, + fs + ).also { + it.addStatusHandler(statusUpdates::add) + } + + init { + context("when the recording directory can't be created") { + Files.createDirectories(recordingsDir) + setPerms("r--r--r--", recordingsDir) + + context("starting a file recording service") { + fileRecordingJibriService.start() + + should("publish an error status") { + statusUpdates shouldHaveSize 1 + val status = statusUpdates.first() + + status.shouldBeInstanceOf() + status.error shouldBe ErrorCreatingRecordingsDirectory + } + } + } + context("starting a file recording service") { + fileRecordingJibriService.start() + should("create the recording directory") { + Files.exists(fs.getPath(recordingsDir.toString(), sessionId)) shouldBe true + } + should("have selenium join the call") { + verify { seleniumMockHelper.mock.joinCall(any(), any()) } + } + context("and selenium joins the call successfully") { + seleniumMockHelper.startSuccessfully() + should("start the capturer") { + verify { capturerMockHelper.mock.start(any()) } + } + should("pass the correct arguments to the capturer's sink") { + capturerMockHelper.sink.path.shouldContain(recordingsDir.toString()) + capturerMockHelper.sink.path.shouldContain(callParams.callUrlInfo.callName) + } + context("and the capturer starts successfully") { + capturerMockHelper.startSuccessfully() + + should("publish that it's running") { + statusUpdates shouldHaveSize 1 + val status = statusUpdates.first() + + status shouldBe ComponentState.Running + } + } + context("but the capturer fails to start") { + capturerMockHelper.error(FfmpegFailedToStart) + + should("publish an error") { + statusUpdates shouldHaveSize 1 + val status = statusUpdates.first() + + status.shouldBeInstanceOf() + } + } + } + + context("but joining the call fails") { + seleniumMockHelper.error(FailedToJoinCall) + + should("publish an error") { + statusUpdates shouldHaveSize 1 + val status = statusUpdates.first() + + status.shouldBeInstanceOf() + } + } + } + context("stopping a service which has successfully started") { + // First get the service in a 'successful start' state. + fileRecordingJibriService.start() + seleniumMockHelper.startSuccessfully() + capturerMockHelper.startSuccessfully() + + // Validate that it started + statusUpdates shouldHaveSize 1 + val status = statusUpdates.first() + status shouldBe ComponentState.Running + + context("where no media file was written") { + fileRecordingJibriService.stop() + should("not run the finalize scripts") { + verify(exactly = 0) { processFactory.createProcess(any(), any()) } + } + should("delete the directory") { + Files.exists(fs.getPath(recordingsDir.toString(), sessionId)) shouldBe false + } + should("still tell selenium to leave the call") { + verify { seleniumMockHelper.mock.leaveCallAndQuitBrowser() } + } + } + + context("where a media file was written") { + Files.createFile(fs.getPath(capturerMockHelper.sink.path)) + every { seleniumMockHelper.mock.getParticipants() } returns listOf(mapOf("a" to "b")) + val finalizeProcessMock = createFinalizeProcessMock(true) + every { + processFactory.createProcess(match { it.first().contains("finalize") }, any(), any()) + } returns finalizeProcessMock + + fileRecordingJibriService.stop() + + should("get the list of participants from selenium") { + verify { seleniumMockHelper.mock.getParticipants() } + } + should("write the metadata file") { + Files.exists(fs.getPath(recordingsDir.toString(), sessionId, "metadata.json")) shouldBe true + } + should("still tell selenium to leave the call") { + verify { seleniumMockHelper.mock.leaveCallAndQuitBrowser() } + } + should("run the finalize command") { + verify { finalizeProcessMock.start() } + verify { finalizeProcessMock.waitFor() } + } + } + } + context("when sessionId contains special characters") { + val sessionId = "foo&bar/baz" + val fileRecordingParams = FileRecordingParams( + callParams, + sessionId, + callLoginParams + ) + + FileRecordingJibriService( + fileRecordingParams, + SeleniumMockHelper().mock, + CapturerMockHelper().mock, + mockk(), + fs + ).start() + + should("sanitize the sessionId") { + // The sanitized version should replace all special chars with underscores + Files.exists(fs.getPath(recordingsDir.toString(), "foo_bar_baz")) shouldBe true + } + } + } + + private fun setPerms(permsStr: String, p: Path) { + val perms = PosixFilePermissions.fromString(permsStr) + Files.setPosixFilePermissions(p, perms) + } +} + +// This wraps a mock of a Capturer, and does some setup/exposes some helpers to make its use more ergonomic +private class CapturerMockHelper { + private val sinkSlot = slot() + private val eventHandlers = mutableListOf<(ComponentState) -> Boolean>() + + val mock: FfmpegCapturer = mockk(relaxed = true) { + every { addStatusHandler(captureLambda()) } answers { + // This behavior mimics what's done in StatusPublisher#addStatusHandler + eventHandlers.add { + lambda<(ComponentState) -> Unit>().captured(it) + true + } + } + + every { start(capture(sinkSlot)) } just Runs + } + + val sink: Sink + get() = sinkSlot.captured + + fun startSuccessfully() { + eventHandlers.forEach { it(ComponentState.Running) } + } + + fun error(error: JibriError) { + eventHandlers.forEach { it(ComponentState.Error(error)) } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/service/impl/SipGatewayJibriServiceTest.kt b/src/test/kotlin/org/jitsi/jibri/service/impl/SipGatewayJibriServiceTest.kt new file mode 100644 index 0000000..fb103e5 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/service/impl/SipGatewayJibriServiceTest.kt @@ -0,0 +1,207 @@ +/* + * 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.jibri.service.impl + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.jitsi.jibri.CallUrlInfo +import org.jitsi.jibri.config.XmppCredentials +import org.jitsi.jibri.error.JibriError +import org.jitsi.jibri.helpers.SeleniumMockHelper +import org.jitsi.jibri.helpers.createFinalizeProcessMock +import org.jitsi.jibri.selenium.CallParams +import org.jitsi.jibri.selenium.FailedToJoinCall +import org.jitsi.jibri.sipgateway.SipClientParams +import org.jitsi.jibri.sipgateway.pjsua.PjsuaClient +import org.jitsi.jibri.sipgateway.pjsua.util.RemoteSipClientBusy +import org.jitsi.jibri.status.ComponentState +import org.jitsi.jibri.util.ProcessFactory + +internal class SipGatewayJibriServiceTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + private val callParams = CallParams( + CallUrlInfo("baseUrl", "callName"), + "someemail@jitsi.net" + ) + private val callLoginParams = XmppCredentials( + domain = "domain", + username = "username", + password = "password" + ) + private val sipClientParams = SipClientParams( + "sipAddress@sip.8x8.vc", + "sipusername", + true + ) + + private val sipGatewayServiceParams = SipGatewayServiceParams( + callParams, + callLoginParams, + sipClientParams + ) + + private val seleniumMockHelper = SeleniumMockHelper() + private val pjsuaClientMockHelper = PjsuaClientMockHelper() + private val processFactory: ProcessFactory = mockk() + private val statusUpdates = mutableListOf() + private val sipGatewayJibriService = + SipGatewayJibriService( + sipGatewayServiceParams, + seleniumMockHelper.mock, + pjsuaClientMockHelper.mock, + processFactory + ).also { + it.addStatusHandler(statusUpdates::add) + } + + init { + context("starting a sip gateway service") { + sipGatewayJibriService.start() + should("have selenium join the call") { + verify { seleniumMockHelper.mock.joinCall(any(), any(), any(), unmute = true) } + } + context("and selenium joins the call successfully") { + seleniumMockHelper.startSuccessfully() + should("start pjsua") { + verify { pjsuaClientMockHelper.mock.start() } + } + should("add sip metadata to presence") { + verify { + seleniumMockHelper.mock.setParticipantProperties( + mapOf("sip_address" to "sip:sipAddress@sip.8x8.vc") + ) + } + } + context("and the pjsua starts successfully") { + pjsuaClientMockHelper.startSuccessfully() + + should("publish that it's running") { + statusUpdates shouldHaveSize 1 + val status = statusUpdates.first() + + status shouldBe ComponentState.Running + } + } + context("but pjsua fails to start") { + pjsuaClientMockHelper.error(RemoteSipClientBusy) + + should("publish an error") { + statusUpdates shouldHaveSize 1 + val status = statusUpdates.first() + + status.shouldBeInstanceOf() + } + } + } + + context("but joining the call fails") { + seleniumMockHelper.error(FailedToJoinCall) + + should("publish an error") { + statusUpdates shouldHaveSize 1 + val status = statusUpdates.first() + + status.shouldBeInstanceOf() + } + } + } + context("stopping a service which has successfully started") { + // First get the service in a 'successful start' state. + sipGatewayJibriService.start() + seleniumMockHelper.startSuccessfully() + pjsuaClientMockHelper.startSuccessfully() + + // Validate that it started + statusUpdates shouldHaveSize 1 + val status = statusUpdates.first() + status shouldBe ComponentState.Running + + // Stop the service + val finalizeProcessMock = createFinalizeProcessMock(true) + every { + processFactory.createProcess(eq(listOf("/opt/jitsi/jibri/finalize_sip.sh")), any(), any()) + } returns finalizeProcessMock + + sipGatewayJibriService.stop() + should("tell selenium to leave the call") { + verify { seleniumMockHelper.mock.leaveCallAndQuitBrowser() } + } + should("run the finalize command") { + finalizeProcessMock.start() + } + } + + context("stopping a service which has successfully started, but finalize script fails") { + // First get the service in a 'successful start' state. + sipGatewayJibriService.start() + seleniumMockHelper.startSuccessfully() + pjsuaClientMockHelper.startSuccessfully() + + // Validate that it started + statusUpdates shouldHaveSize 1 + val status = statusUpdates.first() + status shouldBe ComponentState.Running + + // Stop the service + val finalizeProcessMock = createFinalizeProcessMock(false) + every { + processFactory.createProcess(eq(listOf("/opt/jitsi/jibri/finalize_sip.sh")), any(), any()) + } returns finalizeProcessMock + + sipGatewayJibriService.stop() + should("tell selenium to leave the call") { + verify { seleniumMockHelper.mock.leaveCallAndQuitBrowser() } + } + should("run the finalize command") { + finalizeProcessMock.start() + finalizeProcessMock.waitFor() + } + } + } +} + +private class PjsuaClientMockHelper() { + private val eventHandlers = mutableListOf<(ComponentState) -> Boolean>() + + val mock: PjsuaClient = mockk(relaxed = true) { + every { addTemporaryHandler(capture(eventHandlers)) } just Runs + every { addStatusHandler(captureLambda()) } answers { + // This behavior mimics what's done in StatusPublisher#addStatusHandler + eventHandlers.add { + lambda<(ComponentState) -> Unit>().captured(it) + true + } + } + } + + fun startSuccessfully() { + eventHandlers.forEach { it(ComponentState.Running) } + } + + fun error(error: JibriError) { + eventHandlers.forEach { it(ComponentState.Error(error)) } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/sink/impl/FileSinkTest.kt b/src/test/kotlin/org/jitsi/jibri/sink/impl/FileSinkTest.kt new file mode 100644 index 0000000..d2a24ba --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/sink/impl/FileSinkTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.sink.impl + +import com.github.marschall.memoryfilesystem.MemoryFileSystemBuilder +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldStartWith +import kotlin.random.Random + +internal class FileSinkTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + private val fs = MemoryFileSystemBuilder.newLinux().build() + + init { + context("when created") { + val sink = FileSink(fs.getPath("/tmp/xxx"), "callname") + should("have the correct path") { + sink.path.shouldStartWith("/tmp/xxx") + sink.path.shouldContain("callname") + } + } + context("when created with a really long call name") { + val reallyLongCallName = String.randomAlphas(200) + val sink = FileSink(fs.getPath("/tmp/xxx"), reallyLongCallName) + should("not generate a filename longer than the max file length") { + sink.file.fileName.toString().length shouldBe FileSink.MAX_FILENAME_LENGTH + } + } + } + + // Generates a random string of lower-case a-z letters with the given size + private fun String.Companion.randomAlphas(size: Int): String { + val chars: List = ('a'..'z').toList() + return (1..size) + .map { Random.nextInt(0, chars.size) } + .map(chars::get) + .joinToString("") + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/util/FileUtilsKtTest.kt b/src/test/kotlin/org/jitsi/jibri/util/FileUtilsKtTest.kt new file mode 100644 index 0000000..9f8aeb9 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/util/FileUtilsKtTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +import com.github.marschall.memoryfilesystem.MemoryFileSystemBuilder +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions + +internal class FileUtilsKtTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + private val fs = MemoryFileSystemBuilder.newLinux().build() + + private fun setPerms(permsStr: String, p: Path) { + val perms = PosixFilePermissions.fromString(permsStr) + Files.setPosixFilePermissions(p, perms) + } + private fun createPath(pathStr: String): Path = createPath(fs.getPath(pathStr)) + private fun createPath(path: Path): Path { + // Just check for the presence of a "." to determine if it's a file or a dir + if (path.fileName.toString().contains(".")) { + Files.createFile(path) + } else { + Files.createDirectories(path) + } + return path + } + private fun Path.withPerms(permString: String): Path { + setPerms(permString, this) + return this + } + + init { + context("createIfDoesNotExist") { + context("with the proper permissions") { + should("create a directory") { + val dir = fs.getPath("/xxx/dir") + createIfDoesNotExist(dir) shouldBe true + Files.exists(dir) shouldBe true + } + should("create nested directories") { + val dir = fs.getPath("/test/dir") + createIfDoesNotExist(dir) shouldBe true + Files.exists(dir) shouldBe true + } + should("return true if the dir already exists") { + val dir = fs.getPath("dir") + Files.createDirectory(dir) + createIfDoesNotExist(dir) shouldBe true + Files.exists(dir) shouldBe true + } + } + context("without permissions to create in a directory") { + val baseDir = fs.getPath("/noperms") + Files.createDirectory(baseDir).withPerms("r--r--r--") + should("fail to create a single dir") { + val newDir = baseDir.resolve("test") + createIfDoesNotExist(newDir) shouldBe false + } + should("fail to create nested dirs") { + val newDir = baseDir.resolve("test1/test2") + createIfDoesNotExist(newDir) shouldBe false + } + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/util/JibriSubprocessTest.kt b/src/test/kotlin/org/jitsi/jibri/util/JibriSubprocessTest.kt new file mode 100644 index 0000000..414b93c --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/util/JibriSubprocessTest.kt @@ -0,0 +1,118 @@ +/* + * 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.jibri.util + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.jitsi.jibri.helpers.resetOutputLogger +import org.jitsi.jibri.helpers.setTestOutputLogger +import org.jitsi.utils.logging2.Logger + +internal class JibriSubprocessTest : ShouldSpec() { + override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf + + private val processFactory: ProcessFactory = mockk() + private val processWrapper: ProcessWrapper = mockk(relaxed = true) + private val processStatePublisher: ProcessStatePublisher = mockk(relaxed = true) + private val parentLogger: Logger = mockk(relaxed = true) + + @Suppress("MoveLambdaOutsideParentheses") + private val subprocess = JibriSubprocess( + parentLogger, + "name", + mockk(), + processFactory = processFactory, + processStatePublisherProvider = { + processStatePublisher + } + ) + private val processStateHandler = slot<(ProcessState) -> Unit>() + private val executorStateUpdates = mutableListOf() + + init { + beforeSpec { + LoggingUtils.setTestOutputLogger { _, _ -> + mockk { + every { get() } returns true + every { get(any(), any()) } returns true + } + } + + every { processFactory.createProcess(any(), any(), any()) } returns processWrapper + every { processStatePublisher.addStatusHandler(capture(processStateHandler)) } just Runs + + subprocess.addStatusHandler { status -> + executorStateUpdates.add(status) + } + } + + afterSpec { + LoggingUtils.resetOutputLogger() + } + context("launching the subprocess") { + context("without any error launching the process") { + subprocess.launch(listOf()) + should("not publish any state until the proc does") { + executorStateUpdates.shouldBeEmpty() + } + context("when the process publishes a state") { + val procState = ProcessState(ProcessRunning(), "most recent output") + processStateHandler.captured(procState) + should("bubble up the state update") { + executorStateUpdates.shouldNotBeEmpty() + executorStateUpdates[0] shouldBe procState + } + } + } + context("and the start process throwing") { + every { processWrapper.start() } throws Exception() + subprocess.launch(listOf()) + should("publish a state update with the error") { + executorStateUpdates.shouldNotBeEmpty() + executorStateUpdates[0].runningState.shouldBeInstanceOf() + } + } + } + context("stopping the subprocess") { + context("before launch") { + should("not cause any errors") { + subprocess.stop() + } + } + context("after it launches") { + subprocess.launch(emptyList()) + context("when it refuses to stop gracefully") { + every { processWrapper.stopAndWaitFor(any()) } returns false + should("try and destroy it forcibly") { + subprocess.stop() + verify { processWrapper.destroyForciblyAndWaitFor(any()) } + } + } + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/util/LoggingUtilsKtTest.kt b/src/test/kotlin/org/jitsi/jibri/util/LoggingUtilsKtTest.kt new file mode 100644 index 0000000..4096c21 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/util/LoggingUtilsKtTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.jitsi.jibri.helpers.seconds +import org.jitsi.jibri.helpers.within +import org.jitsi.utils.logging2.Logger +import java.io.PipedInputStream +import java.io.PipedOutputStream +import kotlin.concurrent.thread + +internal class LoggingUtilsKtTest : FunSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + private val process: ProcessWrapper = mockk() + private lateinit var pipedOutputStream: PipedOutputStream + private lateinit var inputStream: PipedInputStream + private val logger: Logger = mockk(relaxed = true) + + init { + beforeTest { + pipedOutputStream = PipedOutputStream() + inputStream = PipedInputStream(pipedOutputStream) + clearMocks(logger) + every { process.getOutput() } returns inputStream + } + + test("logStream should write log lines to the given logger") { + LoggingUtils.logOutputOfProcess(process, logger) + thread { + for (i in 0..4) { + pipedOutputStream.write("$i\n".toByteArray()) + } + } + + val logLines = mutableListOf() + within(5.seconds) { + verify(exactly = 5) { logger.info(capture(logLines)) } + } + logLines.forEachIndexed { index, value -> + index.toString() shouldBe value + } + } + + test("logStream should complete the task when EOF is reached") { + val streamClosed = LoggingUtils.logOutputOfProcess(process, logger) + thread { + for (i in 0..4) { + pipedOutputStream.write("$i\n".toByteArray()) + } + pipedOutputStream.close() + } + val logLines = mutableListOf() + within(5.seconds) { + verify(exactly = 5) { logger.info(capture(logLines)) } + } + logLines.forEachIndexed { index, value -> + index.toString() shouldBe value + } + within(5.seconds) { + streamClosed.isDone shouldBe true + } + streamClosed.get() shouldBe true + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/util/ProcessWrapperTest.kt b/src/test/kotlin/org/jitsi/jibri/util/ProcessWrapperTest.kt new file mode 100644 index 0000000..2aa0467 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/util/ProcessWrapperTest.kt @@ -0,0 +1,205 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +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 io.mockk.verify +import org.jitsi.jibri.helpers.seconds +import org.jitsi.jibri.helpers.within +import org.jitsi.utils.logging2.Logger +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.time.Duration +import java.util.concurrent.TimeUnit + +@Suppress("BlockingMethodInNonBlockingContext") +internal class ProcessWrapperTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + private val process: Process = mockk(relaxed = true) + private var stoppedPid: Long = -1 + private lateinit var stopperException: Throwable + private var stopperThrows = false + private lateinit var outputStream: PipedOutputStream + private lateinit var inputStream: PipedInputStream + private lateinit var processWrapper: ProcessWrapper + private val logger: Logger = mockk(relaxed = true) + + init { + beforeTest { + outputStream = PipedOutputStream() + inputStream = PipedInputStream(outputStream) + stopperThrows = false + + every { process.inputStream } returns inputStream + every { process.destroyForcibly() } returns process + every { process.pid() } returns 12345L + + val stopper: ProcessStopper = { pid -> + if (stopperThrows) throw stopperException + stoppedPid = pid + } + + processWrapper = ProcessWrapper( + listOf(), + parentLogger = logger, + processStarter = { process }, + stopper = stopper + ) + processWrapper.start() + } + context("getOutput") { + should("return independent streams") { + val op1 = processWrapper.getOutput() + val reader1 = BufferedReader(InputStreamReader(op1)) + val op2 = processWrapper.getOutput() + val reader2 = BufferedReader(InputStreamReader(op2)) + + outputStream.write("hello\n".toByteArray()) + // Both output streams should see 'hello' + reader1.readLine() shouldBe "hello" + reader2.readLine() shouldBe "hello" + } + } + context("getMostRecentLine") { + should("return empty string at first") { + processWrapper.getMostRecentLine() shouldBe "" + } + should("be equal to the process stdout") { + outputStream.write("hello\n".toByteArray()) + within(5.seconds) { + processWrapper.getMostRecentLine() shouldBe "hello" + } + } + should("update to the most recent line") { + outputStream.write("hello\n".toByteArray()) + outputStream.write("goodbye\n".toByteArray()) + within(5.seconds) { + processWrapper.getMostRecentLine() shouldBe "goodbye" + } + } + } + context("isAlive") { + should("return false if the process is dead") { + every { process.isAlive } returns false + processWrapper.isAlive shouldBe false + } + should("return true if the process is alive") { + every { process.isAlive } returns true + processWrapper.isAlive shouldBe true + } + } + context("exitValue") { + context("when the process has exited") { + should("return its exit code") { + every { process.exitValue() } returns 42 + processWrapper.exitValue shouldBe 42 + } + } + context("when the process has not exited") { + should("throw IllegalThreadStateException") { + every { process.exitValue() } throws IllegalThreadStateException() + shouldThrow { + processWrapper.exitValue + } + } + } + } + context("waitFor") { + should("call the process' waitFor") { + processWrapper.waitFor() + verify { process.waitFor() } + } + } + context("waitFor(timeout)") { + should("call the process' waitFor(timeout')") { + processWrapper.waitFor(10, TimeUnit.SECONDS) + verify { process.waitFor(10, TimeUnit.SECONDS) } + } + } + context("destroyForcibly") { + should("call the process' destroyForcibly") { + processWrapper.destroyForcibly() + verify { process.destroyForcibly() } + } + } + context("stop") { + should("invoke the stopper with the process pid") { + processWrapper.stop() + stoppedPid shouldBe 12345L + } + context("when the stopper throws IOException") { + should("let the exception bubble up") { + stopperThrows = true + stopperException = java.io.IOException() + shouldThrow { processWrapper.stop() } + } + } + context("when the stopper throws RuntimeException") { + should("let the exception bubble up") { + stopperThrows = true + stopperException = RuntimeException() + shouldThrow { processWrapper.stop() } + } + } + } + context("stopAndWaitFor") { + should("invoke the stopper and wait") { + every { process.waitFor(any(), any()) } returns true + processWrapper.stopAndWaitFor(Duration.ofSeconds(10)) shouldBe true + stoppedPid shouldBe 12345L + } + context("when the stopper throws IOException") { + should("handle it correctly") { + stopperThrows = true + stopperException = java.io.IOException() + processWrapper.stopAndWaitFor(Duration.ofSeconds(10)) shouldBe false + } + } + context("when the stopper throws RuntimeException") { + should("handle it correctly") { + stopperThrows = true + stopperException = RuntimeException() + processWrapper.stopAndWaitFor(Duration.ofSeconds(10)) shouldBe false + } + } + } + context("destroyForciblyAndWaitFor") { + should("invoke the correct command") { + every { process.waitFor(any(), any()) } returns true + processWrapper.destroyForciblyAndWaitFor(Duration.ofSeconds(10)) shouldBe true + verify { process.destroyForcibly() } + } + context("when waitFor returns false") { + every { process.waitFor(any(), any()) } returns false + processWrapper.destroyForciblyAndWaitFor(Duration.ofSeconds(10)) shouldBe false + } + context("when waitFor throws") { + every { process.waitFor(any(), any()) } throws InterruptedException() + processWrapper.destroyForciblyAndWaitFor(Duration.ofSeconds(10)) shouldBe false + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/util/TailLogicTest.kt b/src/test/kotlin/org/jitsi/jibri/util/TailLogicTest.kt new file mode 100644 index 0000000..33cd071 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/util/TailLogicTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import java.io.PipedInputStream +import java.io.PipedOutputStream + +class TailLogicTest : ShouldSpec() { + private lateinit var outputStream: PipedOutputStream + private lateinit var inputStream: PipedInputStream + private lateinit var tail: TailLogic + + private fun writeLine(data: String) { + val line = if (data.endsWith("\n")) data else data + "\n" + line.toByteArray().forEach { + outputStream.write(it.toInt()) + } + } + + init { + beforeTest { + outputStream = PipedOutputStream() + inputStream = PipedInputStream(outputStream) + tail = TailLogic(inputStream) + } + context("mostRecentLine") { + context("initially") { + should("equal an empty string") { + tail.mostRecentLine shouldBe "" + } + } + context("after writing once") { + should("equal that line") { + writeLine("hello, world") + tail.readLine() + tail.mostRecentLine shouldBe "hello, world" + } + } + context("after writing multiple times") { + should("equal the most recent line") { + writeLine("hello, world") + tail.readLine() + writeLine("goodbye, world") + tail.readLine() + tail.mostRecentLine shouldBe "goodbye, world" + } + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/util/TeeLogicTest.kt b/src/test/kotlin/org/jitsi/jibri/util/TeeLogicTest.kt new file mode 100644 index 0000000..b129003 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/util/TeeLogicTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +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 java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PipedInputStream +import java.io.PipedOutputStream +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime + +internal class TeeLogicTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + private val outputStream = PipedOutputStream() + private val inputStream = PipedInputStream(outputStream) + private val tee = TeeLogic(inputStream) + + private fun pushIncomingData(data: Byte) { + outputStream.write(data.toInt()) + tee.read() + } + + private fun pushIncomingData(data: String) { + data.toByteArray().forEach { + pushIncomingData(it) + } + } + + init { + context("data written") { + context("after the creation of a branch") { + should("be received by that branch") { + val branch = tee.addBranch() + pushIncomingData("hello, world\n") + val reader = BufferedReader(InputStreamReader(branch)) + reader.readLine() shouldBe "hello, world" + } + } + context("before the creation of a branch") { + should("not be received by that branch") { + pushIncomingData("hello, world\n") + val branch = tee.addBranch() + pushIncomingData("goodbye, world\n") + val reader = BufferedReader(InputStreamReader(branch)) + reader.readLine() shouldBe "goodbye, world" + } + } + } + context("end of stream") { + should("throw EndOfStreamException") { + outputStream.close() + shouldThrow { + tee.read() + } + } + should("close all branches") { + val branch1 = tee.addBranch() + val branch2 = tee.addBranch() + outputStream.close() + shouldThrow { + tee.read() + } + val reader1 = BufferedReader(InputStreamReader(branch1)) + reader1.readLine() shouldBe null + + val reader2 = BufferedReader(InputStreamReader(branch2)) + reader2.readLine() shouldBe null + } + } + } +} + +@ExperimentalTime +internal class TeeTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + private val outputStream = PipedOutputStream() + private val inputStream = PipedInputStream(outputStream) + private val tee = Tee(inputStream) + + init { + val branch = tee.addBranch() + context("stop") { + should("close downstream readers").config(timeout = 5.seconds) { + tee.stop() + // This is testing that it returns the proper value (to signal + // the stream was closed). But the timeout in the config tests + // is also critical because if the stream wasn't closed, then + // the read call will block indefinitely + branch.read() shouldBe -1 + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/util/XmppUtilsTest.kt b/src/test/kotlin/org/jitsi/jibri/util/XmppUtilsTest.kt new file mode 100644 index 0000000..3a5cf72 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/util/XmppUtilsTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright @ 2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.jitsi.jibri.util + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jitsi.jibri.CallUrlInfo +import org.jxmpp.jid.impl.JidCreate + +class XmppUtilsTest : ShouldSpec() { + private val baseDomain = "brian.jitsi.net" + private val baseUrl = "http://brian.jitsi.net" + + init { + context("getCallUrlInfoFromJid") { + context("a basic room jid with no baseUrl set") { + val expected = CallUrlInfo("https://$baseDomain", "roomName") + val jid = JidCreate.entityBareFrom("${expected.callName}@$baseDomain") + should("convert to a call url correctly") { + getCallUrlInfoFromJid(jid, "", baseDomain, null) shouldBe expected + } + } + context("a basic room jid with the baseUrl set") { + val expected = CallUrlInfo(baseUrl, "roomName") + val jid = JidCreate.entityBareFrom("${expected.callName}@$baseDomain") + should("convert to a call url correctly") { + getCallUrlInfoFromJid(jid, "", baseDomain, baseUrl) shouldBe expected + } + } + context("a basic room jid with empty baseUrl") { + val expected = CallUrlInfo("https://$baseDomain", "roomName") + val jid = JidCreate.entityBareFrom("${expected.callName}@$baseDomain") + should("convert to a call url correctly") { + getCallUrlInfoFromJid(jid, "", baseDomain, "") shouldBe expected + } + } + context("a roomjid with a subdomain that should be stripped") { + val expected = CallUrlInfo("https://$baseDomain", "roomName") + val jid = JidCreate.entityBareFrom("${expected.callName}@mucdomain.$baseDomain") + should("convert to a call url correctly") { + getCallUrlInfoFromJid(jid, "mucdomain.", baseDomain, "") shouldBe expected + } + } + context("a roomjid with a call subdomain") { + val expected = CallUrlInfo("https://$baseDomain/subdomain", "roomName") + val jid = JidCreate.entityBareFrom("${expected.callName}@mucdomain.subdomain.$baseDomain") + getCallUrlInfoFromJid(jid, "mucdomain.", baseDomain, "") shouldBe expected + } + context("a basic muc room jid, domain contains part to be stripped") { + // domain contains 'conference' + val conferenceBaseDomain = "conference.$baseDomain" + val expected = CallUrlInfo("https://$conferenceBaseDomain", "roomName") + val jid = JidCreate.entityBareFrom("${expected.callName}@conference.$conferenceBaseDomain") + should("convert to a call url correctly") { + // we want to strip the first conference from the jid + getCallUrlInfoFromJid(jid, "conference", conferenceBaseDomain, "") shouldBe expected + } + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/webhooks/v1/WebhookClientTest.kt b/src/test/kotlin/org/jitsi/jibri/webhooks/v1/WebhookClientTest.kt new file mode 100644 index 0000000..4dc0013 --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/webhooks/v1/WebhookClientTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright @ 2018 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.jibri.webhooks.v1 + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respondError +import io.ktor.client.engine.mock.respondOk +import io.ktor.client.engine.mock.toByteReadPacket +import io.ktor.client.request.HttpRequestData +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.utils.io.readText +import kotlinx.coroutines.delay +import org.jitsi.jibri.helpers.inPlaceExecutor +import org.jitsi.jibri.helpers.resetIoPool +import org.jitsi.jibri.status.ComponentBusyStatus +import org.jitsi.jibri.status.ComponentHealthStatus +import org.jitsi.jibri.status.JibriStatus +import org.jitsi.jibri.status.OverallHealth +import org.jitsi.jibri.util.TaskPools + +class WebhookClientTest : ShouldSpec({ + isolationMode = IsolationMode.InstancePerLeaf + + val requests = mutableListOf() + val goodStatus = JibriStatus( + ComponentBusyStatus.IDLE, + OverallHealth( + ComponentHealthStatus.HEALTHY, + mapOf() + ) + ) + val badStatus = JibriStatus( + ComponentBusyStatus.IDLE, + OverallHealth( + ComponentHealthStatus.UNHEALTHY, + mapOf() + ) + ) + val client = WebhookClient( + "test", + client = HttpClient(MockEngine) { + engine { + addHandler { request -> + requests += request + with(request.url.toString()) { + when { + contains("success") -> { + respondOk() + } + + contains("delay") -> { + delay(1000) + respondOk() + } + + contains("error") -> { + respondError(HttpStatusCode.BadRequest) + } + + else -> error("Unsupported URL") + } + } + } + } + } + ) + + beforeSpec { + TaskPools.ioPool = inPlaceExecutor + } + + afterSpec { + TaskPools.resetIoPool() + } + + context("when the client") { + context("has a valid subscriber") { + client.addSubscriber("success") + context("calling updateStatus") { + client.updateStatus(goodStatus) + should("send a POST to the subscriber at the proper url") { + requests shouldHaveSize 1 + with(requests[0]) { + url.toString() shouldContain "/v1/status" + method shouldBe HttpMethod.Post + } + } + should("send the correct data") { + requests[0].body.contentType shouldBe ContentType.Application.Json + val requestBody = requests[0].body.toByteReadPacket().readText() + requestBody shouldBe jacksonObjectMapper().writeValueAsString( + JibriEvent.HealthEvent("test", goodStatus) + ) + requestBody shouldContain """ + "jibriId":"test" + """.trimIndent() + } + context("and calling updateStatus again") { + client.updateStatus(badStatus) + should("send another request with the new status") { + requests shouldHaveSize 2 + val request1Body = requests[1].body.toByteReadPacket().readText() + + request1Body shouldContain jacksonObjectMapper().writeValueAsString( + JibriEvent.HealthEvent("test", badStatus) + ) + } + } + } + } + context("has multiple subscribers") { + client.addSubscriber("https://success") + client.addSubscriber("https://delay") + client.addSubscriber("https://error") + context("calling updateStatus") { + client.updateStatus(goodStatus) + should("send a POST to the subscribers at the proper url") { + requests shouldHaveSize 3 + requests shouldContainRequestTo "success" + requests shouldContainRequestTo "delay" + requests shouldContainRequestTo "error" + } + context("and calling updateStatus again") { + requests.clear() + client.updateStatus(goodStatus) + should("send a POST to the subscribers at the proper url") { + requests shouldHaveSize 3 + requests shouldContainRequestTo "success" + requests shouldContainRequestTo "delay" + requests shouldContainRequestTo "error" + } + } + } + } + } +}) + +infix fun List.shouldContainRequestTo(host: String) { + this.find { it.url.host.contains(host) } shouldNotBe null +} diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..6794725 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,33 @@ + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file