diff --git a/.env.template b/.env.template index d355ab67..d0db42e3 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,7 @@ DEBUG_MODE=false JITSI_URL=meet.jit.si +# If your Jitsi environment has authentication set up, you MUST set JITSI_PRIVATE_MODE to "true" and you MUST pass a SECRET_JITSI_KEY to generate the JWT secret +JITSI_PRIVATE_MODE=false +JITSI_ISS= +SECRET_JITSI_KEY= ADMIN_API_TOKEN=123 diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 25d2b0cd..e77fb133 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -121,6 +121,9 @@ jobs: env: KUBE_CONFIG_FILE: ${{ secrets.KUBE_CONFIG_FILE }} ADMIN_API_TOKEN: ${{ secrets.ADMIN_API_TOKEN }} + JITSI_ISS: ${{ secrets.JITSI_ISS }} + JITSI_URL: ${{ secrets.JITSI_URL }} + SECRET_JITSI_KEY: ${{ secrets.SECRET_JITSI_KEY }} with: namespace: workadventure-${{ env.GITHUB_REF_SLUG }} diff --git a/back/src/Controller/IoSocketController.ts b/back/src/Controller/IoSocketController.ts index 1b690754..dc8b237f 100644 --- a/back/src/Controller/IoSocketController.ts +++ b/back/src/Controller/IoSocketController.ts @@ -12,6 +12,8 @@ import { WebRtcSignalToServerMessage, PlayGlobalMessage, ReportPlayerMessage, + QueryJitsiJwtMessage, + SendJitsiJwtMessage, } from "../Messages/generated/messages_pb"; import {UserMovesMessage} from "../Messages/generated/messages_pb"; import {TemplatedApp} from "uWebSockets.js" @@ -20,6 +22,7 @@ import {jwtTokenManager} from "../Services/JWTTokenManager"; import {adminApi} from "../Services/AdminApi"; import {socketManager} from "../Services/SocketManager"; import {emitInBatch, resetPing} from "../Services/IoSocketHelpers"; +import Jwt from "jsonwebtoken"; export class IoSocketController { private nextUserId: number = 1; @@ -191,6 +194,8 @@ export class IoSocketController { socketManager.emitPlayGlobalMessage(client, message.getPlayglobalmessage() as PlayGlobalMessage); } else if (message.hasReportplayermessage()){ socketManager.handleReportMessage(client, message.getReportplayermessage() as ReportPlayerMessage); + } else if (message.hasQueryjitsijwtmessage()){ + socketManager.handleQueryJitsiJwtMessage(client, message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage); } /* Ok is false if backpressure was built up, wait for drain */ diff --git a/back/src/Enum/EnvironmentVariable.ts b/back/src/Enum/EnvironmentVariable.ts index 61ab4cc9..0d4f5ed2 100644 --- a/back/src/Enum/EnvironmentVariable.ts +++ b/back/src/Enum/EnvironmentVariable.ts @@ -6,6 +6,9 @@ const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLER const ADMIN_API_URL = process.env.ADMIN_API_URL || 'http://admin'; const ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN || 'myapitoken'; const CPU_OVERHEAT_THRESHOLD = Number(process.env.CPU_OVERHEAT_THRESHOLD) || 80; +const JITSI_URL : string|undefined = (process.env.JITSI_URL === '') ? undefined : process.env.JITSI_URL; +const JITSI_ISS = process.env.JITSI_ISS || ''; +const SECRET_JITSI_KEY = process.env.SECRET_JITSI_KEY || ''; export { SECRET_KEY, @@ -16,4 +19,7 @@ export { GROUP_RADIUS, ALLOW_ARTILLERY, CPU_OVERHEAT_THRESHOLD, + JITSI_URL, + JITSI_ISS, + SECRET_JITSI_KEY } diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index a09039ad..e704ac4f 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -4,8 +4,8 @@ import { GroupDeleteMessage, GroupUpdateMessage, ItemEventMessage, - ItemStateMessage, - PlayGlobalMessage, + ItemStateMessage, + PlayGlobalMessage, PointMessage, PositionMessage, RoomJoinedMessage, @@ -19,7 +19,7 @@ import { UserMovesMessage, ViewportMessage, WebRtcDisconnectMessage, WebRtcSignalToClientMessage, - WebRtcSignalToServerMessage, WebRtcStartMessage + WebRtcSignalToServerMessage, WebRtcStartMessage, QueryJitsiJwtMessage, SendJitsiJwtMessage } from "../Messages/generated/messages_pb"; import {PointInterface} from "../Model/Websocket/PointInterface"; import {User} from "../Model/User"; @@ -27,20 +27,22 @@ import {ProtobufUtils} from "../Model/Websocket/ProtobufUtils"; import {Group} from "../Model/Group"; import {cpuTracker} from "./CpuTracker"; import {isSetPlayerDetailsMessage} from "../Model/Websocket/SetPlayerDetailsMessage"; -import {GROUP_RADIUS, MINIMUM_DISTANCE} from "../Enum/EnvironmentVariable"; +import {GROUP_RADIUS, JITSI_ISS, MINIMUM_DISTANCE, SECRET_JITSI_KEY} from "../Enum/EnvironmentVariable"; import {Movable} from "../Model/Movable"; import {PositionInterface} from "../Model/PositionInterface"; import {adminApi} from "./AdminApi"; import Direction = PositionMessage.Direction; import {Gauge} from "prom-client"; import {emitError, emitInBatch} from "./IoSocketHelpers"; +import Jwt from "jsonwebtoken"; +import {JITSI_URL} from "../Enum/EnvironmentVariable"; class SocketManager { private Worlds: Map = new Map(); private sockets: Map = new Map(); private nbClientsGauge: Gauge; private nbClientsPerRoomGauge: Gauge; - + constructor() { this.nbClientsGauge = new Gauge({ name: 'workadventure_nb_sockets', @@ -55,14 +57,14 @@ class SocketManager { } handleJoinRoom(client: ExSocketInterface): void { - const position = client.position; - const viewport = client.viewport; + const position = client.position; + const viewport = client.viewport; try { this.sockets.set(client.userId, client); //todo: should this be at the end of the function? this.nbClientsGauge.inc(); // Let's log server load when a user joins console.log(new Date().toISOString() + ' A user joined (', socketManager.sockets.size, ' connected users)'); - + //join new previous room const gameRoom = this.joinRoom(client, position); @@ -592,7 +594,44 @@ class SocketManager { } return null; } - + + + public handleQueryJitsiJwtMessage(client: ExSocketInterface, queryJitsiJwtMessage: QueryJitsiJwtMessage) { + const room = queryJitsiJwtMessage.getJitsiroom(); + const tag = queryJitsiJwtMessage.getTag(); // FIXME: this is not secure. We should load the JSON for the current room and check rights associated to room instead. + + if (SECRET_JITSI_KEY === '') { + throw new Error('You must set the SECRET_JITSI_KEY key to the secret to generate JWT tokens for Jitsi.'); + } + + // Let's see if the current client has + const isAdmin = client.tags.includes(tag); + + const jwt = Jwt.sign({ + "aud": "jitsi", + "iss": JITSI_ISS, + "sub": JITSI_URL, + "room": room, + "moderator": isAdmin + }, SECRET_JITSI_KEY, { + expiresIn: '1d', + algorithm: "HS256", + header: + { + "alg": "HS256", + "typ": "JWT" + } + }); + + const sendJitsiJwtMessage = new SendJitsiJwtMessage(); + sendJitsiJwtMessage.setJitsiroom(room); + sendJitsiJwtMessage.setJwt(jwt); + + const serverToClientMessage = new ServerToClientMessage(); + serverToClientMessage.setSendjitsijwtmessage(sendJitsiJwtMessage); + + client.send(serverToClientMessage.serializeBinary().buffer, true); + } } -export const socketManager = new SocketManager(); \ No newline at end of file +export const socketManager = new SocketManager(); diff --git a/deeployer.libsonnet b/deeployer.libsonnet index df04399a..4edb4728 100644 --- a/deeployer.libsonnet +++ b/deeployer.libsonnet @@ -16,7 +16,10 @@ "env": { "SECRET_KEY": "tempSecretKeyNeedsToChange", "ADMIN_API_TOKEN": env.ADMIN_API_TOKEN, - "ADMIN_API_URL": "https://admin."+url + "ADMIN_API_URL": "https://admin."+url, + "JITSI_ISS": env.JITSI_ISS, + "JITSI_URL": env.JITSI_URL, + "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, } }, "front": { @@ -28,10 +31,12 @@ "ports": [80], "env": { "API_URL": "api."+url, - "JITSI_URL": "meet.jit.si", + "JITSI_URL": env.JITSI_URL, + "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "TURN_SERVER": "turn:coturn.workadventu.re:443,turns:coturn.workadventu.re:443", "TURN_USER": "workadventure", - "TURN_PASSWORD": "WorkAdventure123" + "TURN_PASSWORD": "WorkAdventure123", + "JITSI_PRIVATE_MODE": if env.SECRET_JITSI_KEY != '' then "true" else "false" } }, "maps": { diff --git a/docker-compose.yaml b/docker-compose.yaml index ffc846e4..482dfbcb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,6 +23,7 @@ services: environment: DEBUG_MODE: "$DEBUG_MODE" JITSI_URL: $JITSI_URL + JITSI_PRIVATE_MODE: "$JITSI_PRIVATE_MODE" HOST: "0.0.0.0" NODE_ENV: development API_URL: api.workadventure.localhost @@ -72,8 +73,11 @@ services: environment: STARTUP_COMMAND_1: yarn install SECRET_KEY: yourSecretKey + SECRET_JITSI_KEY: "$SECRET_JITSI_KEY" ALLOW_ARTILLERY: "true" ADMIN_API_TOKEN: "$ADMIN_API_TOKEN" + JITSI_URL: $JITSI_URL + JITSI_ISS: $JITSI_ISS volumes: - ./back:/usr/src/app labels: diff --git a/front/src/Connexion/ConnexionModels.ts b/front/src/Connexion/ConnexionModels.ts index c564ed90..375e1ded 100644 --- a/front/src/Connexion/ConnexionModels.ts +++ b/front/src/Connexion/ConnexionModels.ts @@ -27,6 +27,7 @@ export enum EventMessage{ STOP_GLOBAL_MESSAGE = "stop-global-message", TELEPORT = "teleport", + START_JITSI_ROOM = "start-jitsi-room", } export interface PointInterface { diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index a9b830d3..2d2d2cf8 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -22,7 +22,7 @@ import { WebRtcSignalToServerMessage, WebRtcStartMessage, ReportPlayerMessage, - TeleportMessageMessage + TeleportMessageMessage, QueryJitsiJwtMessage, SendJitsiJwtMessage } from "../Messages/generated/messages_pb" import {UserSimplePeerInterface} from "../WebRtc/SimplePeer"; @@ -150,6 +150,8 @@ export class RoomConnection implements RoomConnection { this.dispatch(EventMessage.STOP_GLOBAL_MESSAGE, message.getStopglobalmessage()); } else if (message.hasTeleportmessagemessage()) { this.dispatch(EventMessage.TELEPORT, message.getTeleportmessagemessage()); + } else if (message.hasSendjitsijwtmessage()) { + this.dispatch(EventMessage.START_JITSI_ROOM, message.getSendjitsijwtmessage()); } else { throw new Error('Unknown message received'); } @@ -501,6 +503,25 @@ export class RoomConnection implements RoomConnection { this.socket.send(clientToServerMessage.serializeBinary().buffer); } + public emitQueryJitsiJwtMessage(jitsiRoom: string, tag: string|undefined ): void { + const queryJitsiJwtMessage = new QueryJitsiJwtMessage(); + queryJitsiJwtMessage.setJitsiroom(jitsiRoom); + if (tag !== undefined) { + queryJitsiJwtMessage.setTag(tag); + } + + const clientToServerMessage = new ClientToServerMessage(); + clientToServerMessage.setQueryjitsijwtmessage(queryJitsiJwtMessage); + + this.socket.send(clientToServerMessage.serializeBinary().buffer); + } + + public onStartJitsiRoom(callback: (jwt: string, room: string) => void): void { + this.onMessage(EventMessage.START_JITSI_ROOM, (message: SendJitsiJwtMessage) => { + callback(message.getJwt(), message.getJitsiroom()); + }); + } + public hasTag(tag: string): boolean { return this.tags.includes(tag); } diff --git a/front/src/Enum/EnvironmentVariable.ts b/front/src/Enum/EnvironmentVariable.ts index 0479d252..16918e06 100644 --- a/front/src/Enum/EnvironmentVariable.ts +++ b/front/src/Enum/EnvironmentVariable.ts @@ -4,6 +4,7 @@ const TURN_SERVER: string = process.env.TURN_SERVER || "turn:numb.viagenie.ca"; const TURN_USER: string = process.env.TURN_USER || 'g.parant@thecodingmachine.com'; const TURN_PASSWORD: string = process.env.TURN_PASSWORD || 'itcugcOHxle9Acqi$'; const JITSI_URL : string|undefined = (process.env.JITSI_URL === '') ? undefined : process.env.JITSI_URL; +const JITSI_PRIVATE_MODE : boolean = process.env.JITSI_PRIVATE_MODE == "true"; const RESOLUTION = 3; const ZOOM_LEVEL = 1/*3/4*/; const POSITION_DELAY = 200; // Wait 200ms between sending position events @@ -19,5 +20,6 @@ export { TURN_SERVER, TURN_USER, TURN_PASSWORD, - JITSI_URL + JITSI_URL, + JITSI_PRIVATE_MODE } diff --git a/front/src/Phaser/Game/GameMap.ts b/front/src/Phaser/Game/GameMap.ts index a588a4e6..9f3157a0 100644 --- a/front/src/Phaser/Game/GameMap.ts +++ b/front/src/Phaser/Game/GameMap.ts @@ -1,6 +1,6 @@ import {ITiledMap} from "../Map/ITiledMap"; -export type PropertyChangeCallback = (newValue: string | number | boolean | undefined, oldValue: string | number | boolean | undefined) => void; +export type PropertyChangeCallback = (newValue: string | number | boolean | undefined, oldValue: string | number | boolean | undefined, allProps: Map) => void; /** * A wrapper around a ITiledMap interface to provide additional capabilities. @@ -35,14 +35,14 @@ export class GameMap { for (const [newPropName, newPropValue] of newProps.entries()) { const oldPropValue = oldProps.get(newPropName); if (oldPropValue !== newPropValue) { - this.trigger(newPropName, oldPropValue, newPropValue); + this.trigger(newPropName, oldPropValue, newPropValue, newProps); } } for (const [oldPropName, oldPropValue] of oldProps.entries()) { if (!newProps.has(oldPropName)) { // We found a property that disappeared - this.trigger(oldPropName, oldPropValue, undefined); + this.trigger(oldPropName, oldPropValue, undefined, newProps); } } @@ -74,11 +74,11 @@ export class GameMap { return properties; } - private trigger(propName: string, oldValue: string | number | boolean | undefined, newValue: string | number | boolean | undefined) { + private trigger(propName: string, oldValue: string | number | boolean | undefined, newValue: string | number | boolean | undefined, allProps: Map) { const callbacksArray = this.callbacks.get(propName); if (callbacksArray !== undefined) { for (const callback of callbacksArray) { - callback(newValue, oldValue); + callback(newValue, oldValue, allProps); } } } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 6d90525b..2eb34ca0 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -9,7 +9,14 @@ import { RoomJoinedMessageInterface } from "../../Connexion/ConnexionModels"; import {CurrentGamerInterface, hasMovedEventName, Player} from "../Player/Player"; -import {DEBUG_MODE, JITSI_URL, POSITION_DELAY, RESOLUTION, ZOOM_LEVEL} from "../../Enum/EnvironmentVariable"; +import { + DEBUG_MODE, + JITSI_PRIVATE_MODE, + JITSI_URL, + POSITION_DELAY, + RESOLUTION, + ZOOM_LEVEL +} from "../../Enum/EnvironmentVariable"; import { ITiledMap, ITiledMapLayer, @@ -137,6 +144,8 @@ export class GameScene extends ResizableScene implements CenterListener { private outlinedItem: ActionableItem|null = null; private userInputManager!: UserInputManager; + private jitsiApi: any; // eslint-disable-line @typescript-eslint/no-explicit-any + static createFromUrl(room: Room, mapUrlFile: string, gameSceneKey: string|null = null): GameScene { // We use the map URL as a key if (gameSceneKey === null) { @@ -460,34 +469,18 @@ export class GameScene extends ResizableScene implements CenterListener { CoWebsiteManager.loadCoWebsite(newValue as string); } }); - let jitsiApi: any; // eslint-disable-line @typescript-eslint/no-explicit-any - this.gameMap.onPropertyChange('jitsiRoom', (newValue, oldValue) => { + this.gameMap.onPropertyChange('jitsiRoom', (newValue, oldValue, allProps) => { if (newValue === undefined) { - this.connection.setSilent(false); - jitsiApi?.dispose(); - CoWebsiteManager.closeCoWebsite(); - mediaManager.showGameOverlay(); + this.stopJitsi(); } else { - CoWebsiteManager.insertCoWebsite((cowebsiteDiv => { - const domain = JITSI_URL; - const options = { - roomName: this.instance + "-" + newValue, - width: "100%", - height: "100%", - parentNode: cowebsiteDiv, - configOverwrite: { - prejoinPageEnabled: false - }, - interfaceConfigOverwrite: { - SHOW_CHROME_EXTENSION_BANNER: false, - MOBILE_APP_PROMO: false - } - }; - jitsiApi = new (window as any).JitsiMeetExternalAPI(domain, options); // eslint-disable-line @typescript-eslint/no-explicit-any - jitsiApi.executeCommand('displayName', gameManager.getPlayerName()); - })); - this.connection.setSilent(true); - mediaManager.hideGameOverlay(); + console.log("JITSI_PRIVATE_MODE", JITSI_PRIVATE_MODE); + if (JITSI_PRIVATE_MODE) { + const adminTag = allProps.get("jitsiRoomAdminTag") as string|undefined; + + this.connection.emitQueryJitsiJwtMessage(this.instance + "-" + newValue, adminTag); + } else { + this.startJitsi(newValue as string); + } } }) @@ -597,6 +590,13 @@ export class GameScene extends ResizableScene implements CenterListener { item.fire(message.event, message.state, message.parameters); })); + /** + * Triggered when we receive the JWT token to connect to Jitsi + */ + connection.onStartJitsiRoom((jwt, room) => { + this.startJitsi(room, jwt); + }); + // When connection is performed, let's connect SimplePeer this.simplePeer = new SimplePeer(this.connection, !this.room.isPublic); this.GlobalMessageManager = new GlobalMessageManager(this.connection); @@ -1191,4 +1191,55 @@ export class GameScene extends ResizableScene implements CenterListener { public onCenterChange(): void { this.updateCameraOffset(); } + + public startJitsi(roomName: string, jwt?: string): void { + CoWebsiteManager.insertCoWebsite((cowebsiteDiv => { + const domain = JITSI_URL; + const options = { + roomName: roomName, + jwt: jwt, + width: "100%", + height: "100%", + parentNode: cowebsiteDiv, + configOverwrite: { + prejoinPageEnabled: false + }, + interfaceConfigOverwrite: { + SHOW_CHROME_EXTENSION_BANNER: false, + MOBILE_APP_PROMO: false, + + HIDE_INVITE_MORE_HEADER: true, + + // Note: hiding brand does not seem to work, we probably need to put this on the server side. + SHOW_BRAND_WATERMARK: false, + SHOW_JITSI_WATERMARK: false, + SHOW_POWERED_BY: false, + SHOW_PROMOTIONAL_CLOSE_PAGE: false, + SHOW_WATERMARK_FOR_GUESTS: false, + + TOOLBAR_BUTTONS: [ + 'microphone', 'camera', 'closedcaptions', 'desktop', /*'embedmeeting',*/ 'fullscreen', + 'fodeviceselection', 'hangup', 'profile', 'chat', 'recording', + 'livestreaming', 'etherpad', 'sharedvideo', 'settings', 'raisehand', + 'videoquality', 'filmstrip', /*'invite',*/ 'feedback', 'stats', 'shortcuts', + 'tileview', 'videobackgroundblur', 'download', 'help', 'mute-everyone', /*'security'*/ + ], + } + }; + if (!options.jwt) { + delete options.jwt; + } + this.jitsiApi = new (window as any).JitsiMeetExternalAPI(domain, options); // eslint-disable-line @typescript-eslint/no-explicit-any + this.jitsiApi.executeCommand('displayName', gameManager.getPlayerName()); + })); + this.connection.setSilent(true); + mediaManager.hideGameOverlay(); + } + + public stopJitsi(): void { + this.connection.setSilent(false); + this.jitsiApi?.dispose(); + CoWebsiteManager.closeCoWebsite(); + mediaManager.showGameOverlay(); + } } diff --git a/front/webpack.config.js b/front/webpack.config.js index cf8112ab..218b7374 100644 --- a/front/webpack.config.js +++ b/front/webpack.config.js @@ -45,7 +45,7 @@ module.exports = { new webpack.ProvidePlugin({ Phaser: 'phaser' }), - new webpack.EnvironmentPlugin(['API_URL', 'DEBUG_MODE', 'TURN_SERVER', 'TURN_USER', 'TURN_PASSWORD', 'JITSI_URL']) + new webpack.EnvironmentPlugin(['API_URL', 'DEBUG_MODE', 'TURN_SERVER', 'TURN_USER', 'TURN_PASSWORD', 'JITSI_URL', 'JITSI_PRIVATE_MODE']) ], }; diff --git a/messages/messages.proto b/messages/messages.proto index 6e00e42a..450def24 100644 --- a/messages/messages.proto +++ b/messages/messages.proto @@ -53,6 +53,11 @@ message ReportPlayerMessage { string reportComment = 2; } +message QueryJitsiJwtMessage { + string jitsiRoom = 1; + string tag = 2; // FIXME: rather than reading the tag from the query, we should read it from the current map! +} + message ClientToServerMessage { oneof message { UserMovesMessage userMovesMessage = 2; @@ -65,6 +70,7 @@ message ClientToServerMessage { PlayGlobalMessage playGlobalMessage = 9; StopGlobalMessage stopGlobalMessage = 10; ReportPlayerMessage reportPlayerMessage = 11; + QueryJitsiJwtMessage queryJitsiJwtMessage = 12; } } @@ -167,6 +173,11 @@ message TeleportMessageMessage{ string map = 1; } +message SendJitsiJwtMessage { + string jitsiRoom = 1; + string jwt = 2; +} + message ServerToClientMessage { oneof message { BatchMessage batchMessage = 1; @@ -179,5 +190,6 @@ message ServerToClientMessage { PlayGlobalMessage playGlobalMessage = 8; StopGlobalMessage stopGlobalMessage = 9; TeleportMessageMessage teleportMessageMessage = 10; + SendJitsiJwtMessage sendJitsiJwtMessage = 11; } }