init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

177
resources/cloud-api.swagger Normal file
View File

@@ -0,0 +1,177 @@
swagger: "2.0"
info:
description: "Documents the REST calls used by Jitsi Meet to integrate with other services"
version: "1.0.0"
title: "Swagger Video"
termsOfService: "https://jitsi.org/CloudAPITOS/"
contact:
email: "team@jitsi.org"
host: "jitsi-api.jitsi.org"
basePath: "/"
tags:
- name: "conferenceMapper"
description: "Conference to ID Mapper"
externalDocs:
description: "Conference API Details"
url: "https://jitsi.org/CloudAPI"
- name: "phoneNumberList"
description: "List of dial-in numbers"
schemes:
- "https"
paths:
/conferenceMapper:
get:
tags:
- "conferenceMapper"
summary: "Create or retrieve conference ID mapping"
description: "When called with a conference, creates a new ID and both stores and returns the result. When called with an ID, returns the mapping if previously created."
operationId: "GETconferenceMapper"
consumes:
- "application/json"
produces:
- "application/json"
parameters:
- in: "query"
name: "conference"
type: "string"
format: "JID"
description: "Full JID (room@conference.server.domain) for the conference to create or return existing conference mapping. Used preferentially over all other input parameters (search by conference)"
- in: "query"
name: "id"
type: "number"
description: "ID to search for existing conference mapping. Only used when provided alone (search by ID)"
responses:
200:
description: "mapping search performed"
schema:
$ref: "#/definitions/ConferenceMapperDetails"
405:
description: "Invalid input"
post:
tags:
- "conferenceMapper"
summary: "Create or retrieve conference ID mapping"
description: "When called with a conference, creates a new ID and both stores and returns the result. When called with an ID, returns the mapping if previously created."
operationId: "POSTconferenceMapper"
consumes:
- "application/json"
produces:
- "application/json"
parameters:
- in: "body"
name: "body"
description: "Conference Mapper Request"
required: true
schema:
$ref: "#/definitions/ConferenceMapperRequest"
responses:
200:
description: "mapping search performed"
schema:
$ref: "#/definitions/ConferenceMapperDetails"
405:
description: "Invalid input"
/phoneNumberList:
get:
tags:
- "phoneNumberList"
summary: "Returns a list phone numbers by country"
description: "Used to populate the Share The Link section of jitsi-meet"
operationId: "phoneNumberList"
produces:
- "application/json"
responses:
200:
description: "successful operation"
schema:
type: array
items:
$ref: "#/definitions/PhoneNumberListAnswer"
/waiting-queue/golive:
post:
tags:
- waitingQueueGoLive
summary: Post a go live request
description: Mark the conference as live, notify all visitors waiting.
operationId: goLive
consumes:
- "application/json"
parameters:
- in: "body"
name: "body"
description: "Go Live Request"
required: true
schema:
"$ref": "#/definitions/GoLiveRequest"
responses:
'200':
description: Successful operation
'404':
description: Missing conference
'500':
description: Failed operation
securityDefinitions:
token:
type: "apiKey"
name: "token"
in: "query"
Bearer:
type: "apiKey"
name: "Authorization"
in: "header"
definitions:
ConferenceMapperRequest:
description: "Request to create or find a conference mapping"
type: "object"
properties:
id:
type: "number"
description: "ID to search for existing conference mapping. Only used when provided alone (search by ID)"
conference:
type: "string"
format: "JID"
description: "Full JID (room@conference.server.domain) for the conference to create or return existing conference mapping. Used preferentially over all other input parameters (search by conference)"
room:
type: "string"
description: "Room part of the conference. Required if 'conference' is not provided. Used to generate a 'conference' value (search by conference)"
domain:
type: "string"
description: "Domain part of the conference. Used if 'conference' is not provided. Defaults to domain of the API endpoint. Used to generate a 'conference' value (search by conference)"
ConferenceMapperDetails:
description: "Conference mapping between conference JID and numeric ID"
type: "object"
properties:
id:
type: "number"
description: "Unique ID mapped to conference"
conference:
type: "string"
format: "JID"
description: "Full JID for the conference OR boolean false if no conference was found (search by ID)"
PhoneNumberListAnswer:
description: "Answer with Phone number list and additional information"
type: "object"
properties:
countryCode:
type: string
formattedNumber:
type: string
tollFree:
type: boolean
example:
- {"countryCode":"US","tollFree":false,"formattedNumber":"+1 123-456-7890"}
- {"countryCode":"UK","tollFree":true,"formattedNumber":"+44 123 456 7890"}
GoLiveRequest:
type: object
properties:
conference:
type: string
externalDocs:
description: "Find out more about the Jitsi Cloud API"
url: "https://jitsi.org/CloudAPI"

47
resources/coturn-le-update.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/sh
set -e
# This script is updating the Let's Encrypt certificates on renew or when installing
# The only param it gets is the domain and expects the certificates to use are updated
# in /etc/jitsi/meet folder.
DOMAIN=$1
if [ -z "$DOMAIN" ] ; then
echo "You need to pass the domain as parameter."
exit 10;
fi
COTURN_CERT_DIR="/etc/coturn/certs"
TURN_CONFIG="/etc/turnserver.conf"
# Execute only if turnconfig exist and is one managed by jitsi-meet
if [ -f $TURN_CONFIG ] && grep -q "jitsi-meet coturn config" "$TURN_CONFIG" ; then
# create a directory to store certs if it does not exists
if [ ! -d "$COTURN_CERT_DIR" ]; then
mkdir -p $COTURN_CERT_DIR
chown -R turnserver:turnserver /etc/coturn/
chmod -R 700 /etc/coturn/
fi
# Make sure the certificate and private key files are
# never world readable, even just for an instant while
# we're copying them into daemon_cert_root.
umask 077
cp "/etc/jitsi/meet/${DOMAIN}.crt" "$COTURN_CERT_DIR/${DOMAIN}.fullchain.pem"
cp "/etc/jitsi/meet/${DOMAIN}.key" "$COTURN_CERT_DIR/${DOMAIN}.privkey.pem"
# Apply the proper file ownership and permissions for
# the daemon to read its certificate and key.
chown turnserver "$COTURN_CERT_DIR/${DOMAIN}.fullchain.pem" \
"$COTURN_CERT_DIR/${DOMAIN}.privkey.pem"
chmod 400 "$COTURN_CERT_DIR/${DOMAIN}.fullchain.pem" \
"$COTURN_CERT_DIR/${DOMAIN}.privkey.pem"
echo "Configuring turnserver"
sed -i "/^cert/c\cert=\/etc\/coturn\/certs\/${DOMAIN}.fullchain.pem" $TURN_CONFIG
sed -i "/^pkey/c\pkey=\/etc\/coturn\/certs\/${DOMAIN}.privkey.pem" $TURN_CONFIG
service coturn restart
fi

View File

@@ -0,0 +1,153 @@
{
"spacing": [ 0, 4, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128 ],
"breakpoints": {
"values": {
"0": 0,
"320": 320,
"400": 400,
"480": 480
}
},
"palette": {
"uiBackground": "#040404",
"ui01": "#141414",
"ui02": "#292929",
"ui03": "#3D3D3D",
"ui04": "#525252",
"ui05": "#666",
"action01": "#0056E0",
"action01Hover": "#246FE5",
"action01Active": "#0045B3",
"disabled01": "#00225A",
"action02": "#3D3D3D",
"action02Hover": "#525252",
"action02Active": "#292929",
"action03": "transparent",
"action03Hover": "#525252",
"action03Active": "#292929",
"actionDanger": "#CB2233",
"actionDangerHover": "#E04757",
"actionDangerActive": "#A21B29",
"bottomSheet": "#111111",
"text01": "#FFF",
"text02": "#C2C2C2",
"text03": "#858585",
"text04": "#AAAAAA",
"textError": "#E04757",
"icon01": "#FFF",
"icon02": "#C2C2C2",
"icon03": "#858585",
"iconError": "#E04757",
"field01": "#040404",
"link01": "#669AEC",
"link01Hover": "#99BBF3",
"link01Active": "#246FE5",
"success01": "#1EC26A",
"success02": "#1EC26A",
"warning01": "#F8AE1A",
"warning02": "#ED9E1B",
"support01": "#FF9B42",
"support02": "#F96E57",
"support03": "#DF486F",
"support04": "#B23683",
"support05": "#73348C",
"support06": "#6A50D3",
"support07": "#4380E2",
"support08": "#00A8B3",
"support09": "#2AA076"
},
"typography": {
"font": {
"weightRegular": "400",
"weightSemiBold": "600"
},
"labelRegular": {
"fontSize": "0.75rem",
"lineHeight": "1rem",
"fontWeight": "400",
"letterSpacing": 0.16
},
"labelBold": {
"fontSize": "0.75rem",
"lineHeight": "1rem",
"fontWeight": "600",
"letterSpacing": 0.16
},
"bodyShortRegular": {
"fontSize": "0.875rem",
"lineHeight": "1.125rem",
"fontWeight": "400",
"letterSpacing": 0
},
"bodyShortBold": {
"fontSize": "0.875rem",
"lineHeight": "1.125rem",
"fontWeight": "600",
"letterSpacing": 0
},
"bodyShortRegularLarge": {
"fontSize": "1rem",
"lineHeight": "1.5rem",
"fontWeight": "400",
"letterSpacing": 0
},
"bodyShortBoldLarge": {
"fontSize": "1rem",
"lineHeight": "1.5rem",
"fontWeight": "600",
"letterSpacing": 0
},
"bodyLongRegular": {
"fontSize": "0.875rem",
"lineHeight": "1.5rem",
"fontWeight": "400",
"letterSpacing": 0
},
"bodyLongBold": {
"fontSize": "0.875rem",
"lineHeight": "1.5rem",
"fontWeight": "600",
"letterSpacing": 0
},
"heading1": {
"fontSize": "3.375rem",
"lineHeight": "4rem",
"fontWeight": "600",
"letterSpacing": 0
},
"heading2": {
"fontSize": "2.625rem",
"lineHeight": "3.125rem",
"fontWeight": "600",
"letterSpacing": 0
},
"heading3": {
"fontSize": "2rem",
"lineHeight": "2.5rem",
"fontWeight": "600",
"letterSpacing": 0
},
"heading4": {
"fontSize": "1.75rem",
"lineHeight": "2.25rem",
"fontWeight": "600",
"letterSpacing": 0
},
"heading5": {
"fontSize": "1.25rem",
"lineHeight": "1.75rem",
"fontWeight": "600",
"letterSpacing": 0
},
"heading6": {
"fontSize": "1rem",
"lineHeight": "1.625rem",
"fontWeight": "600",
"letterSpacing": 0
}
},
"shape": {
"borderRadius": 6,
"boxShadow": "inset 0px -1px 0px rgba(255, 255, 255, 0.15)"
}
}

50
resources/encode-sound.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
usage() {
echo "Usage: $0 [--mp3] [--opus] [--stereo] <input file ...>"
exit 1
}
for arg in "$@"; do
case "$arg" in
--stereo)
STEREO=true
shift
;;
--mp3)
MP3=true
shift
;;
--opus)
OPUS=true
shift
;;
esac
done
if [ $# -lt 1 ] ;then
usage
fi
if [ "$MP3" != "true" ] ;then
if [ "$OPUS" != "true" ] ;then
echo "At least one of --mp3 or --opus is required"
usage
fi
fi
echo "STEREO=$STEREO MP3=$MP3 OPUS=$OPUS"
AC1="-ac 1"
if [ "$STEREO" = "true" ] ;then
AC1=""
fi
for i in "$@" ;do
if [ "$MP3" = "true" ] ;then
ffmpeg -i "$i" -codec:a libmp3lame -qscale:a 9 -map_metadata -1 $AC1 "${i%.*}.mp3"
fi
if [ "$OPUS" = "true" ] ;then
ffmpeg -i "$i" -c:a libopus -b:a 30k -vbr on -compression_level 10 -map_metadata -1 $AC1 "${i%.*}.opus"
fi
done

View File

@@ -0,0 +1,123 @@
WARNING: This is still a Work In Progress
The final implementation may diverge. Currently, only the participants after a
configured threshold will be just viewers (visitors) and there is no promotion
mechanism to become a main participant yet.
TODO:
* Polls
* Speaker stats
* call duration
# Low-latency conference streaming to very large audiences
To have a low-latency conference with a very large audience, the media and
signaling load must be spread beyond what can be handled by a typical Jitsi
installation. A call with 10k participants requires around 50 bridges on decent
vms (8+ cores). The main participants of a conference with a very large
audience will share a main prosody, like with normal conferences, and
additional prosody vms are needed to support signaling to the audience.
In the example configuration we use a 16 core machine. Eight of the cores are
used for the main prosody and other services (nginx, jicofo, etc) and the other
eight cores are used to run prosody services for visitors, i.e., "visitor
prosodies".
We consider 2000 participants per visitor node a safe value. So eight visitor
prosodies will be enough for one 10k participants meeting.
<img src="imgs/visitors-prosody.svg" alt="diagram of a central prosody connected to several visitor prosodies" width="500"/>
# Configuration
If using older than Prosody 0.12.4 you need to apply the patch - s2sout_override1.patch and s2sout_override2.patch.
Use the `pre-configure.sh` script to configure your system, passing it the
number of visitor prosodies to set up.
`./pre-configure.sh 8`
The script will add for each visitor prosody:
- folders in `/etc/`
- a systemd unit file in `/lib/systemd/system/`
- a user for jicofo
- a config entry in jicofo.conf
Setting up configuration for the main prosody is a manual process:
- Add to the enabled modules list in the general part (e.g. [here](https://github.com/bjc/prosody/blob/76bf6d511f851c7cde8a81257afaaae0fb7a4160/prosody.cfg.lua.dist#L33)):
```
"s2s_bidi";
"certs_s2soutinjection";
"s2sout_override";
"s2s_whitelist";
```
- Add the following config also in the general part (matching the number of prosodies you generated config for):
```
-- targets must be IPs, not hostnames
s2sout_override = {
["conference.v1.meet.jitsi"] = "tcp://127.0.0.1:52691";
["v1.meet.jitsi"] = "tcp://127.0.0.1:52691"; -- needed for v1.meet.jitsi->visitors.jitmeet.example.com
["conference.v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
["v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
["conference.v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
["v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
["conference.v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
["v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
["conference.v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
["v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
["conference.v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
["v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
["conference.v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
["v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
["conference.v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
["v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
}
-- allowed list of server-2-server connections
s2s_whitelist = {
"conference.v1.meet.jitsi", "conference.v2.meet.jitsi", "conference.v3.meet.jitsi", "conference.v4.meet.jitsi",
"conference.v5.meet.jitsi", "conference.v6.meet.jitsi", "conference.v7.meet.jitsi", "conference.v8.meet.jitsi"
};
```
- Make sure s2s is not in modules_disabled
- Enable `"visitors";` module under the main virtual host (e.g. [here](https://github.com/jitsi/jitsi-meet/blob/f42772ec5bcc87ff6de17423d36df9bcad6e770d/doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example#L57))
You can add under main virtual host the config: `visitors_ignore_list = { "recorder.jitmeet.example.com" }` to ignore jibri and transcribers from the visitor logic and use them only in the main prosody conference.
- Create the visitors component in /etc/prosody/conf.d/jitmeet.example.com.cfg.lua:
```
Component "visitors.jitmeet.example.com" "visitors_component"
auto_allow_visitor_promotion = true
admins = { "focus@auth.jitmeet.example.com" }
```
- Make sure you add the correct upstreams to nginx config
```
upstream v1 {
zone upstreams 64K;
server 127.0.0.1:52801;
keepalive 2;
}
upstream v2 {
zone upstreams 64K;
server 127.0.0.1:52802;
keepalive 2;
}
```
After configuring you can set the maximum number of main participants, before
redirecting to visitors.
```
hocon -f /etc/jitsi/jicofo/jicofo.conf set "jicofo.visitors.enabled" true
hocon -f /etc/jitsi/jicofo/jicofo.conf set "jicofo.visitors.max-participants" 30
```
Now restart prosody and jicofo
```
service prosody restart
service jicofo restart
service nginx restart
```
Now after the main 30 participants join, the rest will be visitors using the
visitor nodes.
To enable promotion where visitors need to be approved by a moderator to join the meeting:
- you need to switch `auto_allow_visitor_promotion=false`.
- You need to enable http requests to jicofo by editing config.js and adding a nginx rule for it.
- In /etc/jitsi/meet/jitmeet.example.com-config.js uncomment conferenceRequestUrl.
- In jitsi-meet nginx config make sure you have the conference-request location rules.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 198 KiB

View File

@@ -0,0 +1,61 @@
#!/bin/bash
SCRIPT_DIR=`dirname "$0"`
cd $SCRIPT_DIR
NUMBER_OF_INSTANCES=$1
if ! [[ $NUMBER_OF_INSTANCES =~ ^[0-9]+([.][0-9]+)?$ ]] ; then
echo "error: Not a number param" >&2;
exit 1
fi
echo "Will configure $NUMBER_OF_INSTANCES number of visitor prosodies"
set -e
set -x
JICOFO_HOSTNAME=$(echo get jitsi-videobridge/jvb-hostname | sudo debconf-communicate jicofo | cut -d' ' -f2-)
# Configure prosody instances
for (( i=1 ; i<=${NUMBER_OF_INSTANCES} ; i++ ));
do
cp prosody-v.service.template /lib/systemd/system/prosody-v${i}.service
sed -i "s/vX/v${i}/g" /lib/systemd/system/prosody-v${i}.service
mkdir /etc/prosody-v${i}
ln -s /etc/prosody/certs /etc/prosody-v${i}/certs
cp prosody.cfg.lua.visitor.template /etc/prosody-v${i}/prosody.cfg.lua
sed -i "s/vX/v${i}/g" /etc/prosody-v${i}/prosody.cfg.lua
sed -i "s/jitmeet.example.com/$JICOFO_HOSTNAME/g" /etc/prosody-v${i}/prosody.cfg.lua
# fix the ports
sed -i "s/52691/5269${i}/g" /etc/prosody-v${i}/prosody.cfg.lua
sed -i "s/52221/5222${i}/g" /etc/prosody-v${i}/prosody.cfg.lua
sed -i "s/52801/5280${i}/g" /etc/prosody-v${i}/prosody.cfg.lua
sed -i "s/52811/5281${i}/g" /etc/prosody-v${i}/prosody.cfg.lua
done
# Configure jicofo
HOCON_CONFIG="/etc/jitsi/jicofo/jicofo.conf"
hocon -f $HOCON_CONFIG set "jicofo.bridge.selection-strategy" "VisitorSelectionStrategy"
hocon -f $HOCON_CONFIG set "jicofo.bridge.visitor-selection-strategy" "RegionBasedBridgeSelectionStrategy"
hocon -f $HOCON_CONFIG set "jicofo.bridge.participant-selection-strategy" "RegionBasedBridgeSelectionStrategy"
hocon -f $HOCON_CONFIG set "jicofo.bridge.topology-strategy" "VisitorTopologyStrategy"
PASS=$(hocon -f $HOCON_CONFIG get "jicofo.xmpp.client.password")
for (( i=1 ; i<=${NUMBER_OF_INSTANCES} ; i++ ));
do
prosodyctl --config /etc/prosody-v${i}/prosody.cfg.lua register focus auth.meet.jitsi $PASS
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.enabled" true
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.conference-service" "conference.v${i}.meet.jitsi"
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.hostname" 127.0.0.1
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.port" 5222${i}
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.domain" "auth.meet.jitsi"
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.xmpp-domain" "v${i}.meet.jitsi"
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.password" "${PASS}"
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.disable-certificate-verification" true
done
for (( i=1 ; i<=${NUMBER_OF_INSTANCES} ; i++ ));
do
service prosody-v${i} restart
done
service jicofo restart

View File

@@ -0,0 +1,46 @@
[Unit]
### see man systemd.unit
Description=Prosody vX (visitor vX) JVB XMPP Server
Documentation=https://prosody.im/doc
Requires=network-online.target
After=network-online.target network.target mariadb.service mysql.service postgresql.service
Before=biboumi.service
[Service]
### see man systemd.service
Type=simple
# Start by executing the main executable
# Note: -F option requires Prosody 0.11.5 or later
ExecStart=/usr/bin/prosody --config /etc/prosody-vX/prosody.cfg.lua -F
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-abnormal
### see man systemd.exec
User=prosody
Group=prosody
UMask=0027
RuntimeDirectory=prosody-vX
ConfigurationDirectory=prosody-vX
StateDirectory=prosody-vX
StateDirectoryMode=0750
LogsDirectory=prosody-vX
WorkingDirectory=~
# Set stdin to /dev/null since Prosody does not need it
StandardInput=null
# Direct stdout/-err to journald for use with log = "*stdout"
StandardOutput=journal
StandardError=inherit
# Allow binding low ports
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
### see man systemd.unit
WantedBy=multi-user.target
# vim: filetype=systemd

View File

@@ -0,0 +1,140 @@
---------- Server-wide settings ----------
s2s_ports = { 52691 };
c2s_ports = { 52221 }
http_ports = { 52801 }
https_ports = { 52811 }
daemonize = true;
-- we use a common jid for jicofo
admins = {
'focus@auth.meet.jitsi'
}
-- Enable use of native prosody 0.11 support for epoll over select
network_backend = 'epoll';
network_settings = {
tcp_backlog = 511;
}
modules_enabled = {
'saslauth';
'tls';
'disco';
'posix';
'secure_interfaces';
-- jitsi
'websocket';
'bosh';
's2s_bidi';
's2s_whitelist';
's2sout_override';
'certs_s2soutinjection';
};
s2s_whitelist = {
'conference.jitmeet.example.com', -- needed for visitors to send messages to main room
'visitors.jitmeet.example.com'; -- needed for sending promotion request to visitors.jitmeet.example.com component
'jitmeet.example.com'; -- unavailable presences back to main room
};
s2sout_override = {
["conference.jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for visitors to send messages to main room
["jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for the main room when connecting in to send main participants
["visitors.jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for sending promotion request to visitors.jitmeet.example.com component
}
external_service_secret = '__turnSecret__';
external_services = {
{ type = 'stun', host = 'jitmeet.example.com', port = 3478 },
{ type = 'turn', host = 'jitmeet.example.com', port = 3478, transport = 'udp', secret = true, ttl = 86400, algorithm = 'turn' },
{ type = 'turns', host = 'jitmeet.example.com', port = 5349, transport = 'tcp', secret = true, ttl = 86400, algorithm = 'turn' }
};
muc_mapper_domain_base = 'vX.meet.jitsi';
main_domain = 'jitmeet.example.com';
-- https://prosody.im/doc/modules/mod_smacks
smacks_max_unacked_stanzas = 5;
smacks_hibernation_time = 60;
-- this is dropped in 0.12
smacks_max_hibernated_sessions = 1;
smacks_max_old_sessions = 1;
unlimited_jids = { 'focus@auth.meet.jitsi' }
limits = {
c2s = {
rate = '512kb/s';
};
}
modules_disabled = {
'offline';
'pubsub';
'register';
};
allow_registration = false;
authentication = 'internal_hashed'
storage = 'internal'
log = {
-- Log files (change 'info' to 'debug' for debug logs):
info = '/var/log/prosody-vX/prosody.log';
error = '/var/log/prosody-vX/prosody.err';
}
consider_websocket_secure = true;
consider_bosh_secure = true;
bosh_max_inactivity = 60;
plugin_paths = { '/usr/share/jitsi-meet/prosody-plugins/' }
----------- Virtual hosts -----------
VirtualHost 'vX.meet.jitsi'
authentication = 'jitsi-anonymous'
ssl = {
key = '/etc/prosody/certs/jitmeet.example.com.key';
certificate = '/etc/prosody/certs/jitmeet.example.com.crt';
}
modules_enabled = {
'bosh';
'ping';
'external_services';
'smacks';
'jiconop';
'conference_duration';
}
main_muc = 'conference.vX.meet.jitsi';
VirtualHost 'auth.meet.jitsi'
modules_enabled = {
'limits_exception';
'ping';
'smacks';
}
authentication = 'internal_hashed'
smacks_hibernation_time = 15;
Component 'conference.vX.meet.jitsi' 'muc'
storage = 'memory'
muc_room_cache_size = 10000
restrict_room_creation = true
modules_enabled = {
'muc_hide_all';
'muc_domain_mapper';
'muc_meeting_id';
'fmuc';
's2s_bidi';
's2s_whitelist';
's2sout_override';
}
muc_room_default_presence_broadcast = {
visitor = false;
participant = true;
moderator = true;
};
muc_room_locking = false
muc_room_default_public_jids = true

292
resources/file-sharing.yaml Normal file
View File

@@ -0,0 +1,292 @@
openapi: 3.0.1
info:
title: File Sharing API
description: Management of the file sharing feature
version: 1.0.0
servers:
- url: https://your-server-here
description: Generated server url
security:
- HttpBearerKey: []
tags:
- name: Content sharing history
description: crud operation for shared documents
paths:
/v1/documents/sessions/{sessionId}/files:
get:
tags:
- Document sharing history
summary: Return a list of metadata with pre-sign urls for download
description: Used to get the list of files in past meetings
operationId: retrieveContentSharingHistory_1
parameters:
- name: sessionId
in: path
required: true
schema:
type: string
- name: offset
in: query
required: false
schema:
type: integer
format: int32
default: 0
- name: page-size
in: query
required: false
schema:
type: integer
format: int32
default: 20
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/CssFileMetadataResponse'
post:
tags:
- Document sharing history
summary: Save an document and metadata
description: Add a document to a meeting - allowed only to those with file-sharing feature if any
operationId: addDocumentInMeeting
parameters:
- name: sessionId
in: path
description: The session ID of the meeting.
required: true
schema:
type: string
requestBody:
content:
multipart/form-data:
schema:
required:
- file
- metadata
type: object
properties:
metadata:
type: string
description: The metadata of the document in JSON format. Must conform to FileMetadata schema.
example: '{"conferenceFullName":"myroomname@conference.tenant.jitsi-meet.example.com","timestamp":1741017572040,"fileSize":1042157,"fileId":"e393a7e5-e790-4f43-836e-d27238201904"}'
file:
type: string
description: The file to be uploaded.
format: binary
responses:
'200':
description: Document added successfully
content:
application/json:
schema:
$ref: '#/components/schemas/AddDocumentResponse'
example:
fileId: e393a7e5-e790-4f43-836e-d27238201904
delete:
tags:
- Document sharing history
summary: Deletes documents for a given session, user and customer
operationId: deleteDocumentsForSessionCustomerAndUser
parameters:
- name: sessionId
in: path
required: true
schema:
type: string
- name: user-id
in: query
required: true
schema:
type: string
- name: customer-id
in: query
required: true
schema:
type: string
responses:
'204':
description: No Content
/v1/documents/sessions/{sessionId}/files/{fileId}:
get:
tags:
- Document sharing history
summary: Get file pre-signed URL plus document info
description: Used by UI to get the presign url for the file before serving it to the user who needs it
operationId: getDocumentInfoDuringMeeting
parameters:
- name: sessionId
in: path
required: true
schema:
type: string
- name: fileId
in: path
description: The file ID to be deleted.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
'*/*':
schema:
$ref: '#/components/schemas/DocumentMetadataResponse'
delete:
tags:
- Document sharing history
summary: Delete a file by sessionId and fileId
description: Delete a file by sessionId and fileId allowed by all moderators
operationId: deleteFile
parameters:
- name: sessionId
in: path
description: The session ID of the meeting.
required: true
schema:
type: string
example: 86bf35e2-62a5-497e-9cae-efd35139f81f
- name: fileId
in: path
description: The file ID to be deleted.
required: true
schema:
type: string
responses:
'200':
description: OK
components:
schemas:
FileMetadata:
type: object
required:
- fileId
- conferenceFullName
- timestamp
- fileSize
properties:
fileId:
type: string
format: uuid
description: Client-generated unique identifier for the file
example: e393a7e5-e790-4f43-836e-d27238201904
conferenceFullName:
type: string
description: Full name of the conference/meeting room
example: myroomname@conference.tenant.jitsi-meet.example.com
timestamp:
type: integer
format: int64
description: Upload timestamp in milliseconds since epoch
example: 1741017572040
fileSize:
type: integer
format: int64
description: Size of the file in bytes
example: 1042157
description: Metadata structure that must be sent as JSON string in the metadata field
Payload:
type: object
properties:
isBreakout:
type: boolean
conference:
type: string
AddDocumentResponse:
type: object
properties:
fileId:
type: string
description: File ID of the added document
example: e393a7e5-e790-4f43-836e-d27238201904
description: Response body containing the fileId of the added document
CssFileMetadataResponse:
type: object
properties:
objectId:
type: string
description: Object id - can be file id
example: e393a7e5-e790-4f43-836e-d27238201904
sessionId:
type: string
description: Session id
example: 85a32e37-ddd5-45de-89a6-e94ccffe547a
timestamp:
type: integer
description: Added timestamp
format: int64
example: 124
contentType:
type: string
description: Content type
example: application/pdf
objectName:
type: string
description: Object name
example: e393a7e5-e790-4f43-836e-d27238201904
initiatorId:
type: string
description: User id for the author
example: f56g5y4
preSignedUrl:
type: string
description: Presign url - expires after 24h for JaaS
example: https://oracle.com/presigned-url
description: Response body containing the file metadata
PaginatedResponseCssFileMetadataResponse:
type: object
properties:
content:
type: array
items:
$ref: '#/components/schemas/CssFileMetadataResponse'
nextStartWith:
type: string
DocumentMetadataResponse:
type: object
properties:
fileId:
type: string
description: File ID of the document
example: e393a7e5-e790-4f43-836e-d27238201904
sessionId:
type: string
description: Session ID of the document
example: 85a32e37-ddd5-45de-89a6-e94ccffe547a
fileName:
type: string
description: Filename
example: sample.pdf
customerId:
type: string
description: Customer id
example: vthtryv56yb65
userId:
type: string
description: User id
example: dvdsvfhjv
presignedUrl:
type: string
description: Presign url - points to document sharing since for CSS the link expires in 3h
example: https://content-sharing-url.com
createdAt:
type: integer
description: Created at
format: int64
example: 1745436546
fileSize:
type: integer
description: File Size
format: int64
example: 124
description: Response body containing the document metadata
securitySchemes:
HttpBearerKey:
type: http
description: Http request Authorization header
scheme: bearer

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,74 @@
#!/bin/bash
set -e
echo "-------------------------------------------------------------------------"
echo "This script will:"
echo "- Need a working DNS record pointing to this machine(for hostname ${DOMAIN})"
echo "- Install additional dependencies in order to request Lets Encrypt certificate (acme.sh)"
echo "- Configure and reload nginx or apache2, whichever is used"
echo "- Configure the coturn server to use Let's Encrypt certificate and add required deploy hooks"
echo "- Configure renew of certificate"
echo ""
EMAIL=$1
if [ -z "$EMAIL" ]; then
echo "You need to agree to the ACME server's Subscriber Agreement (https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf) "
echo "by providing an email address for important account notifications"
echo -n "Enter your email and press [ENTER]: "
read EMAIL
fi
DOMAIN=$2
if [ -z "$DOMAIN" ]; then
DEB_CONF_RESULT=$(debconf-show jitsi-meet-web-config | grep jitsi-meet/jvb-hostname)
DOMAIN="${DEB_CONF_RESULT##*:}"
fi
# remove whitespace
DOMAIN="$(echo -e "${DOMAIN}" | tr -d '[:space:]')"
export HOME=/opt/acmesh
curl https://get.acme.sh | sh -s email=$EMAIL
# Checks whether nginx or apache is installed
NGINX_INSTALL_CHECK="$(dpkg-query -f '${Status}' -W 'nginx' 2>/dev/null | awk '{print $3}' || true)"
NGINX_FULL_INSTALL_CHECK="$(dpkg-query -f '${Status}' -W 'nginx-full' 2>/dev/null | awk '{print $3}' || true)"
NGINX_EXTRAS_INSTALL_CHECK="$(dpkg-query -f '${Status}' -W 'nginx-extras' 2>/dev/null | awk '{print $3}' || true)"
OPENRESTY_INSTALL_CHECK="$(dpkg-query -f '${Status}' -W 'openresty' 2>/dev/null | awk '{print $3}' || true)"
APACHE_INSTALL_CHECK="$(dpkg-query -f '${Status}' -W 'apache2' 2>/dev/null | awk '{print $3}' || true)"
RELOAD_CMD=""
if [ "$NGINX_INSTALL_CHECK" = "installed" ] || [ "$NGINX_INSTALL_CHECK" = "unpacked" ] \
|| [ "$NGINX_FULL_INSTALL_CHECK" = "installed" ] || [ "$NGINX_FULL_INSTALL_CHECK" = "unpacked" ] \
|| [ "$NGINX_EXTRAS_INSTALL_CHECK" = "installed" ] || [ "$NGINX_EXTRAS_INSTALL_CHECK" = "unpacked" ]; then
RELOAD_CMD="systemctl force-reload nginx.service"
elif [ "$OPENRESTY_INSTALL_CHECK" = "installed" ] || [ "$OPENRESTY_INSTALL_CHECK" = "unpacked" ] ; then
RELOAD_CMD="systemctl force-reload openresty.service"
elif [ "$APACHE_INSTALL_CHECK" = "installed" ] || [ "$APACHE_INSTALL_CHECK" = "unpacked" ] ; then
RELOAD_CMD="systemctl force-reload apache2.service"
else
RELOAD_CMD="echo 'No webserver found'"
fi
RELOAD_CMD+=" && /usr/share/jitsi-meet/scripts/coturn-le-update.sh ${DOMAIN}"
ISSUE_FAILED_CODE=0
ISSUE_CERT_CMD="/opt/acmesh/.acme.sh/acme.sh -f --issue -d ${DOMAIN} -w /usr/share/jitsi-meet --server letsencrypt"
eval "${ISSUE_CERT_CMD}" || ISSUE_FAILED_CODE=$?
INSTALL_CERT_CMD="/opt/acmesh/.acme.sh/acme.sh -f --install-cert -d ${DOMAIN} --key-file /etc/jitsi/meet/${DOMAIN}.key --fullchain-file /etc/jitsi/meet/${DOMAIN}.crt --reloadcmd \"${RELOAD_CMD}\""
if [ ${ISSUE_FAILED_CODE} -ne 0 ] ; then
# it maybe this certificate already exists (code 2 - skip, no need to renew)
if [ ${ISSUE_FAILED_CODE} -eq 2 ]; then
eval "$INSTALL_CERT_CMD"
else
echo "Issuing the certificate from Let's Encrypt failed, continuing ..."
echo "You can retry later by executing:"
echo "/usr/share/jitsi-meet/scripts/install-letsencrypt-cert.sh $EMAIL"
fi
else
eval "$INSTALL_CERT_CMD"
fi

8
resources/lang-sort.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash -e
for file in ./lang/*.json; do
echo "Sorting and standardizing ${file}"
t=$(mktemp)
jq --indent 4 -S "." "${file}" > "${t}"
mv "${t}" "${file}"
done

View File

@@ -0,0 +1,92 @@
#### room._data fields
- allModerators - If is set to true, all authenticated participants are moderators. You need a custom module to set participants as moderators based on the field.
- av_can_unmute - Default value is missing/true. If set to false, when the first moderator joining the room we enable AV moderation and disable the ability for participants to unmute themselves.
- av_first_moderator_joined - When av_can_unmute is set to false, this flag is used to indicate that the first moderator has joined the room and AV moderation is enabled.
- breakout_rooms - A table containing breakout rooms created in the main room. The keys are the JIDs of the breakout rooms, and the values are their subjects.
- breakout_rooms_active - Whether there was a breakout room created in the main room.
- breakout_rooms_counter - A counter for breakout rooms created in the main room.
- flip_participant_nick - Used in mod_muc_flip, when flipping a participant we store the nick of the second device/participant. Same processing as kicked_participant_nick.
- hideDisplayNameForAll - TODO: drop.
- hideDisplayNameForGuests - When set to true, the display name of participants is hidden for guests.
- jicofo_lock - A boolean value, when set to true the room is locked waiting for Jicofo to join. All attempts to join will be queued until Jicofo joins.
- kicked_participant_nick - Used in mod_muc_flip, when flipping a participant we store the nick of the initial device/participant so we can kick it after the second on joins, everything happens in the same pass when the second join presence is processed, so concurrency and overwriting are not a concern.
- lobby_extra_reason - Used in mod_muc_lobby_rooms to store the reason for creating a lobby room. This is set by the global event 'create-lobby-room' and can be used to provide additional context or information about the lobby room creation. Used by mod_muc_wait_for_host with value 'waiting-for-host'. Value is sent as the error reply 'registration-required'.
- lobby_skip_display_name_check - Used in mod_muc_lobby_rooms to skip the display name check for the lobby room. This is used when the lobby room is created with a specific reason that does not require a display name check via the global event 'create-lobby-room'.
- lobbyroom - Holds the JID of the lobby room if it exists. It is set by mod_muc_lobby_rooms.lua when a lobby room is created. Value is available in the room config form for all clients to use it.
- max_occupants - The maximum number of occupants allowed in the room used by mod_muc_max_occupants.
- meetingId - A unique identifier for the meeting, generated by mod_muc_meeting_id. Set in the room config form for all clients to use it. It is also sent to all visitor nodes if any.
- moderator_id - The id (from the token) for the moderator of the room. The value can be userId or a groupId where all participants from the same group will be moderators. You need a custom module to set participants as moderators based on the field.
- moderators - A list of moderator ids (from the token) that are allowed to join the room. The list is sent to jicofo to take decisions about forwarding to main room or visitor node in case of large meetings. Those participants will be moderators. You need a custom module to set participants as moderators based on the field.
- participants - A list of participants ids (from the token) that are allowed to join the room. The list is sent to jicofo to take decisions about forwarding to main room or visitor node in case of large meetings. Those participants will not be moderators.
- participants_details - Used in mod_muc_flip to store details about authenticated participants in the room. A table where the keys are participant id from the token and the values are their room jid.
- persist_lobby - A boolean value, when set to true the lobby room will be persistent and not destroyed after the main room is destroyed. Used by mod_persistent_lobby.
- subject - The subject of the room. Set by subject.lib.lua in prosody.
# room fields added by jitsi
- _connected_vnodes - A cache object that holds connected vnodes for the room. It is used by mod_visitors_component.lua to manage connected vnodes.
- _jid_nick - Prosody's internal table that maps connection JIDs to room jids in the room. It is used by mod_muc_max_occupants.lua.
- _jitsi_go_live_sent - A boolean value indicating whether the go live request has been sent for the room. It is used by mod_visitors_component.lua to manage the go live state of the room.
- _main_room_lobby_enabled - A boolean value indicating whether the main room lobby is enabled. It is set by mod_fmuc.lua when the main room lobby is enabled or disabled.
- _muc_messages_count - A counter for the number of messages sent in the room. It is used by mod_measure_message_count.lua to track message counts for analytics.
- _muc_messages_limit - A boolean value indicating whether the message limit is enabled for the room. It is used by mod_muc_limit_messages.lua to control the message limit in the room.
- _muc_messages_limit_count - A counter for the number of messages sent in the room when the message limit is enabled. It is used by mod_muc_limit_messages.lua to track message counts for the limit.
- _muc_polls_count - A counter for the number of polls created in the room. It is used by mod_measure_message_count.lua to track poll counts for analytics.
- _occupants - Prosody's internal table that holds room occupants.
- _transcription_languages - A table containing transcription languages for each occupant in the room. The keys are occupant JIDs and the values are language codes. Used by mod_fmuc.lua to manage transcription languages for occupants.
- av_moderation - A table containing media types as keys ('audio', 'video', 'desktop') and arrays of occupant nicknames that are allowed to unmute themselves. Used by mod_av_moderation_component.lua.
- av_moderation_actors - A table containing media types as keys ('audio', 'video', 'desktop') and occupant nicknames of the moderators that performed the last AV moderation action. Used by mod_av_moderation_component.lua.
- av_moderation_startMuted_restore - Keeps the startMuted metadata state of the room before AV moderation was applied. Used by mod_av_moderation_component.lua.
- broadcast_timer - A timer used to broadcast the list of breakout rooms in the main room. It is set by mod_muc_breakout_rooms.lua to periodically update the list of breakout rooms.
- close_timer - A timer used to close the main room when all occupants have left. It is set by mod_muc_breakout_rooms.lua to clean up the main room after all participants left from breakout room.
- created_timestamp - A timestamp in milliseconds when the room was created. It is set by mod_conference_duration_component.lua and used by mod_conference_duration.lua to calculate the conference duration. It is also used by mod_measure_message_count.lua to calculate the room duration for analytics.
- has_host - Whether the host (an authenticated user) has arrived in the room.
- is_vpaas - A boolean value indicating whether the room is a VPAAS room, optimization for the is_vpaas function in util.internal.lib.lua.
- jibri_throttle - A throttle object used to limit the number of Jibri requests per room. It is created in mod_filter_iq_jibri.lua to prevent abuse of Jibri resources.
- jitsi_meet_tenant_mismatch - A boolean value indicating whether there is a tenant mismatch comparing room tenant and jwt tenant value.
- jitsi_shared_files - A table containing shared files in the room. The keys are file IDs and the values are file objects with properties like fileId, name, size, type, and url. Used by mod_filesharing_component.lua to manage file sharing in the room.
- jitsiMetadata - The metadata for the room. Example values:
```json
{
"allownersEnabled": true,
"asyncTranscription": true,
"conferencePresetsServiceEnabled": true,
"participantsSoftLimit": 200,
"permissions": {
"groupChatRestricted": true,
"pollCreationRestricted": true
},
"recording": {
"autoAudioRecording": true,
"autoVideoRecording": true,
"autoTranscriptions": true,
"isTranscribingEnabled": true
},
"startMuted": {
"audio": true,
"video": true
},
"transcriberType": "GOOGLE | ORACLE_CLOUD_AI_SPEECH | EGHT_WHISPER",
"visitors": {
"autoPromote": true,
"live": true
},
"visitorsEnabled": true
}
```
- join_rate_presence_queue - A queue used to hold join presence requests when the join rate throttle is exceeded. It is set by mod_muc_rate_limit.lua to manage join requests in the room.
- join_rate_queue_timer - A timer used to process the join rate presence queue. It is set by mod_muc_rate_limit.lua to periodically check and process join requests in the queue.
- join_rate_throttle - A throttle object used to limit the rate of join requests in the room. It is set by mod_muc_rate_limit.lua to prevent high join rate in case of large conferences.
- leave_rate_presence_queue - A queue used to hold leave presence requests when the leave rate throttle is exceeded. It is set by mod_muc_rate_limit.lua to manage leave requests in the room.
- leave_rate_queue_timer - A timer used to process the leave rate presence queue. It is set by mod_muc_rate_limit.lua to periodically check and process leave requests in the queue.
- leave_rate_throttle - A throttle object used to limit the rate of leave requests in the room. It is set by mod_muc_rate_limit.lua to prevent high leave rate in case of large conferences.
- main_room - For lobby or breakout rooms it is the main room object.
- moderators_list - A set containing the JIDs of moderators in the room. It is used by mod_fmuc.lua to manage moderators in the room.
- polls - A table containing polls created in the room. The keys are poll IDs and the values are poll objects with properties like id, question, options, votes, and creator. Used by mod_polls.lua to manage polls in the room.
- pre_join_queue - A queue used to hold join requests when the room is locked waiting for Jicofo to join. It is set by mod_muc_meeting_id.lua when the room is locked. The queue is processed once Jicofo joins the room.
- send_default_permissions_to - A table containing the default permissions to be sent to occupants in the room. The keys are bare JIDs and the values are boolean values indicating whether the permissions should be sent. Used by mod_jitsi_permissions.lua to manage permissions in the room.
- sent_initial_metadata - A table containing the initial metadata sent to occupants in the room. The keys are bare JIDs and the values are boolean values indicating whether the metadata has been sent. Used by mod_room_metadata_component.lua to manage metadata in the room.
- speakerStats - A table containing speaker statistics for occupants in the room. The keys are occupant JIDs and the values are objects with properties like dominantSpeakerId, faceLandmarks, and sessionId. Used by mod_speakerstats_component.lua to manage speaker statistics in the room.
- visitors_destroy_timer - A timer used to destroy the room when there are no main occupants or visitors left. It is set by mod_fmuc.lua to clean up the room after a certain period of inactivity.
#### Notes:
When modules need to store data they should do it in the room object in _data or directly. The data needs to be a simple as strings or table of strings, they should not add objects like room, sessions or occupants that cannot be serialized. Attaching data to the room object makes reloading modules safe and guarantees data will be wiped once the room is destroyed.

View File

@@ -0,0 +1,263 @@
local cjson_safe = require 'cjson.safe'
local basexx = require 'basexx'
local digest = require 'openssl.digest'
local hmac = require 'openssl.hmac'
local pkey = require 'openssl.pkey'
-- Generates an RSA signature of the data.
-- @param data The data to be signed.
-- @param key The private signing key in PEM format.
-- @param algo The digest algorithm to user when generating the signature: sha256, sha384, or sha512.
-- @return The signature or nil and an error message.
local function signRS (data, key, algo)
local privkey = pkey.new(key)
if privkey == nil then
return nil, 'Not a private PEM key'
else
local datadigest = digest.new(algo):update(data)
return privkey:sign(datadigest)
end
end
-- Verifies an RSA signature on the data.
-- @param data The signed data.
-- @param signature The signature to be verified.
-- @param key The public key of the signer.
-- @param algo The digest algorithm to user when generating the signature: sha256, sha384, or sha512.
-- @return True if the signature is valid, false otherwise. Also returns false if the key is invalid.
local function verifyRS (data, signature, key, algo)
local pubkey = pkey.new(key)
if pubkey == nil then
return false
end
local datadigest = digest.new(algo):update(data)
return pubkey:verify(signature, datadigest)
end
local alg_sign = {
['HS256'] = function(data, key) return hmac.new(key, 'sha256'):final(data) end,
['HS384'] = function(data, key) return hmac.new(key, 'sha384'):final(data) end,
['HS512'] = function(data, key) return hmac.new(key, 'sha512'):final(data) end,
['RS256'] = function(data, key) return signRS(data, key, 'sha256') end,
['RS384'] = function(data, key) return signRS(data, key, 'sha384') end,
['RS512'] = function(data, key) return signRS(data, key, 'sha512') end
}
local alg_verify = {
['HS256'] = function(data, signature, key) return signature == alg_sign['HS256'](data, key) end,
['HS384'] = function(data, signature, key) return signature == alg_sign['HS384'](data, key) end,
['HS512'] = function(data, signature, key) return signature == alg_sign['HS512'](data, key) end,
['RS256'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha256') end,
['RS384'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha384') end,
['RS512'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha512') end
}
-- Splits a token into segments, separated by '.'.
-- @param token The full token to be split.
-- @return A table of segments.
local function split_token(token)
local segments={}
for str in string.gmatch(token, "([^\\.]+)") do
table.insert(segments, str)
end
return segments
end
-- Parses a JWT token into it's header, body, and signature.
-- @param token The JWT token to be parsed.
-- @return A JSON header and body represented as a table, and a signature.
local function parse_token(token)
local segments=split_token(token)
if #segments ~= 3 then
return nil, nil, nil, "Invalid token"
end
local header, err = cjson_safe.decode(basexx.from_url64(segments[1]))
if err then
return nil, nil, nil, "Invalid header"
end
local body, err = cjson_safe.decode(basexx.from_url64(segments[2]))
if err then
return nil, nil, nil, "Invalid body"
end
local sig, err = basexx.from_url64(segments[3])
if err then
return nil, nil, nil, "Invalid signature"
end
return header, body, sig
end
-- Removes the signature from a JWT token.
-- @param token A JWT token.
-- @return The token without its signature.
local function strip_signature(token)
local segments=split_token(token)
if #segments ~= 3 then
return nil, nil, nil, "Invalid token"
end
table.remove(segments)
return table.concat(segments, ".")
end
-- Verifies that a claim is in a list of allowed claims. Allowed claims can be exact values, or the
-- catch all wildcard '*'.
-- @param claim The claim to be verified.
-- @param acceptedClaims A table of accepted claims.
-- @return True if the claim was allowed, false otherwise.
local function verify_claim(claim, acceptedClaims)
for i, accepted in ipairs(acceptedClaims) do
if accepted == '*' then
return true;
end
if claim == accepted then
return true;
end
end
return false;
end
local M = {}
-- Encodes the data into a signed JWT token.
-- @param data The data the put in the body of the JWT token.
-- @param key The key to use for signing the JWT token.
-- @param alg The signature algorithm to use: HS256, HS384, HS512, RS256, RS384, or RS512.
-- @param header Additional values to put in the JWT header.
-- @param The resulting JWT token, or nil and an error message.
function M.encode(data, key, alg, header)
if type(data) ~= 'table' then return nil, "Argument #1 must be table" end
if type(key) ~= 'string' then return nil, "Argument #2 must be string" end
alg = alg or "HS256"
if not alg_sign[alg] then
return nil, "Algorithm not supported"
end
header = header or {}
header['typ'] = 'JWT'
header['alg'] = alg
local headerEncoded, err = cjson_safe.encode(header)
if headerEncoded == nil then
return nil, err
end
local dataEncoded, err = cjson_safe.encode(data)
if dataEncoded == nil then
return nil, err
end
local segments = {
basexx.to_url64(headerEncoded),
basexx.to_url64(dataEncoded)
}
local signing_input = table.concat(segments, ".")
local signature, error = alg_sign[alg](signing_input, key)
if signature == nil then
return nil, error
end
segments[#segments+1] = basexx.to_url64(signature)
return table.concat(segments, ".")
end
-- Verify that the token is valid, and if it is return the decoded JSON payload data.
-- @param token The token to verify.
-- @param expectedAlgo The signature algorithm the caller expects the token to be signed with:
-- HS256, HS384, HS512, RS256, RS384, or RS512.
-- @param key The verification key used for the signature.
-- @param acceptedIssuers Optional table of accepted issuers. If not nil, the 'iss' claim will be
-- checked against this list.
-- @param acceptedAudiences Optional table of accepted audiences. If not nil, the 'aud' claim will
-- be checked against this list.
-- @return A table representing the JSON body of the token, or nil and an error message.
function M.verify(token, expectedAlgo, key, acceptedIssuers, acceptedAudiences)
if type(token) ~= 'string' then return nil, "token argument must be string" end
if type(expectedAlgo) ~= 'string' then return nil, "algorithm argument must be string" end
if type(key) ~= 'string' then return nil, "key argument must be string" end
if acceptedIssuers ~= nil and type(acceptedIssuers) ~= 'table' then
return nil, "acceptedIssuers argument must be table"
end
if acceptedAudiences ~= nil and type(acceptedAudiences) ~= 'table' then
return nil, "acceptedAudiences argument must be table"
end
if not alg_verify[expectedAlgo] then
return nil, "Algorithm not supported"
end
local header, body, sig, err = parse_token(token)
if err ~= nil then
return nil, err
end
-- Validate header
if not header.typ or header.typ ~= "JWT" then
return nil, "Invalid typ"
end
if not header.alg or header.alg ~= expectedAlgo then
return nil, "Invalid or incorrect alg"
end
-- Validate signature
if not alg_verify[expectedAlgo](strip_signature(token), sig, key) then
return nil, 'Invalid signature'
end
-- Validate body
if body.exp and type(body.exp) ~= "number" then
return nil, "exp must be number"
end
if body.nbf and type(body.nbf) ~= "number" then
return nil, "nbf must be number"
end
if body.exp and os.time() >= body.exp then
local extra_msg = '';
if body.iat then
extra_msg = ", valid for:"..tostring(body.exp-body.iat).." sec";
end
return nil, "Not acceptable by exp ("..tostring(os.time()-body.exp).." sec since expired"..extra_msg..")"
end
if body.nbf and os.time() < body.nbf then
return nil, "Not acceptable by nbf"
end
if acceptedIssuers ~= nil then
local issClaim = body.iss;
if issClaim == nil then
return nil, "'iss' claim is missing";
end
if not verify_claim(issClaim, acceptedIssuers) then
return nil, "invalid 'iss' claim";
end
end
if acceptedAudiences ~= nil then
local audClaim = body.aud;
if audClaim == nil then
return nil, "'aud' claim is missing";
end
if not verify_claim(audClaim, acceptedAudiences) then
return nil, "invalid 'aud' claim";
end
end
return body
end
return M

View File

@@ -0,0 +1,78 @@
-- Anonymous authentication with extras:
-- * session resumption
-- Copyright (C) 2021-present 8x8, Inc.
local generate_uuid = require "util.uuid".generate;
local new_sasl = require "util.sasl".new;
local sasl = require "util.sasl";
local sessions = prosody.full_sessions;
-- define auth provider
local provider = {};
function provider.test_password(username, password)
return nil, "Password based auth not supported";
end
function provider.get_password(username)
return nil;
end
function provider.set_password(username, password)
return nil, "Set password not supported";
end
function provider.user_exists(username)
return nil;
end
function provider.create_user(username, password)
return nil;
end
function provider.delete_user(username)
return nil;
end
function provider.get_sasl_handler(session)
-- Custom session matching so we can resume session even with randomly
-- generated user IDs.
local function get_username(self, message)
if (session.previd ~= nil) then
for _, session1 in pairs(sessions) do
if (session1.resumption_token == session.previd) then
self.username = session1.username;
break;
end
end
else
self.username = message;
end
return true;
end
return new_sasl(module.host, { anonymous = get_username });
end
module:provides("auth", provider);
local function anonymous(self, message)
-- Same as the vanilla anonymous auth plugin
local username = generate_uuid();
-- This calls the handler created in 'provider.get_sasl_handler(session)'
local result, err, msg = self.profile.anonymous(self, username, self.realm);
if result == true then
if (self.username == nil) then
-- Session was not resumed
self.username = username;
end
return "success";
else
return "failure", err, msg;
end
end
sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous);

View File

@@ -0,0 +1,65 @@
-- Authentication with shared secret where the username is ignored
-- Copyright (C) 2023-present 8x8, Inc.
local new_sasl = require "util.sasl".new;
local saslprep = require "util.encodings".stringprep.saslprep;
local secure_equals = require "util.hashes".equals;
local shared_secret = module:get_option_string('shared_secret');
local shared_secret_prev = module:get_option_string('shared_secret_prev');
if shared_secret == nil then
module:log('error', 'No shared_secret specified. No secret to operate on!');
return;
end
module:depends("jitsi_session");
-- define auth provider
local provider = {};
function provider.test_password(username, password)
password = saslprep(password);
if not password then
return nil, "Password fails SASLprep.";
end
if secure_equals(password, saslprep(shared_secret)) then
return true;
elseif (shared_secret_prev ~= nil and secure_equals(password, saslprep(shared_secret_prev))) then
module:log("info", "Accepting login using previous shared secret, username=%s", username);
return true;
else
return nil, "Auth failed. Invalid username or password.";
end
end
function provider.get_password(username)
return shared_secret;
end
function provider.set_password(username, password)
return nil, "Set password not supported";
end
function provider.user_exists(username)
return true; -- all usernames exist
end
function provider.create_user(username, password)
return nil;
end
function provider.delete_user(username)
return nil;
end
function provider.get_sasl_handler(session)
local getpass_authentication_profile = {
plain = function(_, username, realm)
return shared_secret, true;
end
};
return new_sasl(module.host, getpass_authentication_profile);
end
module:provides("auth", provider);

View File

@@ -0,0 +1,169 @@
-- Token authentication
-- Copyright (C) 2021-present 8x8, Inc.
local formdecode = require "util.http".formdecode;
local generate_uuid = require "util.uuid".generate;
local new_sasl = require "util.sasl".new;
local sasl = require "util.sasl";
local token_util = module:require "token/util".new(module);
local sessions = prosody.full_sessions;
-- no token configuration
if token_util == nil then
return;
end
module:depends("jitsi_session");
local measure_pre_fetch_fail = module:measure('pre_fetch_fail', 'counter');
local measure_verify_fail = module:measure('verify_fail', 'counter');
local measure_success = module:measure('success', 'counter');
local measure_ban = module:measure('ban', 'counter');
local measure_post_auth_fail = module:measure('post_auth_fail', 'counter');
-- define auth provider
local provider = {};
local host = module.host;
-- Extract 'token' param from URL when session is created
function init_session(event)
local session, request = event.session, event.request;
local query = request.url.query;
local token = nil;
-- extract token from Authorization header
if request.headers["authorization"] then
-- assumes the header value starts with "Bearer "
token = request.headers["authorization"]:sub(8,#request.headers["authorization"])
end
-- allow override of token via query parameter
if query ~= nil then
local params = formdecode(query);
-- The following fields are filled in the session, by extracting them
-- from the query and no validation is being done.
-- After validating auth_token will be cleaned in case of error and few
-- other fields will be extracted from the token and set in the session
if params and params.token then
token = params.token;
end
end
-- in either case set auth_token in the session
session.auth_token = token;
session.user_agent_header = request.headers['user_agent'];
end
module:hook_global("bosh-session", init_session);
module:hook_global("websocket-session", init_session);
function provider.test_password(username, password)
return nil, "Password based auth not supported";
end
function provider.get_password(username)
return nil;
end
function provider.set_password(username, password)
return nil, "Set password not supported";
end
function provider.user_exists(username)
return nil;
end
function provider.create_user(username, password)
return nil;
end
function provider.delete_user(username)
return nil;
end
function provider.get_sasl_handler(session)
local function get_username_from_token(self, message)
-- retrieve custom public key from server and save it on the session
local pre_event_result = prosody.events.fire_event("pre-jitsi-authentication-fetch-key", session);
if pre_event_result ~= nil and pre_event_result.res == false then
module:log("warn",
"Error verifying token on pre authentication stage:%s, reason:%s", pre_event_result.error, pre_event_result.reason);
session.auth_token = nil;
measure_pre_fetch_fail(1);
return pre_event_result.res, pre_event_result.error, pre_event_result.reason;
end
local res, error, reason = token_util:process_and_verify_token(session);
if res == false then
module:log("warn",
"Error verifying token err:%s, reason:%s tenant:%s room:%s user_agent:%s",
error, reason, session.jitsi_web_query_prefix, session.jitsi_web_query_room,
session.user_agent_header);
session.auth_token = nil;
measure_verify_fail(1);
return res, error, reason;
end
local shouldAllow = prosody.events.fire_event("jitsi-access-ban-check", session);
if shouldAllow == false then
module:log("warn", "user is banned")
measure_ban(1);
return false, "not-allowed", "user is banned";
end
local customUsername = prosody.events.fire_event("pre-jitsi-authentication", session);
if customUsername then
self.username = customUsername;
elseif session.previd ~= nil then
for _, session1 in pairs(sessions) do
if (session1.resumption_token == session.previd) then
self.username = session1.username;
break;
end
end
else
self.username = message;
end
local post_event_result = prosody.events.fire_event("post-jitsi-authentication", session);
if post_event_result ~= nil and post_event_result.res == false then
module:log("warn",
"Error verifying token on post authentication stage :%s, reason:%s", post_event_result.error, post_event_result.reason);
session.auth_token = nil;
measure_post_auth_fail(1);
return post_event_result.res, post_event_result.error, post_event_result.reason;
end
measure_success(1);
return res;
end
return new_sasl(host, { anonymous = get_username_from_token });
end
module:provides("auth", provider);
local function anonymous(self, message)
local username = generate_uuid();
-- This calls the handler created in 'provider.get_sasl_handler(session)'
local result, err, msg = self.profile.anonymous(self, username, self.realm);
if result == true then
if (self.username == nil) then
self.username = username;
end
return "success";
else
return "failure", err, msg;
end
end
sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous);

View File

@@ -0,0 +1,6 @@
-- TODO: Remove this file after several stable releases when people update their configs
module:log('warn', 'mod_av_moderation is deprecated and will be removed in a future release. '
.. 'Please update your config by removing this module from the list of loaded modules.');
module:depends('jitsi_session');
module:depends('features_identity');

View File

@@ -0,0 +1,431 @@
local util = module:require 'util';
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local is_healthcheck_room = util.is_healthcheck_room;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local process_host_module = util.process_host_module;
local table_shallow_copy = util.table_shallow_copy;
local is_admin = util.is_admin;
local array = require "util.array";
local json = require 'cjson.safe';
local st = require 'util.stanza';
local muc_component_host = module:get_option_string('muc_component');
if muc_component_host == nil then
module:log('error', 'No muc_component specified. No muc to operate on!');
return;
end
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
if not main_virtual_host then
module:log('warn', 'No "muc_mapper_domain_base" option set, disabling AV moderation.');
return ;
end
module:log('info', 'Starting av_moderation for %s', muc_component_host);
-- Returns the index of the given element in the table
-- @param table in which to look
-- @param elem the element for which to find the index
function get_index_in_table(table, elem)
for index, value in pairs(table) do
if value == elem then
return index
end
end
end
-- Sends a json-message to the destination jid
-- @param to_jid the destination jid
-- @param json_message the message content to send
function send_json_message(to_jid, json_message)
local stanza = st.message({ from = module.host; to = to_jid; })
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_message):up();
module:send(stanza);
end
-- Notifies that av moderation has been enabled or disabled
-- @param jid the jid to notify, if missing will notify all occupants
-- @param enable whether it is enabled or disabled
-- @param room the room
-- @param actorJid the jid that is performing the enable/disable operation (the muc jid)
-- @param mediaType the media type for the moderation
function notify_occupants_enable(jid, enable, room, actorJid, mediaType)
local body_json = {};
body_json.type = 'av_moderation';
body_json.enabled = enable;
body_json.room = internal_room_jid_match_rewrite(room.jid);
body_json.actor = internal_room_jid_match_rewrite(actorJid);
body_json.mediaType = mediaType;
local body_json_str, error = json.encode(body_json);
if not body_json_str then
module:log('error', 'error encoding json room:%s error:%s', room.jid, error);
return;
end
if jid then
send_json_message(jid, body_json_str)
else
for _, occupant in room:each_occupant() do
send_json_message(occupant.jid, body_json_str)
end
end
end
-- Notifies about a change to the whitelist. Notifies all moderators and admin and the jid itself
-- @param jid the jid to notify about the change
-- @param moderators whether to notify all moderators in the room
-- @param room the room where to send it
-- @param mediaType used only when a participant is approved (not sent to moderators)
-- @param removed whether the jid is removed or added
function notify_whitelist_change(jid, moderators, room, mediaType, removed)
local body_json = {};
body_json.type = 'av_moderation';
body_json.room = internal_room_jid_match_rewrite(room.jid);
-- we will be modifying it, so we need a copy
body_json.whitelists = table_shallow_copy(room.av_moderation);
if removed then
body_json.removed = true;
end
body_json.mediaType = mediaType;
-- sanitize, make sure we don't have an empty array as it will encode it as {} not as []
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if body_json.whitelists[mediaType] and #body_json.whitelists[mediaType] == 0 then
body_json.whitelists[mediaType] = nil;
end
end
local moderators_body_json_str, error = json.encode(body_json);
if not moderators_body_json_str then
module:log('error', 'error encoding moderator json room:%s error:%s', room.jid, error);
return;
end
body_json.whitelists = nil;
if not removed then
body_json.approved = true; -- we want to send to participants only that they were approved to unmute
end
local participant_body_json_str, error = json.encode(body_json);
if not participant_body_json_str then
module:log('error', 'error encoding participant json room:%s error:%s', room.jid, error);
return;
end
for _, occupant in room:each_occupant() do
if moderators and occupant.role == 'moderator' then
send_json_message(occupant.jid, moderators_body_json_str);
elseif occupant.jid == jid then
-- if the occupant is not moderator we send him that it is approved
-- if it is moderator we update him with the list, this is moderator joining or grant moderation was executed
if occupant.role == 'moderator' then
send_json_message(occupant.jid, moderators_body_json_str);
else
send_json_message(occupant.jid, participant_body_json_str);
end
end
end
end
-- Notifies jid that is approved. This is a moderator to jid message to ask to unmute,
-- @param jid the jid to notify about the change
-- @param from the jid that triggered this
-- @param room the room where to send it
-- @param mediaType the mediaType it was approved for
function notify_jid_approved(jid, from, room, mediaType)
local body_json = {};
body_json.type = 'av_moderation';
body_json.room = internal_room_jid_match_rewrite(room.jid);
body_json.approved = true; -- we want to send to participants only that they were approved to unmute
body_json.mediaType = mediaType;
body_json.from = from;
local json_message, error = json.encode(body_json);
if not json_message then
module:log('error', 'skip sending json message to:%s error:%s', jid, error);
return;
end
send_json_message(jid, json_message);
end
function start_av_moderation(room, mediaType, occupant)
if not room.av_moderation then
room.av_moderation = {};
room.av_moderation_actors = {};
end
room.av_moderation[mediaType] = array();
-- add all current moderators to the new whitelist
for _, room_occupant in room:each_occupant() do
if room_occupant.role == 'moderator' and not ends_with(room_occupant.nick, '/focus') then
room.av_moderation[mediaType]:push(internal_room_jid_match_rewrite(room_occupant.nick));
end
end
-- We want to set startMuted policy in metadata, in case of new participants are joining to respect
-- it, that will be enforced by jicofo
local startMutedMetadata = room.jitsiMetadata.startMuted or {};
-- We want to keep the previous value of startMuted for this mediaType if av moderation is disabled
-- to be able to restore
local av_moderation_startMuted_restore = room.av_moderation_startMuted_restore or {};
av_moderation_startMuted_restore[mediaType] = startMutedMetadata[mediaType];
room.av_moderation_startMuted_restore = av_moderation_startMuted_restore;
startMutedMetadata[mediaType] = true;
room.jitsiMetadata.startMuted = startMutedMetadata;
room.av_moderation_actors[mediaType] = occupant.nick;
end
-- receives messages from clients to the component sending A/V moderation enable/disable commands or adding
-- jids to the whitelist
function on_message(event)
local session = event.origin;
-- Check the type of the incoming stanza to avoid loops:
if event.stanza.attr.type == 'error' then
return; -- We do not want to reply to these, so leave.
end
if not session or not session.jitsi_web_query_room then
return false;
end
local moderation_command = event.stanza:get_child('av_moderation');
if moderation_command then
-- get room name with tenant and find room
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
module:log('warn', 'No room found found for %s/%s',
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return false;
end
-- check that the participant requesting is a moderator and is an occupant in the room
local from = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant then
module:log('warn', 'No occupant %s found for %s', from, room.jid);
return false;
end
if occupant.role ~= 'moderator' then
module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
return false;
end
local mediaType = moderation_command.attr.mediaType;
if mediaType then
if mediaType ~= 'audio' and mediaType ~= 'video' and mediaType ~= 'desktop' then
module:log('warn', 'Wrong mediaType %s for %s', mediaType, room.jid);
return false;
end
else
module:log('warn', 'Missing mediaType for %s', room.jid);
return false;
end
if moderation_command.attr.enable ~= nil then
local enabled;
if moderation_command.attr.enable == 'true' then
enabled = true;
if room.av_moderation and room.av_moderation[mediaType] then
module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync');
return true;
else
start_av_moderation(room, mediaType, occupant);
end
else
enabled = false;
if not room.av_moderation then
module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync');
return true;
else
room.av_moderation[mediaType] = nil;
room.av_moderation_actors[mediaType] = nil;
local startMutedMetadata = room.jitsiMetadata.startMuted or {};
local av_moderation_startMuted_restore = room.av_moderation_startMuted_restore or {};
startMutedMetadata[mediaType] = av_moderation_startMuted_restore[mediaType];
room.jitsiMetadata.startMuted = startMutedMetadata;
local is_empty = true;
for key,_ in pairs(room.av_moderation) do
if room.av_moderation[key] then
is_empty = false;
end
end
if is_empty then
room.av_moderation = nil;
end
end
end
-- send message to all occupants
notify_occupants_enable(nil, enabled, room, occupant.nick, mediaType);
if enabled then
-- inform all moderators for the newly created whitelist
notify_whitelist_change(nil, true, room, mediaType);
end
return true;
elseif moderation_command.attr.jidToWhitelist then
local occupant_jid = moderation_command.attr.jidToWhitelist;
-- check if jid is in the room, if so add it to whitelist
-- inform all moderators and admins and the jid
local occupant_to_add = room:get_occupant_by_nick(room_jid_match_rewrite(occupant_jid));
if not occupant_to_add then
module:log('warn', 'No occupant %s found for %s', occupant_jid, room.jid);
return false;
end
if room.av_moderation then
local whitelist = room.av_moderation[mediaType];
if not whitelist then
whitelist = array{};
room.av_moderation[mediaType] = whitelist;
end
whitelist:push(occupant_jid);
notify_whitelist_change(occupant_to_add.jid, true, room, mediaType, false);
return true;
else
-- this is a moderator asking the jid to unmute without enabling av moderation
-- let's just send the event
notify_jid_approved(occupant_to_add.jid, occupant.nick, room, mediaType);
end
elseif moderation_command.attr.jidToBlacklist then
local occupant_jid = moderation_command.attr.jidToBlacklist;
-- check if jid is in the room, if so remove it from the whitelist
-- inform all moderators and admins
local occupant_to_remove = room:get_occupant_by_nick(room_jid_match_rewrite(occupant_jid));
if not occupant_to_remove then
module:log('warn', 'No occupant %s found for %s', occupant_jid, room.jid);
return false;
end
if room.av_moderation then
local whitelist = room.av_moderation[mediaType];
if whitelist then
local index = get_index_in_table(whitelist, occupant_jid)
if(index) then
whitelist:pop(index);
notify_whitelist_change(occupant_to_remove.jid, true, room, mediaType, true);
end
end
return true;
end
end
end
-- return error
return false
end
-- handles new occupants to inform them about the state enabled/disabled, new moderators also get and the whitelist
function occupant_joined(event)
local room, occupant = event.room, event.occupant;
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
return;
end
-- when first moderator joins if av_can_unmute from password preset is set to false, we enable av moderation for both
-- audio and video, and set the first moderator as the actor that enabled it
if room._data.av_can_unmute ~= nil
and not room._data.av_first_moderator_joined
-- occupant.role is not reflecting the actual role after set_affiliation is used in same occupant_joined event
and room:get_role(occupant.nick) == 'moderator' then
if not room._data.av_can_unmute then
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
start_av_moderation(room, mediaType, occupant);
notify_occupants_enable(nil, true, room, occupant.nick, mediaType);
notify_whitelist_change(nil, true, room, mediaType);
end
room._data.av_first_moderator_joined = true;
return;
end
end
if room.av_moderation then
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if room.av_moderation[mediaType] then
notify_occupants_enable(
occupant.jid, true, room, room.av_moderation_actors[mediaType], mediaType);
end
end
-- NOTE for some reason event.occupant.role is not reflecting the actual occupant role (when changed
-- from allowners module) but iterating over room occupants returns the correct role
for _, room_occupant in room:each_occupant() do
-- if it is a moderator, send the whitelist to every moderator
if room_occupant.nick == occupant.nick and room_occupant.role == 'moderator' then
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if room.av_moderation[mediaType] then
notify_whitelist_change(nil, true, room, mediaType);
end
end
end
end
end
end
-- when a occupant was granted moderator we need to update him with the whitelist
function occupant_affiliation_changed(event)
local room = event.room;
if not room.av_moderation or is_healthcheck_room(room.jid) or is_admin(event.jid)
or event.affiliation ~= 'owner' then
return;
end
-- in any enabled media type add the new moderator to the whitelist
for _, room_occupant in room:each_occupant() do
if room_occupant.bare_jid == event.jid then
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if room.av_moderation[mediaType] then
room.av_moderation[mediaType]:push(internal_room_jid_match_rewrite(room_occupant.nick));
end
end
end
end
-- the actor can be nil if is coming from allowners or similar module we want to skip it here
-- as we will handle it in occupant_joined
if event.actor and event.affiliation == 'owner' then
-- notify all moderators for the new grant moderator and the change in whitelists
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if room.av_moderation[mediaType] then
notify_whitelist_change(nil, true, room, mediaType);
end
end
end
end
-- we will receive messages from the clients
module:hook('message/host', on_message);
process_host_module(muc_component_host, function(host_module, host)
module:log('info','Hook to muc events on %s', host);
host_module:hook('muc-occupant-joined', occupant_joined, -2); -- make sure it runs after allowners or similar
host_module:hook('muc-set-affiliation', occupant_affiliation_changed, -1);
end);
process_host_module(main_virtual_host, function(host_module)
module:context(host_module.host):fire_event('jitsi-add-identity', {
name = 'av_moderation'; host = module.host;
});
end);

View File

@@ -0,0 +1,23 @@
-- global module
-- validates certificates for all hosts used for s2soutinjection or s2sout_override
module:set_global();
local s2s_overrides = module:get_option("s2s_connect_overrides");
if not s2s_overrides then
s2s_overrides = module:get_option("s2sout_override");
end
function attach(event)
local session = event.session;
if s2s_overrides and s2s_overrides[event.host] then
session.cert_chain_status = 'valid';
session.cert_identity_status = 'valid';
return true;
end
end
module:wrap_event('s2s-check-certificate', function (handlers, event_name, event_data)
return attach(event_data);
end);

View File

@@ -0,0 +1,202 @@
if module:get_host_type() ~= "component" then
error("proxy_component should be loaded as component", 0);
end
local jid_split = require "util.jid".split;
local jid_bare = require "util.jid".bare;
local jid_prep = require "util.jid".prep;
local st = require "util.stanza";
local array = require "util.array";
local target_address = module:get_option_string("target_address");
sessions = array{};
local sessions = sessions;
local function handle_target_presence(stanza)
local type = stanza.attr.type;
module:log("debug", "received presence from destination: %s", type)
local _, _, resource = jid_split(stanza.attr.from);
if type == "error" then
-- drop all known sessions
for k in pairs(sessions) do
sessions[k] = nil
end
module:log(
"debug",
"received error presence, dropping all target sessions",
resource
)
elseif type == "unavailable" then
for k in pairs(sessions) do
if sessions[k] == resource then
sessions[k] = nil
module:log(
"debug",
"dropped target session: %s",
resource
)
break
end
end
elseif not type then
-- available
local found = false;
for k in pairs(sessions) do
if sessions[k] == resource then
found = true;
break
end
end
if not found then
module:log(
"debug",
"registered new target session: %s",
resource
)
sessions:push(resource)
end
end
end
local function handle_from_target(stanza)
local type = stanza.attr.type
module:log(
"debug",
"non-presence stanza from target: name = %s, type = %s",
stanza.name,
type
)
if stanza.name == "iq" then
if type == "error" or type == "result" then
-- de-NAT message
local _, _, denatted_to_unprepped = jid_split(stanza.attr.to);
local denatted_to = jid_prep(denatted_to_unprepped);
if not denatted_to then
module:log(
"debug",
"cannot de-NAT stanza, invalid to: %s",
denatted_to_unprepped
)
return
end
local denatted_from = module:get_host();
module:log(
"debug",
"de-NAT-ed stanza: from: %s -> %s, to: %s -> %s",
stanza.attr.from,
denatted_from,
stanza.attr.to,
denatted_to
)
stanza.attr.from = denatted_from
stanza.attr.to = denatted_to
module:send(stanza)
else
-- FIXME: we dont support NATing outbund requests atm.
module:send(st.error_reply(stanza, "cancel", "feature-not-implemented"))
end
elseif stanza.name == "message" then
-- not implemented yet, we need a way to ensure that routing doesnt
-- break
module:send(st.error_reply(stanza, "cancel", "feature-not-implemented"))
end
end
local function handle_to_target(stanza)
local type = stanza.attr.type;
module:log(
"debug",
"stanza to target: name = %s, type = %s",
stanza.name, type
)
if stanza.name == "presence" then
if type ~= "error" then
module:send(st.error_reply(stanza, "cancel", "bad-request"))
return
end
elseif stanza.name == "iq" then
if type == "get" or type == "set" then
if #sessions == 0 then
-- no sessions available to send to
module:log("debug", "no sessions to send to!")
module:send(st.error_reply(stanza, "cancel", "service-unavailable"))
return
end
-- find a target session
local target_session = sessions:random()
local target = target_address .. "/" .. target_session
-- encode sender JID in resource
local natted_from = module:get_host() .. "/" .. stanza.attr.from;
module:log(
"debug",
"NAT-ed stanza: from: %s -> %s, to: %s -> %s",
stanza.attr.from,
natted_from,
stanza.attr.to,
target
)
stanza.attr.from = natted_from
stanza.attr.to = target
module:send(stanza)
end
-- FIXME: handle and forward result/error correctly
elseif stanza.name == "message" then
-- not implemented yet, we need a way to ensure that routing doesnt
-- break
module:send(st.error_reply(stanza, "cancel", "feature-not-implemented"))
end
end
local function stanza_handler(event)
local origin, stanza = event.origin, event.stanza
module:log("debug", "received stanza from %s session", origin.type)
local bare_from = jid_bare(stanza.attr.from);
local _, _, to = jid_split(stanza.attr.to);
if bare_from == target_address then
-- from our target, to whom?
if not to then
-- directly to component
if stanza.name == "presence" then
handle_target_presence(stanza)
else
module:send(st.error_reply(stanza, "cancel", "bad-request"))
return true
end
else
-- to someone else
handle_from_target(stanza)
end
else
handle_to_target(stanza)
end
return true
end
module:hook("iq/bare", stanza_handler, -1);
module:hook("message/bare", stanza_handler, -1);
module:hook("presence/bare", stanza_handler, -1);
module:hook("iq/full", stanza_handler, -1);
module:hook("message/full", stanza_handler, -1);
module:hook("presence/full", stanza_handler, -1);
module:hook("iq/host", stanza_handler, -1);
module:hook("message/host", stanza_handler, -1);
module:hook("presence/host", stanza_handler, -1);
module:log("debug", "loaded proxy on %s", module:get_host())
subscription_request = st.presence({
type = "subscribe",
to = target_address,
from = module:get_host()}
)
module:send(subscription_request)

View File

@@ -0,0 +1,48 @@
local it = require "util.iterators";
local process_host_module = module:require "util".process_host_module;
local main_muc_component_config = module:get_option_string('main_muc');
if main_muc_component_config == nil then
module:log('error', 'lobby not enabled missing main_muc config');
return ;
end
-- Returns the meeting created timestamp form data.
function getMeetingCreatedTSConfig(room)
return {
name = "muc#roominfo_created_timestamp";
type = "text-single";
label = "The meeting created_timestamp.";
value = room.created_timestamp or "";
};
end
function occupant_joined(event)
local room = event.room;
local occupant = event.occupant;
local participant_count = it.count(room:each_occupant());
if participant_count > 1 then
if room.created_timestamp == nil then
room.created_timestamp = string.format('%i', os.time() * 1000); -- Lua provides UTC time in seconds, so convert to milliseconds
end
end
end
process_host_module(main_muc_component_config, function(host_module, host)
-- add meeting Id to the disco info requests to the room
host_module:hook("muc-disco#info", function(event)
table.insert(event.form, getMeetingCreatedTSConfig(event.room));
end);
-- Marks the created timestamp in the room object
host_module:hook("muc-occupant-joined", occupant_joined, -1);
end);
-- DEPRECATED and will be removed, giving time for mobile clients to update
local conference_duration_component
= module:get_option_string("conference_duration_component", "conferenceduration."..module.host);
if conference_duration_component then
module:add_identity("component", "conference_duration", conference_duration_component);
end

View File

@@ -0,0 +1,47 @@
-- DEPRECATED and will be removed, giving time for mobile clients to update
local st = require "util.stanza";
local socket = require "socket";
local json = require 'cjson.safe';
local it = require "util.iterators";
local process_host_module = module:require "util".process_host_module;
-- we use async to detect Prosody 0.10 and earlier
local have_async = pcall(require, "util.async");
if not have_async then
module:log("warn", "conference duration will not work with Prosody version 0.10 or less.");
return;
end
local muc_component_host = module:get_option_string("muc_component");
if muc_component_host == nil then
module:log("error", "No muc_component specified. No muc to operate on!");
return;
end
module:log("info", "Starting conference duration timer for %s", muc_component_host);
function occupant_joined(event)
local room = event.room;
local occupant = event.occupant;
local participant_count = it.count(room:each_occupant());
if participant_count > 1 then
local body_json = {};
body_json.type = 'conference_duration';
body_json.created_timestamp = room.created_timestamp;
local stanza = st.message({
from = module.host;
to = occupant.jid;
})
:tag("json-message", {xmlns='http://jitsi.org/jitmeet'})
:text(json.encode(body_json)):up();
room:route_stanza(stanza);
end
end
process_host_module(muc_component_host, function(host_module, host)
host_module:hook("muc-occupant-joined", occupant_joined, -1);
end);

View File

@@ -0,0 +1,54 @@
module:set_global();
local traceback = require "util.debug".traceback;
local pposix = require "util.pposix";
local os_date = os.date;
local render_filename = require "util.interpolation".new("%b{}", function (s) return s; end, {
yyyymmdd = function (t)
return os_date("%Y%m%d", t);
end;
hhmmss = function (t)
return os_date("%H%M%S", t);
end;
});
local count = 0;
local function get_filename(filename_template)
filename_template = filename_template;
return render_filename(filename_template, {
paths = prosody.paths;
pid = pposix.getpid();
count = count;
time = os.time();
});
end
local default_filename_template = "{paths.data}/traceback-{pid}-{count}.log";
local filename_template = module:get_option_string("debug_traceback_filename", default_filename_template);
local signal_name = module:get_option_string("debug_traceback_signal", "SIGUSR1");
function dump_traceback()
module:log("info", "Received %s, writing traceback", signal_name);
local tb = traceback();
module:fire_event("debug_traceback/triggered", { traceback = tb });
local f, err = io.open(get_filename(filename_template), "a+");
if not f then
module:log("error", "Unable to write traceback: %s", err);
return;
end
f:write("-- Traceback generated at ", os.date("%b %d %H:%M:%S"), " --\n");
f:write(tb, "\n");
f:write("-- End of traceback --\n");
f:close();
count = count + 1;
end
local mod_posix = module:depends("posix");
if rawget(mod_posix, "features") and mod_posix.features.signal_events then
module:hook("signal/"..signal_name, dump_traceback);
else
require"util.signal".signal(signal_name, dump_traceback);
end

View File

@@ -0,0 +1,95 @@
--
-- Component "endconference.jitmeet.example.com" "end_conference"
-- muc_component = muc.jitmeet.example.com
--
local util = module:require 'util';
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local process_host_module = util.process_host_module;
local END_CONFERENCE_REASON = 'The meeting has been terminated';
-- Since this file serves as both the host module and the component, we rely on the assumption that
-- end_conference_component var would only be define for the host and not in the end_conference component
-- TODO: Remove this if block after several stable releases when people update their configs
local end_conference_component = module:get_option_string('end_conference_component');
if end_conference_component then
module:log('warn', 'Please update your config by removing muc_end_conference module from '
.. 'the list of loaded modules in the main virtual host.');
module:depends("features_identity");
return; -- nothing left to do if called as host module
end
-- What follows is logic for the end_conference component
module:depends("jitsi_session");
local muc_component_host = module:get_option_string('muc_component');
if muc_component_host == nil then
module:log('error', 'No muc_component specified. No muc to operate on!');
return;
end
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
if not main_virtual_host then
module:log('warn', 'No "muc_mapper_domain_base" option set, disabling end conference component.');
return ;
end
module:log('info', 'Starting end_conference for %s', muc_component_host);
-- receives messages from clients to the component to end a conference
function on_message(event)
local session = event.origin;
-- Check the type of the incoming stanza to avoid loops:
if event.stanza.attr.type == 'error' then
return; -- We do not want to reply to these, so leave.
end
if not session or not session.jitsi_web_query_room then
return false;
end
local moderation_command = event.stanza:get_child('end_conference');
if moderation_command then
-- get room name with tenant and find room
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
module:log('warn', 'No room found found for %s/%s',
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return false;
end
-- check that the participant requesting is a moderator and is an occupant in the room
local from = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant then
module:log('warn', 'No occupant %s found for %s', from, room.jid);
return false;
end
if occupant.role ~= 'moderator' then
module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
return false;
end
-- destroy the room
room:destroy(nil, END_CONFERENCE_REASON);
module:log('info', 'Room %s destroyed by occupant %s', room.jid, from);
return true;
end
-- return error
return false
end
-- we will receive messages from the clients
module:hook('message/host', on_message);
process_host_module(main_virtual_host, function(host_module)
module:context(host_module.host):fire_event('jitsi-add-identity', {
name = 'end_conference'; host = module.host;
});
end);

View File

@@ -0,0 +1,8 @@
-- Other components can use the event 'jitsi-add-identity' to attach identity which
-- will be advertised by the main virtual host and discovered by clients.
-- With this we avoid having an almost empty module to just add identity with an extra config
module:hook('jitsi-add-identity', function(event)
module:log('info', 'Adding identity %s for host %s', event.name, event.host);
module:add_identity('component', event.name, event.host);
end);

View File

@@ -0,0 +1,245 @@
local json = require 'cjson.safe';
local jid = require 'util.jid';
local st = require 'util.stanza';
local util = module:require 'util';
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local is_admin = util.is_admin;
local process_host_module = util.process_host_module;
local FILE_SHARING_IDENTITY_TYPE = 'file-sharing';
local JSON_TYPE_ADD_FILE = 'add';
local JSON_TYPE_REMOVE_FILE = 'remove';
local JSON_TYPE_LIST_FILES = 'list';
local NICK_NS = 'http://jabber.org/protocol/nick';
-- this is the main virtual host of the main prosody that this vnode serves
local main_domain = module:get_option_string('main_domain');
-- only the visitor prosody has main_domain setting
local is_visitor_prosody = main_domain ~= nil;
local muc_component_host = module:get_option_string('muc_component');
if muc_component_host == nil then
module:log('error', 'No muc_component specified. No muc to operate on!');
return;
end
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
if not muc_domain_base then
module:log("warn", "No 'muc_domain_base' option set, disabling file sharing component.");
return ;
end
-- receives messages from clients to the component sending file sharing commands for adding or removing files
function on_message(event)
local session, stanza = event.origin, event.stanza;
-- Check the type of the incoming stanza to avoid loops:
if stanza.attr.type == 'error' then
return; -- We do not want to reply to these, so leave.
end
if not session or not session.jitsi_web_query_room then
return false;
end
local message = stanza:get_child(FILE_SHARING_IDENTITY_TYPE, 'http://jitsi.org/jitmeet');
if not message then
return false;
end
-- get room name with tenant and find room
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
module:log('warn', 'No room found for %s/%s', session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return false;
end
-- check that the participant sending the message is an occupant in the room
local from = stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant then
module:log('warn', 'No occupant %s found for %s', from, room.jid);
return false;
end
if not is_feature_allowed(
'file-upload',
session.jitsi_meet_context_features,
room:get_affiliation(stanza.attr.from) == 'owner') then
session.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
end
if message.attr.type == JSON_TYPE_ADD_FILE then
local msg_obj, error = json.decode(message:get_text());
if error then
module:log('error','Error decoding data error:%s %s', error, stanza);
return false;
end
if not msg_obj.fileId then
module:log('error', 'Error missing required field: %s', stanza);
return false;
end
-- make sure we overwrite data for sender so we avoid spoofing
msg_obj.authorParticipantId = jid.resource(occupant.nick);
msg_obj.authorParticipantJid = from;
local nick_element = occupant:get_presence():get_child('nick', NICK_NS);
if nick_element then
msg_obj.authorParticipantName = nick_element:get_text();
else
msg_obj.authorParticipantName = 'anonymous';
end
msg_obj.conferenceFullName = internal_room_jid_match_rewrite(room.jid);
module:context(muc_domain_base):fire_event('jitsi-filesharing-add', {
room = room; file = msg_obj; actor = occupant.nick;
});
module:context(muc_domain_base):fire_event('jitsi-filesharing-updated', {
room = room;
});
return true;
elseif message.attr.type == JSON_TYPE_REMOVE_FILE then
if not message.attr.fileId then
module:log('error', 'Error missing required field: %s', stanza);
return true;
end
module:context(muc_domain_base):fire_event('jitsi-filesharing-remove', {
room = room; id = message.attr.fileId; actor = occupant.nick;
});
module:context(muc_domain_base):fire_event('jitsi-filesharing-updated', {
room = room;
});
return true;
else
-- return error.
return false;
end
end
-- handles new occupants to inform them about any file shared by other participants
function occupant_joined(event)
local room, occupant = event.room, event.occupant;
-- healthcheck rooms does not have shared files
if not room.jitsi_shared_files
or is_admin(occupant.bare_jid)
or not room.jitsi_shared_files
or next(room.jitsi_shared_files) == nil then
return;
end
-- send file list to the new occupant
local json_msg, error = json.encode({
type = FILE_SHARING_IDENTITY_TYPE,
event = JSON_TYPE_LIST_FILES,
files = room.jitsi_shared_files
});
local stanza = st.message({ from = module.host; to = occupant.jid; })
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' })
:text(json_msg):up();
module:send(stanza);
end
process_host_module(muc_component_host, function(host_module, host)
module:log('info','Hook to muc events on %s', host);
host_module:hook('muc-occupant-joined', occupant_joined, -10); -- make sure it runs after allowners or similar
end);
-- we will receive messages from the clients
module:hook('message/host', on_message);
process_host_module(muc_domain_base, function(host_module, host)
module:context(muc_domain_base):fire_event('jitsi-add-identity', {
name = FILE_SHARING_IDENTITY_TYPE; host = module.host;
});
module:context(muc_domain_base):hook('jitsi-filesharing-add', function(event)
local actor, file, room = event.actor, event.file, event.room;
if not room.jitsi_shared_files then
room.jitsi_shared_files = {};
end
room.jitsi_shared_files[file.fileId] = file;
local json_msg, error = json.encode({
type = FILE_SHARING_IDENTITY_TYPE,
event = JSON_TYPE_ADD_FILE,
file = file
});
if not json_msg then
module:log('error', 'skip sending add request room:%s error:%s', room.jid, error);
return false
end
local stanza = st.message({ from = module.host; }):tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' })
:text(json_msg):up();
-- send add file to all occupants except jicofo and sender
-- if this is visitor prosody send it only to visitors
for _, room_occupant in room:each_occupant() do
local send_event = not is_admin(room_occupant.bare_jid) and room_occupant.nick ~= actor;
if is_visitor_prosody then
send_event = room_occupant.role == 'visitor';
end
if send_event then
local to_send = st.clone(stanza);
to_send.attr.to = room_occupant.jid;
module:send(to_send);
end
end
end);
module:context(muc_domain_base):hook('jitsi-filesharing-remove', function(event)
local actor, id, room = event.actor, event.id, event.room;
if not room.jitsi_shared_files then
return;
end
room.jitsi_shared_files[id] = nil;
local json_msg, error = json.encode({
type = FILE_SHARING_IDENTITY_TYPE,
event = JSON_TYPE_REMOVE_FILE,
fileId = id
});
if not json_msg then
module:log('error', 'skip sending remove request room:%s error:%s', room.jid, error);
return false
end
local stanza = st.message({ from = module.host; }):tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' })
:text(json_msg):up();
-- send remove file to all occupants except jicofo and sender
-- if this is visitor prosody send it only to visitors
for _, room_occupant in room:each_occupant() do
local send_event = not is_admin(room_occupant.bare_jid) and room_occupant.nick ~= actor;
if is_visitor_prosody then
send_event = room_occupant.role == 'visitor';
end
if send_event then
local to_send = st.clone(stanza);
to_send.attr.to = room_occupant.jid;
module:send(to_send);
end
end
end);
end);

View File

@@ -0,0 +1,68 @@
-- This module is enabled under the main virtual host
local cache = require 'util.cache';
local new_throttle = require 'util.throttle'.create;
local st = require "util.stanza";
local jid_bare = require "util.jid".bare;
local util = module:require 'util';
local is_feature_allowed = util.is_feature_allowed;
local get_ip = util.get_ip;
local get_room_from_jid = util.get_room_from_jid;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local limit_jibri_reach_ip_attempts;
local limit_jibri_reach_room_attempts;
local rates_per_ip;
local function load_config()
limit_jibri_reach_ip_attempts = module:get_option_number("max_number_ip_attempts_per_minute", 9);
limit_jibri_reach_room_attempts = module:get_option_number("max_number_room_attempts_per_minute", 3);
-- The size of the cache that saves state for IP addresses
cache_size = module:get_option_number("jibri_rate_limit_cache_size", 10000);
-- Maps an IP address to a util.throttle which keeps the rate of attempts to reach jibri events from that IP.
rates_per_ip = cache.new(cache_size);
end
load_config();
-- filters jibri iq in case of requested from jwt authenticated session that
-- has features in the user context, but without feature for recording
module:hook("pre-iq/full", function(event)
local stanza = event.stanza;
if stanza.name == "iq" then
local jibri = stanza:get_child('jibri', 'http://jitsi.org/protocol/jibri');
if jibri then
local session = event.origin;
local token = session.auth_token;
local room = get_room_from_jid(room_jid_match_rewrite(jid_bare(stanza.attr.to)));
local occupant = room:get_occupant_by_real_jid(stanza.attr.from);
local feature = jibri.attr.recording_mode == 'file' and 'recording' or 'livestreaming';
local is_allowed = is_feature_allowed(
feature,
session.jitsi_meet_context_features,
occupant.role == 'moderator');
if jibri.attr.action == 'start' or jibri.attr.action == 'stop' then
if not is_allowed then
module:log('info', 'Filtering jibri start recording, stanza:%s', tostring(stanza));
session.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
end
local ip = get_ip(session);
if not rates_per_ip:get(ip) then
rates_per_ip:set(ip, new_throttle(limit_jibri_reach_ip_attempts, 60));
end
if not room.jibri_throttle then
room.jibri_throttle = new_throttle(limit_jibri_reach_room_attempts, 60);
end
if not rates_per_ip:get(ip):poll(1) or not room.jibri_throttle:poll(1) then
module:log('warn', 'Filtering jibri start recording, ip:%s, room:%s stanza:%s',
ip, room.jid, tostring(stanza));
session.send(st.error_reply(stanza, 'wait', 'policy-violation'));
return true;
end
end
end
end
end);

View File

@@ -0,0 +1,278 @@
-- This module is enabled under the main virtual host
local new_throttle = require "util.throttle".create;
local st = require "util.stanza";
local jid = require "util.jid";
local util = module:require 'util';
local is_admin = util.is_admin;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local is_feature_allowed = util.is_feature_allowed;
local is_sip_jigasi = util.is_sip_jigasi;
local get_room_from_jid = util.get_room_from_jid;
local process_host_module = util.process_host_module;
local jid_bare = require "util.jid".bare;
local sessions = prosody.full_sessions;
local measure_drop = module:measure('drop', 'counter');
local main_muc_component_host = module:get_option_string('main_muc');
if main_muc_component_host == nil then
module:log('error', 'main_muc not configured. Cannot proceed.');
return;
end
local main_muc_service;
-- this is the main virtual host of the main prosody that this vnode serves
local main_domain = module:get_option_string('main_domain');
-- only the visitor prosody has main_domain setting
local is_visitor_prosody = main_domain ~= nil;
-- this is the main virtual host of this vnode
local local_domain = module:get_option_string('muc_mapper_domain_base');
local parentCtx = module:context(local_domain);
if parentCtx == nil then
log("error",
"Failed to start - unable to get parent context for host: %s",
tostring(local_domain));
return;
end
local token_util = module:require "token/util".new(parentCtx);
-- no token configuration but required
if token_util == nil then
module:log("error", "no token configuration but it is required");
return;
end
-- The maximum number of simultaneous calls,
-- and also the maximum number of new calls per minute that a session is allowed to create.
local limit_outgoing_calls;
local function load_config()
limit_outgoing_calls = module:get_option_number("max_number_outgoing_calls", -1);
end
load_config();
-- Header names to use to push extra data extracted from token, if any
local OUT_INITIATOR_USER_ATTR_NAME = "X-outbound-call-initiator-user";
local OUT_INITIATOR_GROUP_ATTR_NAME = "X-outbound-call-initiator-group";
local OUT_ROOM_NAME_ATTR_NAME = "JvbRoomName";
local OUTGOING_CALLS_THROTTLE_INTERVAL = 60; -- if max_number_outgoing_calls is enabled it will be
-- the max number of outgoing calls a user can try for a minute
-- filters rayo iq in case of requested from not jwt authenticated sessions
-- or if the session has features in user context and it doesn't mention
-- feature "outbound-call" to be enabled
module:hook("pre-iq/full", function(event)
local stanza = event.stanza;
if stanza.name == "iq" then
local dial = stanza:get_child('dial', 'urn:xmpp:rayo:1');
if dial then
local session = event.origin;
local token = session.auth_token;
-- find header with attr name 'JvbRoomName' and extract its value
local roomName;
-- Remove any 'header' element if it already exists, so it cannot be spoofed by a client
dial:maptags(function(tag)
if tag.name == "header"
and (tag.attr.name == OUT_INITIATOR_USER_ATTR_NAME
or tag.attr.name == OUT_INITIATOR_GROUP_ATTR_NAME) then
return nil
elseif tag.name == "header" and tag.attr.name == OUT_ROOM_NAME_ATTR_NAME then
roomName = tag.attr.value;
-- we will remove it as we will add it later, modified
if is_visitor_prosody then
return nil;
end
end
return tag
end);
local room_jid = jid.bare(stanza.attr.to);
local room_real_jid = room_jid_match_rewrite(room_jid);
local room = main_muc_service.get_room_from_jid(room_real_jid);
local is_sender_in_room = room:get_occupant_jid(stanza.attr.from) ~= nil;
if not room or not is_sender_in_room then
module:log("warn", "Filtering stanza dial, stanza:%s", tostring(stanza));
session.send(st.error_reply(stanza, "auth", "forbidden"));
return true;
end
local feature = dial.attr.to == 'jitsi_meet_transcribe' and 'transcription' or 'outbound-call';
local is_session_allowed = is_feature_allowed(
feature,
session.jitsi_meet_context_features,
room:get_affiliation(stanza.attr.from) == 'owner');
if roomName == nil
or roomName ~= room_jid
or (token ~= nil and not token_util:verify_room(session, room_real_jid))
or not is_session_allowed
then
module:log("warn", "Filtering stanza dial, stanza:%s", tostring(stanza));
session.send(st.error_reply(stanza, "auth", "forbidden"));
return true;
end
-- we get current user_id or group, or the one from the granted one
-- so guests and the user that granted rights are sharing same limit, as guest can be without token
local user_id, group_id = nil, session.jitsi_meet_context_group;
if session.jitsi_meet_context_user then
user_id = session.jitsi_meet_context_user["id"];
else
user_id = session.granted_jitsi_meet_context_user_id;
group_id = session.granted_jitsi_meet_context_group_id;
end
-- now lets check any limits for outgoing calls if configured
if feature == 'outbound-call' and limit_outgoing_calls > 0 then
if not session.dial_out_throttle then
-- module:log("debug", "Enabling dial-out throttle session=%s.", session);
session.dial_out_throttle = new_throttle(limit_outgoing_calls, OUTGOING_CALLS_THROTTLE_INTERVAL);
end
if not session.dial_out_throttle:poll(1) -- we first check the throttle so we can mark one incoming dial for the balance
or get_concurrent_outgoing_count(user_id, group_id) >= limit_outgoing_calls
then
module:log("warn",
"Filtering stanza dial, stanza:%s, outgoing calls limit reached", tostring(stanza));
measure_drop(1);
session.send(st.error_reply(stanza, "cancel", "resource-constraint"));
return true;
end
end
-- now lets insert token information if any
if session and user_id then
-- adds initiator user id from token
dial:tag("header", {
xmlns = "urn:xmpp:rayo:1",
name = OUT_INITIATOR_USER_ATTR_NAME,
value = tostring(user_id)});
dial:up();
-- Add the initiator group information if it is present
if session.jitsi_meet_context_group then
dial:tag("header", {
xmlns = "urn:xmpp:rayo:1",
name = OUT_INITIATOR_GROUP_ATTR_NAME,
value = tostring(session.jitsi_meet_context_group) });
dial:up();
end
end
-- we want to instruct jigasi to enter the main room, so send the correct main room jid
if is_visitor_prosody then
dial:tag("header", {
xmlns = "urn:xmpp:rayo:1",
name = OUT_ROOM_NAME_ATTR_NAME,
value = string.gsub(roomName, local_domain, main_domain) });
dial:up();
end
end
end
end, 1); -- make sure we run before domain mapper
--- Finds and returns the number of concurrent outgoing calls for a user
-- @param context_user the user id extracted from the token
-- @param context_group the group id extracted from the token
-- @return returns the count of concurrent calls
function get_concurrent_outgoing_count(context_user, context_group)
local count = 0;
local rooms = main_muc_service.live_rooms();
-- now lets iterate over rooms and occupants and search for
-- call initiated by the user
for room in rooms do
for _, occupant in room:each_occupant() do
for _, presence in occupant:each_session() do
local initiator = is_sip_jigasi(presence);
local found_user = false;
local found_group = false;
if initiator then
initiator:maptags(function (tag)
if tag.name == "header"
and tag.attr.name == OUT_INITIATOR_USER_ATTR_NAME then
found_user = tag.attr.value == context_user;
elseif tag.name == "header"
and tag.attr.name == OUT_INITIATOR_GROUP_ATTR_NAME then
found_group = tag.attr.value == context_group;
end
return tag;
end );
-- if found a jigasi participant initiated by the concurrent
-- participant, count it
if found_user
and (context_group == nil or found_group) then
count = count + 1;
end
end
end
end
end
return count;
end
module:hook_global('config-reloaded', load_config);
function process_main_muc_loaded(main_muc, host_module)
module:log('debug', 'Main muc loaded');
main_muc_service = main_muc;
end
process_host_module(main_muc_component_host, function(host_module, host)
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_main_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
-- when recording participants may enable and backend transcriptions
-- it is possible that participant is not moderator, but has the features enabled for
-- transcribing, we need to allow that operation
module:hook('jitsi-metadata-allow-moderation', function (event)
local data, key, occupant, session = event.data, event.key, event.actor, event.session;
if key == 'recording' and data and data.isTranscribingEnabled ~= nil then
-- if it is recording we want to allow setting in metadata if not moderator but features
-- are present
if session.jitsi_meet_context_features
and occupant.role ~= 'moderator'
and is_feature_allowed('transcription', session.jitsi_meet_context_features)
and is_feature_allowed('recording', session.jitsi_meet_context_features) then
local res = {};
res.isTranscribingEnabled = data.isTranscribingEnabled;
return res;
elseif not session.jitsi_meet_context_features and occupant.role == 'moderator' then
return data;
else
return nil;
end
end
if occupant.role == 'moderator' then
return data;
end
return nil;
end);

View File

@@ -0,0 +1,42 @@
-- enable under the main muc module
-- a module that will filter group messages based on features (jitsi_meet_context_features)
-- when requested via metadata (permissions.groupChatRestricted)
local util = module:require 'util';
local get_room_from_jid = util.get_room_from_jid;
local st = require 'util.stanza';
local function on_message(event)
local stanza = event.stanza;
local body = stanza:get_child('body');
local session = event.origin;
if not body or not session then
-- we ignore messages without body - lobby, polls ...
return;
end
-- get room name with tenant and find room.
-- this should already been through domain mapper and this should be the real room jid [tenant]name format
local room = get_room_from_jid(stanza.attr.to);
if not room then
module:log('warn', 'No room found found for %s', stanza.attr.to);
return;
end
if room.jitsiMetadata and room.jitsiMetadata.permissions
and room.jitsiMetadata.permissions.groupChatRestricted
and not is_feature_allowed('send-groupchat', session.jitsi_meet_context_features) then
local reply = st.error_reply(stanza, 'cancel', 'not-allowed', 'Sending group messages not allowed');
if session.type == 's2sin' or session.type == 's2sout' then
reply.skipMapping = true;
end
module:send(reply);
-- let's filter this message
return true;
end
end
module:hook('message/bare', on_message); -- room messages
module:hook('jitsi-visitor-groupchat-pre-route', on_message); -- visitors messages

View File

@@ -0,0 +1,280 @@
local unpack = table.unpack or unpack;
local interpolation = require "util.interpolation";
local template = interpolation.new("%b$$", function (s) return ("%q"):format(s) end);
--luacheck: globals meta idsafe
local action_handlers = {};
-- Takes an XML string and returns a code string that builds that stanza
-- using st.stanza()
local function compile_xml(data)
local code = {};
local first, short_close = true, nil;
for tagline, text in data:gmatch("<([^>]+)>([^<]*)") do
if tagline:sub(-1,-1) == "/" then
tagline = tagline:sub(1, -2);
short_close = true;
end
if tagline:sub(1,1) == "/" then
code[#code+1] = (":up()");
else
local name, attr = tagline:match("^(%S*)%s*(.*)$");
local attr_str = {};
for k, _, v in attr:gmatch("(%S+)=([\"'])([^%2]-)%2") do
if #attr_str == 0 then
table.insert(attr_str, ", { ");
else
table.insert(attr_str, ", ");
end
if k:find("^%a%w*$") then
table.insert(attr_str, string.format("%s = %q", k, v));
else
table.insert(attr_str, string.format("[%q] = %q", k, v));
end
end
if #attr_str > 0 then
table.insert(attr_str, " }");
end
if first then
code[#code+1] = (string.format("st.stanza(%q %s)", name, #attr_str>0 and table.concat(attr_str) or ", nil"));
first = nil;
else
code[#code+1] = (string.format(":tag(%q%s)", name, table.concat(attr_str)));
end
end
if text and text:find("%S") then
code[#code+1] = (string.format(":text(%q)", text));
elseif short_close then
short_close = nil;
code[#code+1] = (":up()");
end
end
return table.concat(code, "");
end
function action_handlers.PASS()
return "do return pass_return end"
end
function action_handlers.DROP()
return "do return true end";
end
function action_handlers.DEFAULT()
return "do return false end";
end
function action_handlers.RETURN()
return "do return end"
end
function action_handlers.STRIP(tag_desc)
local code = {};
local name, xmlns = tag_desc:match("^(%S+) (.+)$");
if not name then
name, xmlns = tag_desc, nil;
end
if name == "*" then
name = nil;
end
code[#code+1] = ("local stanza_xmlns = stanza.attr.xmlns; ");
code[#code+1] = "stanza:maptags(function (tag) if ";
if name then
code[#code+1] = ("tag.name == %q and "):format(name);
end
if xmlns then
code[#code+1] = ("(tag.attr.xmlns or stanza_xmlns) == %q "):format(xmlns);
else
code[#code+1] = ("tag.attr.xmlns == stanza_xmlns ");
end
code[#code+1] = "then return nil; end return tag; end );";
return table.concat(code);
end
function action_handlers.INJECT(tag)
return "stanza:add_child("..compile_xml(tag)..")", { "st" };
end
local error_types = {
["bad-request"] = "modify";
["conflict"] = "cancel";
["feature-not-implemented"] = "cancel";
["forbidden"] = "auth";
["gone"] = "cancel";
["internal-server-error"] = "cancel";
["item-not-found"] = "cancel";
["jid-malformed"] = "modify";
["not-acceptable"] = "modify";
["not-allowed"] = "cancel";
["not-authorized"] = "auth";
["payment-required"] = "auth";
["policy-violation"] = "modify";
["recipient-unavailable"] = "wait";
["redirect"] = "modify";
["registration-required"] = "auth";
["remote-server-not-found"] = "cancel";
["remote-server-timeout"] = "wait";
["resource-constraint"] = "wait";
["service-unavailable"] = "cancel";
["subscription-required"] = "auth";
["undefined-condition"] = "cancel";
["unexpected-request"] = "wait";
};
local function route_modify(make_new, to, drop)
local reroute, deps = "session.send(newstanza)", { "st" };
if to then
reroute = ("newstanza.attr.to = %q; core_post_stanza(session, newstanza)"):format(to);
deps[#deps+1] = "core_post_stanza";
end
return ([[do local newstanza = st.%s; %s;%s end]])
:format(make_new, reroute, drop and " return true" or ""), deps;
end
function action_handlers.BOUNCE(with)
local error = with and with:match("^%S+") or "service-unavailable";
local error_type = error:match(":(%S+)");
if not error_type then
error_type = error_types[error] or "cancel";
else
error = error:match("^[^:]+");
end
error, error_type = string.format("%q", error), string.format("%q", error_type);
local text = with and with:match(" %((.+)%)$");
if text then
text = string.format("%q", text);
else
text = "nil";
end
local route_modify_code, deps = route_modify(("error_reply(stanza, %s, %s, %s)"):format(error_type, error, text), nil, true);
deps[#deps+1] = "type";
deps[#deps+1] = "name";
return [[if type == "error" or (name == "iq" and type == "result") then return true; end -- Don't reply to 'error' stanzas, or iq results
]]..route_modify_code, deps;
end
function action_handlers.REDIRECT(where)
return route_modify("clone(stanza)", where, true);
end
function action_handlers.COPY(where)
return route_modify("clone(stanza)", where, false);
end
function action_handlers.REPLY(with)
return route_modify(("reply(stanza):body(%q)"):format(with));
end
function action_handlers.FORWARD(where)
local code = [[
local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" });
local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza);
core_post_stanza(session, newstanza);
]];
return code:format(where), { "core_post_stanza", "current_host" };
end
function action_handlers.LOG(string)
local level = string:match("^%[(%a+)%]") or "info";
string = string:gsub("^%[%a+%] ?", "");
local meta_deps = {};
local code = meta(("(session.log or log)(%q, '%%s', %q);"):format(level, string), meta_deps);
return code, meta_deps;
end
function action_handlers.RULEDEP(dep)
return "", { dep };
end
function action_handlers.EVENT(name)
return ("fire_event(%q, event)"):format(name);
end
function action_handlers.JUMP_EVENT(name)
return ("do return fire_event(%q, event); end"):format(name);
end
function action_handlers.JUMP_CHAIN(name)
return template([[do
local ret = fire_event($chain_event$, event);
if ret ~= nil then
if ret == false then
log("debug", "Chain %q accepted stanza (ret %s)", $chain_name$, tostring(ret));
return pass_return;
end
log("debug", "Chain %q rejected stanza (ret %s)", $chain_name$, tostring(ret));
return ret;
end
end]], { chain_event = "firewall/chains/"..name, chain_name = name });
end
function action_handlers.MARK_ORIGIN(name)
return [[session.firewall_marked_]]..idsafe(name)..[[ = current_timestamp;]], { "timestamp" };
end
function action_handlers.UNMARK_ORIGIN(name)
return [[session.firewall_marked_]]..idsafe(name)..[[ = nil;]]
end
function action_handlers.MARK_USER(name)
return ([[if session.username and session.host == current_host then
fire_event("firewall/marked/user", {
username = session.username;
mark = %q;
timestamp = current_timestamp;
});
else
log("warn", "Attempt to MARK a remote user - only local users may be marked");
end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name)), {
"current_host";
"timestamp";
};
end
function action_handlers.UNMARK_USER(name)
return ([[if session.username and session.host == current_host then
fire_event("firewall/unmarked/user", {
username = session.username;
mark = %q;
});
else
log("warn", "Attempt to UNMARK a remote user - only local users may be marked");
end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name));
end
function action_handlers.ADD_TO(spec)
local list_name, value = spec:match("(%S+) (.+)");
local meta_deps = {};
value = meta(("%q"):format(value), meta_deps);
return ("list_%s:add(%s);"):format(list_name, value), { "list:"..list_name, unpack(meta_deps) };
end
function action_handlers.UNSUBSCRIBE_SENDER()
return "rostermanager.unsubscribed(to_node, to_host, bare_from);\
rostermanager.roster_push(to_node, to_host, bare_from);\
core_post_stanza(session, st.presence({ from = bare_to, to = bare_from, type = \"unsubscribed\" }));",
{ "rostermanager", "core_post_stanza", "st", "split_to", "bare_to", "bare_from" };
end
function action_handlers.REPORT_TO(spec)
local where, reason, text = spec:match("^%s*(%S+) *(%S*) *(.*)$");
if reason == "spam" then
reason = "urn:xmpp:reporting:spam";
elseif reason == "abuse" or not reason then
reason = "urn:xmpp:reporting:abuse";
end
local code = [[
local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" });
local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza):up();
newstanza:tag("report", { xmlns = "urn:xmpp:reporting:1", reason = %q })
do local text = %q; if text ~= "" then newstanza:text_tag("text", text); end end
newstanza:up();
core_post_stanza(session, newstanza);
]];
return code:format(where, reason, text), { "core_post_stanza", "current_host", "st" };
end
return action_handlers;

View File

@@ -0,0 +1,384 @@
--luacheck: globals meta idsafe
local condition_handlers = {};
local jid = require "util.jid";
local unpack = table.unpack or unpack;
-- Helper to convert user-input strings (yes/true//no/false) to a bool
local function string_to_boolean(s)
s = s:lower();
return s == "yes" or s == "true";
end
-- Return a code string for a condition that checks whether the contents
-- of variable with the name 'name' matches any of the values in the
-- comma/space/pipe delimited list 'values'.
local function compile_comparison_list(name, values)
local conditions = {};
for value in values:gmatch("[^%s,|]+") do
table.insert(conditions, ("%s == %q"):format(name, value));
end
return table.concat(conditions, " or ");
end
function condition_handlers.KIND(kind)
assert(kind, "Expected stanza kind to match against");
return compile_comparison_list("name", kind), { "name" };
end
local wildcard_equivs = { ["*"] = ".*", ["?"] = "." };
local function compile_jid_match_part(part, match)
if not match then
return part.." == nil";
end
local pattern = match:match("^<(.*)>$");
if pattern then
if pattern == "*" then
return part;
end
if pattern:find("^<.*>$") then
pattern = pattern:match("^<(.*)>$");
else
pattern = pattern:gsub("%p", "%%%0"):gsub("%%(%p)", wildcard_equivs);
end
return ("(%s and %s:find(%q))"):format(part, part, "^"..pattern.."$");
else
return ("%s == %q"):format(part, match);
end
end
local function compile_jid_match(which, match_jid)
local match_node, match_host, match_resource = jid.split(match_jid);
local conditions = {};
conditions[#conditions+1] = compile_jid_match_part(which.."_node", match_node);
conditions[#conditions+1] = compile_jid_match_part(which.."_host", match_host);
if match_resource then
conditions[#conditions+1] = compile_jid_match_part(which.."_resource", match_resource);
end
return table.concat(conditions, " and ");
end
function condition_handlers.TO(to)
return compile_jid_match("to", to), { "split_to" };
end
function condition_handlers.FROM(from)
return compile_jid_match("from", from), { "split_from" };
end
function condition_handlers.FROM_FULL_JID()
return "not "..compile_jid_match_part("from_resource", nil), { "split_from" };
end
function condition_handlers.FROM_EXACTLY(from)
local metadeps = {};
return ("from == %s"):format(metaq(from, metadeps)), { "from", unpack(metadeps) };
end
function condition_handlers.TO_EXACTLY(to)
local metadeps = {};
return ("to == %s"):format(metaq(to, metadeps)), { "to", unpack(metadeps) };
end
function condition_handlers.TO_SELF()
-- Intentionally not using 'to' here, as that defaults to bare JID when nil
return ("stanza.attr.to == nil");
end
function condition_handlers.TYPE(type)
assert(type, "Expected 'type' value to match against");
return compile_comparison_list("(type or (name == 'message' and 'normal') or (name == 'presence' and 'available'))", type), { "type", "name" };
end
local function zone_check(zone, which)
local zone_var = zone;
if zone == "$local" then zone_var = "_local" end
local which_not = which == "from" and "to" or "from";
return ("(zone_%s[%s_host] or zone_%s[%s] or zone_%s[bare_%s]) "
.."and not(zone_%s[%s_host] or zone_%s[%s] or zone_%s[bare_%s])"
)
:format(zone_var, which, zone_var, which, zone_var, which,
zone_var, which_not, zone_var, which_not, zone_var, which_not), {
"split_to", "split_from", "bare_to", "bare_from", "zone:"..zone
};
end
function condition_handlers.ENTERING(zone)
return zone_check(zone, "to");
end
function condition_handlers.LEAVING(zone)
return zone_check(zone, "from");
end
-- IN ROSTER? (parameter is deprecated)
function condition_handlers.IN_ROSTER(yes_no)
local in_roster_requirement = string_to_boolean(yes_no or "yes"); -- COMPAT w/ older scripts
return "not "..(in_roster_requirement and "not" or "").." roster_entry", { "roster_entry" };
end
function condition_handlers.IN_ROSTER_GROUP(group)
return ("not not (roster_entry and roster_entry.groups[%q])"):format(group), { "roster_entry" };
end
function condition_handlers.SUBSCRIBED()
return "(bare_to == bare_from or to_node and rostermanager.is_contact_subscribed(to_node, to_host, bare_from))",
{ "rostermanager", "split_to", "bare_to", "bare_from" };
end
function condition_handlers.PENDING_SUBSCRIPTION_FROM_SENDER()
return "(bare_to == bare_from or to_node and rostermanager.is_contact_pending_in(to_node, to_host, bare_from))",
{ "rostermanager", "split_to", "bare_to", "bare_from" };
end
function condition_handlers.PAYLOAD(payload_ns)
return ("stanza:get_child(nil, %q)"):format(payload_ns);
end
function condition_handlers.INSPECT(path)
if path:find("=") then
local query, match_type, value = path:match("(.-)([~/$]*)=(.*)");
if not(query:match("#$") or query:match("@[^/]+")) then
error("Stanza path does not return a string (append # for text content or @name for value of named attribute)", 0);
end
local meta_deps = {};
local quoted_value = ("%q"):format(value);
if match_type:find("$", 1, true) then
match_type = match_type:gsub("%$", "");
quoted_value = meta(quoted_value, meta_deps);
end
if match_type == "~" then -- Lua pattern match
return ("(stanza:find(%q) or ''):match(%s)"):format(query, quoted_value), meta_deps;
elseif match_type == "/" then -- find literal substring
return ("(stanza:find(%q) or ''):find(%s, 1, true)"):format(query, quoted_value), meta_deps;
elseif match_type == "" then -- exact match
return ("stanza:find(%q) == %s"):format(query, quoted_value), meta_deps;
else
error("Unrecognised comparison '"..match_type.."='", 0);
end
end
return ("stanza:find(%q)"):format(path);
end
function condition_handlers.FROM_GROUP(group_name)
return ("group_contains(%q, bare_from)"):format(group_name), { "group_contains", "bare_from" };
end
function condition_handlers.TO_GROUP(group_name)
return ("group_contains(%q, bare_to)"):format(group_name), { "group_contains", "bare_to" };
end
function condition_handlers.CROSSING_GROUPS(group_names)
local code = {};
for group_name in group_names:gmatch("([^, ][^,]+)") do
group_name = group_name:match("^%s*(.-)%s*$"); -- Trim leading/trailing whitespace
-- Just check that's it is crossing from outside group to inside group
table.insert(code, ("(group_contains(%q, bare_to) and group_contains(%q, bare_from))"):format(group_name, group_name))
end
return "not "..table.concat(code, " or "), { "group_contains", "bare_to", "bare_from" };
end
-- COMPAT w/0.12: Deprecated
function condition_handlers.FROM_ADMIN_OF(host)
return ("is_admin(bare_from, %s)"):format(host ~= "*" and metaq(host) or nil), { "is_admin", "bare_from" };
end
-- COMPAT w/0.12: Deprecated
function condition_handlers.TO_ADMIN_OF(host)
return ("is_admin(bare_to, %s)"):format(host ~= "*" and metaq(host) or nil), { "is_admin", "bare_to" };
end
-- COMPAT w/0.12: Deprecated
function condition_handlers.FROM_ADMIN()
return ("is_admin(bare_from, current_host)"), { "is_admin", "bare_from", "current_host" };
end
-- COMPAT w/0.12: Deprecated
function condition_handlers.TO_ADMIN()
return ("is_admin(bare_to, current_host)"), { "is_admin", "bare_to", "current_host" };
end
-- MAY: permission_to_check
function condition_handlers.MAY(permission_to_check)
return ("module:may(%q, event)"):format(permission_to_check);
end
function condition_handlers.TO_ROLE(role_name)
return ("get_jid_role(bare_to, current_host) == %q"):format(role_name), { "get_jid_role", "current_host", "bare_to" };
end
function condition_handlers.FROM_ROLE(role_name)
return ("get_jid_role(bare_from, current_host) == %q"):format(role_name), { "get_jid_role", "current_host", "bare_from" };
end
local day_numbers = { sun = 0, mon = 2, tue = 3, wed = 4, thu = 5, fri = 6, sat = 7 };
local function current_time_check(op, hour, minute)
hour, minute = tonumber(hour), tonumber(minute);
local adj_op = op == "<" and "<" or ">="; -- Start time inclusive, end time exclusive
if minute == 0 then
return "(current_hour"..adj_op..hour..")";
else
return "((current_hour"..op..hour..") or (current_hour == "..hour.." and current_minute"..adj_op..minute.."))";
end
end
local function resolve_day_number(day_name)
return assert(day_numbers[day_name:sub(1,3):lower()], "Unknown day name: "..day_name);
end
function condition_handlers.DAY(days)
local conditions = {};
for day_range in days:gmatch("[^,]+") do
local day_start, day_end = day_range:match("(%a+)%s*%-%s*(%a+)");
if day_start and day_end then
local day_start_num, day_end_num = resolve_day_number(day_start), resolve_day_number(day_end);
local op = "and";
if day_end_num < day_start_num then
op = "or";
end
table.insert(conditions, ("current_day >= %d %s current_day <= %d"):format(day_start_num, op, day_end_num));
elseif day_range:find("%a") then
local day = resolve_day_number(day_range:match("%a+"));
table.insert(conditions, "current_day == "..day);
else
error("Unable to parse day/day range: "..day_range);
end
end
assert(#conditions>0, "Expected a list of days or day ranges");
return "("..table.concat(conditions, ") or (")..")", { "time:day" };
end
function condition_handlers.TIME(ranges)
local conditions = {};
for range in ranges:gmatch("([^,]+)") do
local clause = {};
range = range:lower()
:gsub("(%d+):?(%d*) *am", function (h, m) return tostring(tonumber(h)%12)..":"..(tonumber(m) or "00"); end)
:gsub("(%d+):?(%d*) *pm", function (h, m) return tostring(tonumber(h)%12+12)..":"..(tonumber(m) or "00"); end);
local start_hour, start_minute = range:match("(%d+):(%d+) *%-");
local end_hour, end_minute = range:match("%- *(%d+):(%d+)");
local op = tonumber(start_hour) > tonumber(end_hour) and " or " or " and ";
if start_hour and end_hour then
table.insert(clause, current_time_check(">", start_hour, start_minute));
table.insert(clause, current_time_check("<", end_hour, end_minute));
end
if #clause == 0 then
error("Unable to parse time range: "..range);
end
table.insert(conditions, "("..table.concat(clause, " "..op.." ")..")");
end
return table.concat(conditions, " or "), { "time:hour,min" };
end
function condition_handlers.LIMIT(spec)
local name, param = spec:match("^(%w+) on (.+)$");
local meta_deps = {};
if not name then
name = spec:match("^%w+$");
if not name then
error("Unable to parse LIMIT specification");
end
else
param = meta(("%q"):format(param), meta_deps);
end
if not param then
return ("not global_throttle_%s:poll(1)"):format(name), { "globalthrottle:"..name, unpack(meta_deps) };
end
return ("not multi_throttle_%s:poll_on(%s, 1)"):format(name, param), { "multithrottle:"..name, unpack(meta_deps) };
end
function condition_handlers.ORIGIN_MARKED(name_and_time)
local name, time = name_and_time:match("^%s*([%w_]+)%s+%(([^)]+)s%)%s*$");
if not name then
name = name_and_time:match("^%s*([%w_]+)%s*$");
end
if not name then
error("Error parsing mark name, see documentation for usage examples");
end
if time then
return ("(current_timestamp - (session.firewall_marked_%s or 0)) < %d"):format(idsafe(name), tonumber(time)), { "timestamp" };
end
return ("not not session.firewall_marked_"..idsafe(name));
end
function condition_handlers.USER_MARKED(name_and_time)
local name, time = name_and_time:match("^%s*([%w_]+)%s+%(([^)]+)s%)%s*$");
if not name then
name = name_and_time:match("^%s*([%w_]+)%s*$");
end
if not name then
error("Error parsing mark name, see documentation for usage examples");
end
if time then
return ([[(
current_timestamp - (session.firewall_marks and session.firewall_marks.%s or 0)
) < %d]]):format(idsafe(name), tonumber(time)), { "timestamp" };
end
return ("not not (session.firewall_marks and session.firewall_marks."..idsafe(name)..")");
end
function condition_handlers.SENT_DIRECTED_PRESENCE_TO_SENDER()
return "not not (session.directed and session.directed[from])", { "from" };
end
-- TO FULL JID?
function condition_handlers.TO_FULL_JID()
return "not not full_sessions[to]", { "to", "full_sessions" };
end
-- CHECK LIST: spammers contains $<@from>
function condition_handlers.CHECK_LIST(list_condition)
local list_name, expr = list_condition:match("(%S+) contains (.+)$");
if not (list_name and expr) then
error("Error parsing list check, syntax: LISTNAME contains EXPRESSION");
end
local meta_deps = {};
expr = meta(("%q"):format(expr), meta_deps);
return ("list_%s:contains(%s) == true"):format(list_name, expr), { "list:"..list_name, unpack(meta_deps) };
end
-- SCAN: body for word in badwords
function condition_handlers.SCAN(scan_expression)
local search_name, pattern_name, list_name = scan_expression:match("(%S+) for (%S+) in (%S+)$");
if not (search_name) then
error("Error parsing SCAN expression, syntax: SEARCH for PATTERN in LIST");
end
return ("scan_list(list_%s, %s)"):format(
list_name,
"tokens_"..search_name.."_"..pattern_name
), {
"scan_list",
"tokens:"..search_name.."-"..pattern_name, "list:"..list_name
};
end
-- COUNT: lines in body < 10
local valid_comp_ops = { [">"] = ">", ["<"] = "<", ["="] = "==", ["=="] = "==", ["<="] = "<=", [">="] = ">=" };
function condition_handlers.COUNT(count_expression)
local pattern_name, search_name, comparator_expression = count_expression:match("(%S+) in (%S+) (.+)$");
if not (pattern_name) then
error("Error parsing COUNT expression, syntax: PATTERN in SEARCH COMPARATOR");
end
local value;
comparator_expression = comparator_expression:gsub("%d+", function (value_string)
value = tonumber(value_string);
return "";
end);
if not value then
error("Error parsing COUNT expression, expected value");
end
local comp_op = comparator_expression:gsub("%s+", "");
assert(valid_comp_ops[comp_op], "Error parsing COUNT expression, unknown comparison operator: "..comp_op);
return ("it_count(search_%s:gmatch(pattern_%s)) %s %d"):format(
search_name, pattern_name, comp_op, value
), {
"it_count",
"search:"..search_name, "pattern:"..pattern_name
};
end
return condition_handlers;

View File

@@ -0,0 +1,335 @@
-- Name arguments are unused here
-- luacheck: ignore 212
local definition_handlers = {};
local http = require "net.http";
local timer = require "util.timer";
local set = require"util.set";
local new_throttle = require "util.throttle".create;
local hashes = require "util.hashes";
local jid = require "util.jid";
local lfs = require "lfs";
local multirate_cache_size = module:get_option_number("firewall_multirate_cache_limit", 1000);
function definition_handlers.ZONE(zone_name, zone_members)
local zone_member_list = {};
for member in zone_members:gmatch("[^, ]+") do
zone_member_list[#zone_member_list+1] = member;
end
return set.new(zone_member_list)._items;
end
-- Helper function used by RATE handler
local function evict_only_unthrottled(name, throttle)
throttle:update();
-- Check whether the throttle is at max balance (i.e. totally safe to forget about it)
if throttle.balance < throttle.max then
-- Not safe to forget
return false;
end
end
function definition_handlers.RATE(name, line)
local rate = assert(tonumber(line:match("([%d.]+)")), "Unable to parse rate");
local burst = tonumber(line:match("%(%s*burst%s+([%d.]+)%s*%)")) or 1;
local max_throttles = tonumber(line:match("%(%s*entries%s+([%d]+)%s*%)")) or multirate_cache_size;
local deny_when_full = not line:match("%(allow overflow%)");
return {
single = function ()
return new_throttle(rate*burst, burst);
end;
multi = function ()
local cache = require "util.cache".new(max_throttles, deny_when_full and evict_only_unthrottled or nil);
return {
poll_on = function (_, key, amount)
assert(key, "no key");
local throttle = cache:get(key);
if not throttle then
throttle = new_throttle(rate*burst, burst);
if not cache:set(key, throttle) then
module:log("warn", "Multirate '%s' has hit its maximum number of active throttles (%d), denying new events", name, max_throttles);
return false;
end
end
return throttle:poll(amount);
end;
}
end;
};
end
local list_backends = {
-- %LIST name: memory (limit: number)
memory = {
init = function (self, type, opts)
if opts.limit then
local have_cache_lib, cache_lib = pcall(require, "util.cache");
if not have_cache_lib then
error("In-memory lists with a size limit require Prosody 0.10");
end
self.cache = cache_lib.new((assert(tonumber(opts.limit), "Invalid list limit")));
if not self.cache.table then
error("In-memory lists with a size limit require a newer version of Prosody 0.10");
end
self.items = self.cache:table();
else
self.items = {};
end
end;
add = function (self, item)
self.items[item] = true;
end;
remove = function (self, item)
self.items[item] = nil;
end;
contains = function (self, item)
return self.items[item] == true;
end;
};
-- %LIST name: http://example.com/ (ttl: number, pattern: pat, hash: sha1)
http = {
init = function (self, url, opts)
local poll_interval = assert(tonumber(opts.ttl or "3600"), "invalid ttl for <"..url.."> (expected number of seconds)");
local pattern = opts.pattern or "([^\r\n]+)\r?\n";
assert(pcall(string.match, "", pattern), "invalid pattern for <"..url..">");
if opts.hash then
assert(opts.hash:match("^%w+$") and type(hashes[opts.hash]) == "function", "invalid hash function: "..opts.hash);
self.hash_function = hashes[opts.hash];
end
local etag;
local failure_count = 0;
local retry_intervals = { 60, 120, 300 };
-- By default only check the certificate if net.http supports SNI
local sni_supported = http.feature and http.features.sni;
local insecure = false;
if opts.checkcert == "never" then
insecure = true;
elseif (opts.checkcert == nil or opts.checkcert == "when-sni") and not sni_supported then
insecure = false;
end
local function update_list()
http.request(url, {
insecure = insecure;
headers = {
["If-None-Match"] = etag;
};
}, function (body, code, response)
local next_poll = poll_interval;
if code == 200 and body then
etag = response.headers.etag;
local items = {};
for entry in body:gmatch(pattern) do
items[entry] = true;
end
self.items = items;
module:log("debug", "Fetched updated list from <%s>", url);
elseif code == 304 then
module:log("debug", "List at <%s> is unchanged", url);
elseif code == 0 or (code >= 400 and code <=599) then
module:log("warn", "Failed to fetch list from <%s>: %d %s", url, code, tostring(body));
failure_count = failure_count + 1;
next_poll = retry_intervals[failure_count] or retry_intervals[#retry_intervals];
end
if next_poll > 0 then
timer.add_task(next_poll+math.random(0, 60), update_list);
end
end);
end
update_list();
end;
add = function ()
end;
remove = function ()
end;
contains = function (self, item)
if self.hash_function then
item = self.hash_function(item);
end
return self.items and self.items[item] == true;
end;
};
-- %LIST: file:/path/to/file
file = {
init = function (self, file_spec, opts)
local n, items = 0, {};
self.items = items;
local filename = file_spec:gsub("^file:", "");
if opts.missing == "ignore" and not lfs.attributes(filename, "mode") then
module:log("debug", "Ignoring missing list file: %s", filename);
return;
end
local file, err = io.open(filename);
if not file then
module:log("warn", "Failed to open list from %s: %s", filename, err);
return;
else
for line in file:lines() do
if not items[line] then
n = n + 1;
items[line] = true;
end
end
end
module:log("debug", "Loaded %d items from %s", n, filename);
end;
add = function (self, item)
self.items[item] = true;
end;
remove = function (self, item)
self.items[item] = nil;
end;
contains = function (self, item)
return self.items and self.items[item] == true;
end;
};
-- %LIST: pubsub:pubsub.example.com/node
-- TODO or the actual URI scheme? Bit overkill maybe?
-- TODO Publish items back to the service?
-- Step 1: Receiving pubsub events and storing them in the list
-- We'll start by using only the item id.
-- TODO Invent some custom schema for this? Needed for just a set of strings?
pubsubitemid = {
init = function(self, pubsub_spec, opts)
local service_addr, node = pubsub_spec:match("^pubsubitemid:([^/]*)/(.*)");
if not service_addr then
module:log("warn", "Invalid list specification (expected 'pubsubitemid:<service>/<node>', got: '%s')", pubsub_spec);
return;
end
module:depends("pubsub_subscription");
module:add_item("pubsub-subscription", {
service = service_addr;
node = node;
on_subscribed = function ()
self.items = {};
end;
on_item = function (event)
self:add(event.item.attr.id);
end;
on_retract = function (event)
self:remove(event.item.attr.id);
end;
on_purge = function ()
self.items = {};
end;
on_unsubscribed = function ()
self.items = nil;
end;
on_delete= function ()
self.items = nil;
end;
});
-- TODO Initial fetch? Or should mod_pubsub_subscription do this?
end;
add = function (self, item)
if self.items then
self.items[item] = true;
end
end;
remove = function (self, item)
if self.items then
self.items[item] = nil;
end
end;
contains = function (self, item)
return self.items and self.items[item] == true;
end;
};
};
list_backends.https = list_backends.http;
local normalize_functions = {
upper = string.upper, lower = string.lower;
md5 = hashes.md5, sha1 = hashes.sha1, sha256 = hashes.sha256;
prep = jid.prep, bare = jid.bare;
};
local function wrap_list_method(list_method, filter)
return function (self, item)
return list_method(self, filter(item));
end
end
local function create_list(list_backend, list_def, opts)
if not list_backends[list_backend] then
error("Unknown list type '"..list_backend.."'", 0);
end
local list = setmetatable({}, { __index = list_backends[list_backend] });
if list.init then
list:init(list_def, opts);
end
if opts.filter then
local filters = {};
for func_name in opts.filter:gmatch("[%w_]+") do
if func_name == "log" then
table.insert(filters, function (s)
--print("&&&&&", s);
module:log("debug", "Checking list <%s> for: %s", list_def, s);
return s;
end);
else
assert(normalize_functions[func_name], "Unknown list filter: "..func_name);
table.insert(filters, normalize_functions[func_name]);
end
end
local filter;
local n = #filters;
if n == 1 then
filter = filters[1];
else
function filter(s)
for i = 1, n do
s = filters[i](s or "");
end
return s;
end
end
list.add = wrap_list_method(list.add, filter);
list.remove = wrap_list_method(list.remove, filter);
list.contains = wrap_list_method(list.contains, filter);
end
return list;
end
--[[
%LIST spammers: memory (source: /etc/spammers.txt)
%LIST spammers: memory (source: /etc/spammers.txt)
%LIST spammers: http://example.com/blacklist.txt
]]
function definition_handlers.LIST(list_name, list_definition)
local list_backend = list_definition:match("^%w+");
local opts = {};
local opt_string = list_definition:match("^%S+%s+%((.+)%)");
if opt_string then
for opt_k, opt_v in opt_string:gmatch("(%w+): ?([^,]+)") do
opts[opt_k] = opt_v;
end
end
return create_list(list_backend, list_definition:match("^%S+"), opts);
end
function definition_handlers.PATTERN(name, pattern)
local ok, err = pcall(string.match, "", pattern);
if not ok then
error("Invalid pattern '"..name.."': "..err);
end
return pattern;
end
function definition_handlers.SEARCH(name, pattern)
return pattern;
end
return definition_handlers;

View File

@@ -0,0 +1,35 @@
local mark_storage = module:open_store("firewall_marks");
local mark_map_storage = module:open_store("firewall_marks", "map");
local user_sessions = prosody.hosts[module.host].sessions;
module:hook("firewall/marked/user", function (event)
local user = user_sessions[event.username];
local marks = user and user.firewall_marks;
if user and not marks then
-- Load marks from storage to cache on the user object
marks = mark_storage:get(event.username) or {};
user.firewall_marks = marks; --luacheck: ignore 122
end
if marks then
marks[event.mark] = event.timestamp;
end
local ok, err = mark_map_storage:set(event.username, event.mark, event.timestamp);
if not ok then
module:log("error", "Failed to mark user %q with %q: %s", event.username, event.mark, err);
end
return true;
end, -1);
module:hook("firewall/unmarked/user", function (event)
local user = user_sessions[event.username];
local marks = user and user.firewall_marks;
if marks then
marks[event.mark] = nil;
end
local ok, err = mark_map_storage:set(event.username, event.mark, nil);
if not ok then
module:log("error", "Failed to unmark user %q with %q: %s", event.username, event.mark, err);
end
return true;
end, -1);

View File

@@ -0,0 +1,784 @@
local lfs = require "lfs";
local resolve_relative_path = require "core.configmanager".resolve_relative_path;
local envload = require "util.envload".envload;
local logger = require "util.logger".init;
local it = require "util.iterators";
local set = require "util.set";
local have_features, features = pcall(require, "core.features");
features = have_features and features.available or set.new();
-- [definition_type] = definition_factory(param)
local definitions = module:shared("definitions");
-- When a definition instance has been instantiated, it lives here
-- [definition_type][definition_name] = definition_object
local active_definitions = {
ZONE = {
-- Default zone that includes all local hosts
["$local"] = setmetatable({}, { __index = prosody.hosts });
};
};
local default_chains = {
preroute = {
type = "event";
priority = 0.1;
"pre-message/bare", "pre-message/full", "pre-message/host";
"pre-presence/bare", "pre-presence/full", "pre-presence/host";
"pre-iq/bare", "pre-iq/full", "pre-iq/host";
};
deliver = {
type = "event";
priority = 0.1;
"message/bare", "message/full", "message/host";
"presence/bare", "presence/full", "presence/host";
"iq/bare", "iq/full", "iq/host";
};
deliver_remote = {
type = "event"; "route/remote";
priority = 0.1;
};
};
local extra_chains = module:get_option("firewall_extra_chains", {});
local chains = {};
for k,v in pairs(default_chains) do
chains[k] = v;
end
for k,v in pairs(extra_chains) do
chains[k] = v;
end
-- Returns the input if it is safe to be used as a variable name, otherwise nil
function idsafe(name)
return name:match("^%a[%w_]*$");
end
local meta_funcs = {
bare = function (code)
return "jid_bare("..code..")", {"jid_bare"};
end;
node = function (code)
return "(jid_split("..code.."))", {"jid_split"};
end;
host = function (code)
return "(select(2, jid_split("..code..")))", {"jid_split"};
end;
resource = function (code)
return "(select(3, jid_split("..code..")))", {"jid_split"};
end;
};
-- Run quoted (%q) strings through this to allow them to contain code. e.g.: LOG=Received: $(stanza:top_tag())
function meta(s, deps, extra)
return (s:gsub("$(%b())", function (expr)
expr = expr:gsub("\\(.)", "%1");
return [["..tostring(]]..expr..[[).."]];
end)
:gsub("$(%b<>)", function (expr)
expr = expr:sub(2,-2);
local default = "<undefined>";
expr = expr:gsub("||(%b\"\")$", function (default_string)
default = stripslashes(default_string:sub(2,-2));
return "";
end);
local func_chain = expr:match("|[%w|]+$");
if func_chain then
expr = expr:sub(1, -1-#func_chain);
end
local code;
if expr:match("^@") then
-- Skip stanza:find() for simple attribute lookup
local attr_name = expr:sub(2);
if deps and (attr_name == "to" or attr_name == "from" or attr_name == "type") then
-- These attributes may be cached in locals
code = attr_name;
table.insert(deps, attr_name);
else
code = "stanza.attr["..("%q"):format(attr_name).."]";
end
elseif expr:match("^%w+#$") then
code = ("stanza:get_child_text(%q)"):format(expr:sub(1, -2));
else
code = ("stanza:find(%q)"):format(expr);
end
if func_chain then
for func_name in func_chain:gmatch("|(%w+)") do
-- to/from are already available in local variables, use those if possible
if (code == "to" or code == "from") and func_name == "bare" then
code = "bare_"..code;
table.insert(deps, code);
elseif (code == "to" or code == "from") and (func_name == "node" or func_name == "host" or func_name == "resource") then
table.insert(deps, "split_"..code);
code = code.."_"..func_name;
else
assert(meta_funcs[func_name], "unknown function: "..func_name);
local new_code, new_deps = meta_funcs[func_name](code);
code = new_code;
if new_deps and #new_deps > 0 then
assert(deps, "function not supported here: "..func_name);
for _, dep in ipairs(new_deps) do
table.insert(deps, dep);
end
end
end
end
end
return "\"..tostring("..code.." or "..("%q"):format(default)..")..\"";
end)
:gsub("$$(%a+)", extra or {})
:gsub([[^""%.%.]], "")
:gsub([[%.%.""$]], ""));
end
function metaq(s, ...)
return meta(("%q"):format(s), ...);
end
local escape_chars = {
a = "\a", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t",
v = "\v", ["\\"] = "\\", ["\""] = "\"", ["\'"] = "\'"
};
function stripslashes(s)
return (s:gsub("\\(.)", escape_chars));
end
-- Dependency locations:
-- <type lib>
-- <type global>
-- function handler()
-- <local deps>
-- if <conditions> then
-- <actions>
-- end
-- end
local available_deps = {
st = { global_code = [[local st = require "util.stanza";]]};
it = { global_code = [[local it = require "util.iterators";]]};
it_count = { global_code = [[local it_count = it.count;]], depends = { "it" } };
current_host = { global_code = [[local current_host = module.host;]] };
jid_split = {
global_code = [[local jid_split = require "util.jid".split;]];
};
jid_bare = {
global_code = [[local jid_bare = require "util.jid".bare;]];
};
to = { local_code = [[local to = stanza.attr.to or jid_bare(session.full_jid);]]; depends = { "jid_bare" } };
from = { local_code = [[local from = stanza.attr.from;]] };
type = { local_code = [[local type = stanza.attr.type;]] };
name = { local_code = [[local name = stanza.name;]] };
split_to = { -- The stanza's split to address
depends = { "jid_split", "to" };
local_code = [[local to_node, to_host, to_resource = jid_split(to);]];
};
split_from = { -- The stanza's split from address
depends = { "jid_split", "from" };
local_code = [[local from_node, from_host, from_resource = jid_split(from);]];
};
bare_to = { depends = { "jid_bare", "to" }, local_code = "local bare_to = jid_bare(to)"};
bare_from = { depends = { "jid_bare", "from" }, local_code = "local bare_from = jid_bare(from)"};
group_contains = {
global_code = [[local group_contains = module:depends("groups").group_contains]];
};
is_admin = require"core.usermanager".is_admin and { global_code = [[local is_admin = require "core.usermanager".is_admin;]]} or nil;
get_jid_role = require "core.usermanager".get_jid_role and { global_code = [[local get_jid_role = require "core.usermanager".get_jid_role;]] } or nil;
core_post_stanza = { global_code = [[local core_post_stanza = prosody.core_post_stanza;]] };
zone = { global_code = function (zone)
local var = zone;
if var == "$local" then
var = "_local"; -- See #1090
else
assert(idsafe(var), "Invalid zone name: "..zone);
end
return ("local zone_%s = zones[%q] or {};"):format(var, zone);
end };
date_time = { global_code = [[local os_date = os.date]]; local_code = [[local current_date_time = os_date("*t");]] };
time = { local_code = function (what)
local defs = {};
for field in what:gmatch("%a+") do
table.insert(defs, ("local current_%s = current_date_time.%s;"):format(field, field));
end
return table.concat(defs, " ");
end, depends = { "date_time" }; };
timestamp = { global_code = [[local get_time = require "socket".gettime;]]; local_code = [[local current_timestamp = get_time();]]; };
globalthrottle = {
global_code = function (throttle)
assert(idsafe(throttle), "Invalid rate limit name: "..throttle);
assert(active_definitions.RATE[throttle], "Unknown rate limit: "..throttle);
return ("local global_throttle_%s = rates.%s:single();"):format(throttle, throttle);
end;
};
multithrottle = {
global_code = function (throttle)
assert(pcall(require, "util.cache"), "Using LIMIT with 'on' requires Prosody 0.10 or higher");
assert(idsafe(throttle), "Invalid rate limit name: "..throttle);
assert(active_definitions.RATE[throttle], "Unknown rate limit: "..throttle);
return ("local multi_throttle_%s = rates.%s:multi();"):format(throttle, throttle);
end;
};
full_sessions = {
global_code = [[local full_sessions = prosody.full_sessions;]];
};
rostermanager = {
global_code = [[local rostermanager = require "core.rostermanager";]];
};
roster_entry = {
local_code = [[local roster_entry = (to_node and rostermanager.load_roster(to_node, to_host) or {})[bare_from];]];
depends = { "rostermanager", "split_to", "bare_from" };
};
list = { global_code = function (list)
assert(idsafe(list), "Invalid list name: "..list);
assert(active_definitions.LIST[list], "Unknown list: "..list);
return ("local list_%s = lists[%q];"):format(list, list);
end
};
search = {
local_code = function (search_name)
local search_path = assert(active_definitions.SEARCH[search_name], "Undefined search path: "..search_name);
return ("local search_%s = tostring(stanza:find(%q) or \"\")"):format(search_name, search_path);
end;
};
pattern = {
local_code = function (pattern_name)
local pattern = assert(active_definitions.PATTERN[pattern_name], "Undefined pattern: "..pattern_name);
return ("local pattern_%s = %q"):format(pattern_name, pattern);
end;
};
tokens = {
local_code = function (search_and_pattern)
local search_name, pattern_name = search_and_pattern:match("^([^%-]+)-(.+)$");
local code = ([[local tokens_%s_%s = {};
if search_%s then
for s in search_%s:gmatch(pattern_%s) do
tokens_%s_%s[s] = true;
end
end
]]):format(search_name, pattern_name, search_name, search_name, pattern_name, search_name, pattern_name);
return code, { "search:"..search_name, "pattern:"..pattern_name };
end;
};
scan_list = {
global_code = [[local function scan_list(list, items) for item in pairs(items) do if list:contains(item) then return true; end end end]];
}
};
local function include_dep(dependency, code)
local dep, dep_param = dependency:match("^([^:]+):?(.*)$");
local dep_info = available_deps[dep];
if not dep_info then
module:log("error", "Dependency not found: %s", dep);
return;
end
if code.included_deps[dependency] ~= nil then
if code.included_deps[dependency] ~= true then
module:log("error", "Circular dependency on %s", dep);
end
return;
end
code.included_deps[dependency] = false; -- Pending flag (used to detect circular references)
for _, dep_dep in ipairs(dep_info.depends or {}) do
include_dep(dep_dep, code);
end
if dep_info.global_code then
if dep_param ~= "" then
local global_code, deps = dep_info.global_code(dep_param);
if deps then
for _, dep_dep in ipairs(deps) do
include_dep(dep_dep, code);
end
end
table.insert(code.global_header, global_code);
else
table.insert(code.global_header, dep_info.global_code);
end
end
if dep_info.local_code then
if dep_param ~= "" then
local local_code, deps = dep_info.local_code(dep_param);
if deps then
for _, dep_dep in ipairs(deps) do
include_dep(dep_dep, code);
end
end
table.insert(code, "\n\t\t-- "..dep.."\n\t\t"..local_code.."\n");
else
table.insert(code, "\n\t\t-- "..dep.."\n\t\t"..dep_info.local_code.."\n");
end
end
code.included_deps[dependency] = true;
end
local definition_handlers = module:require("definitions");
local condition_handlers = module:require("conditions");
local action_handlers = module:require("actions");
if module:get_option_boolean("firewall_experimental_user_marks", true) then
module:require"marks";
end
local function new_rule(ruleset, chain)
assert(chain, "no chain specified");
local rule = { conditions = {}, actions = {}, deps = {} };
table.insert(ruleset[chain], rule);
return rule;
end
local function parse_firewall_rules(filename)
local line_no = 0;
local function errmsg(err)
return "Error compiling "..filename.." on line "..line_no..": "..err;
end
local ruleset = {
deliver = {};
};
local chain = "deliver"; -- Default chain
local rule;
local file, err = io.open(filename);
if not file then return nil, err; end
local state; -- nil -> "rules" -> "actions" -> nil -> ...
local line_hold;
for line in file:lines() do
line = line:match("^%s*(.-)%s*$");
if line_hold and line:sub(-1,-1) ~= "\\" then
line = line_hold..line;
line_hold = nil;
elseif line:sub(-1,-1) == "\\" then
line_hold = (line_hold or "")..line:sub(1,-2);
end
line_no = line_no + 1;
if line_hold or line:find("^[#;]") then -- luacheck: ignore 542
-- No action; comment or partial line
elseif line == "" then
if state == "rules" then
return nil, ("Expected an action on line %d for preceding criteria")
:format(line_no);
end
state = nil;
elseif not(state) and line:sub(1, 2) == "::" then
chain = line:gsub("^::%s*", "");
local chain_info = chains[chain];
if not chain_info then
if chain:match("^user/") then
chains[chain] = { type = "event", priority = 1, pass_return = false };
else
return nil, errmsg("Unknown chain: "..chain);
end
elseif chain_info.type ~= "event" then
return nil, errmsg("Only event chains supported at the moment");
end
ruleset[chain] = ruleset[chain] or {};
elseif not(state) and line:sub(1,1) == "%" then -- Definition (zone, limit, etc.)
local what, name = line:match("^%%%s*([%w_]+) +([^ :]+)");
if not definition_handlers[what] then
return nil, errmsg("Definition of unknown object: "..what);
elseif not name or not idsafe(name) then
return nil, errmsg("Invalid "..what.." name");
end
local val = line:match(": ?(.*)$");
if not val and line:find(":<") then -- Read from file
local fn = line:match(":< ?(.-)%s*$");
if not fn then
return nil, errmsg("Unable to parse filename");
end
local f, err = io.open(fn);
if not f then return nil, errmsg(err); end
val = f:read("*a"):gsub("\r?\n", " "):gsub("%s+$", "");
end
if not val then
return nil, errmsg("No value given for definition");
end
val = stripslashes(val);
local ok, ret = pcall(definition_handlers[what], name, val);
if not ok then
return nil, errmsg(ret);
end
if not active_definitions[what] then
active_definitions[what] = {};
end
active_definitions[what][name] = ret;
elseif line:find("^[%w_ ]+[%.=]") then
-- Action
if state == nil then
-- This is a standalone action with no conditions
rule = new_rule(ruleset, chain);
end
state = "actions";
-- Action handlers?
local action = line:match("^[%w_ ]+"):upper():gsub(" ", "_");
if not action_handlers[action] then
return nil, ("Unknown action on line %d: %s"):format(line_no, action or "<unknown>");
end
table.insert(rule.actions, "-- "..line)
local ok, action_string, action_deps = pcall(action_handlers[action], line:match("=(.+)$"));
if not ok then
return nil, errmsg(action_string);
end
table.insert(rule.actions, action_string);
for _, dep in ipairs(action_deps or {}) do
table.insert(rule.deps, dep);
end
elseif state == "actions" then -- state is actions but action pattern did not match
state = nil; -- Awaiting next rule, etc.
table.insert(ruleset[chain], rule);
rule = nil;
else
if not state then
state = "rules";
rule = new_rule(ruleset, chain);
end
-- Check standard modifiers for the condition (e.g. NOT)
local negated;
local condition = line:match("^[^:=%.?]*");
if condition:find("%f[%w]NOT%f[^%w]") then
local s, e = condition:match("%f[%w]()NOT()%f[^%w]");
condition = (condition:sub(1,s-1)..condition:sub(e+1, -1)):match("^%s*(.-)%s*$");
negated = true;
end
condition = condition:gsub(" ", "_");
if not condition_handlers[condition] then
return nil, ("Unknown condition on line %d: %s"):format(line_no, (condition:gsub("_", " ")));
end
-- Get the code for this condition
local ok, condition_code, condition_deps = pcall(condition_handlers[condition], line:match(":%s?(.+)$"));
if not ok then
return nil, errmsg(condition_code);
end
if negated then condition_code = "not("..condition_code..")"; end
table.insert(rule.conditions, condition_code);
for _, dep in ipairs(condition_deps or {}) do
table.insert(rule.deps, dep);
end
end
end
return ruleset;
end
local function process_firewall_rules(ruleset)
-- Compile ruleset and return complete code
local chain_handlers = {};
-- Loop through the chains in the parsed ruleset (e.g. incoming, outgoing)
for chain_name, rules in pairs(ruleset) do
local code = { included_deps = {}, global_header = {} };
local condition_uses = {};
-- This inner loop assumes chain is an event-based, not a filter-based
-- chain (filter-based will be added later)
for _, rule in ipairs(rules) do
for _, condition in ipairs(rule.conditions) do
if condition:find("^not%(.+%)$") then
condition = condition:match("^not%((.+)%)$");
end
condition_uses[condition] = (condition_uses[condition] or 0) + 1;
end
end
local condition_cache, n_conditions = {}, 0;
for _, rule in ipairs(rules) do
for _, dep in ipairs(rule.deps) do
include_dep(dep, code);
end
table.insert(code, "\n\t\t");
local rule_code;
if #rule.conditions > 0 then
for i, condition in ipairs(rule.conditions) do
local negated = condition:match("^not%(.+%)$");
if negated then
condition = condition:match("^not%((.+)%)$");
end
if condition_uses[condition] > 1 then
local name = condition_cache[condition];
if not name then
n_conditions = n_conditions + 1;
name = "condition"..n_conditions;
condition_cache[condition] = name;
table.insert(code, "local "..name.." = "..condition..";\n\t\t");
end
rule.conditions[i] = (negated and "not(" or "")..name..(negated and ")" or "");
else
rule.conditions[i] = (negated and "not(" or "(")..condition..")";
end
end
rule_code = "if "..table.concat(rule.conditions, " and ").." then\n\t\t\t"
..table.concat(rule.actions, "\n\t\t\t")
.."\n\t\tend\n";
else
rule_code = table.concat(rule.actions, "\n\t\t");
end
table.insert(code, rule_code);
end
for name in pairs(definition_handlers) do
table.insert(code.global_header, 1, "local "..name:lower().."s = definitions."..name..";");
end
local code_string = "return function (definitions, fire_event, log, module, pass_return)\n\t"
..table.concat(code.global_header, "\n\t")
.."\n\tlocal db = require 'util.debug';\n\n\t"
.."return function (event)\n\t\t"
.."local stanza, session = event.stanza, event.origin;\n"
..table.concat(code, "")
.."\n\tend;\nend";
chain_handlers[chain_name] = code_string;
end
return chain_handlers;
end
local function compile_firewall_rules(filename)
local ruleset, err = parse_firewall_rules(filename);
if not ruleset then return nil, err; end
local chain_handlers = process_firewall_rules(ruleset);
return chain_handlers;
end
-- Compile handler code into a factory that produces a valid event handler. Factory accepts
-- a value to be returned on PASS
local function compile_handler(code_string, filename)
-- Prepare event handler function
local chunk, err = envload(code_string, "="..filename, _G);
if not chunk then
return nil, "Error compiling (probably a compiler bug, please report): "..err;
end
local function fire_event(name, data)
return module:fire_event(name, data);
end
local init_ok, initialized_chunk = pcall(chunk);
if not init_ok then
return nil, "Error initializing compiled rules: "..initialized_chunk;
end
return function (pass_return)
return initialized_chunk(active_definitions, fire_event, logger(filename), module, pass_return); -- Returns event handler with upvalues
end
end
local function resolve_script_path(script_path)
local relative_to = prosody.paths.config;
if script_path:match("^module:") then
relative_to = module.path:sub(1, -#("/mod_"..module.name..".lua"));
script_path = script_path:match("^module:(.+)$");
end
return resolve_relative_path(relative_to, script_path);
end
-- [filename] = { last_modified = ..., events_hooked = { [name] = handler } }
local loaded_scripts = {};
function load_script(script)
script = resolve_script_path(script);
local last_modified = (lfs.attributes(script) or {}).modification or os.time();
if loaded_scripts[script] then
if loaded_scripts[script].last_modified == last_modified then
return; -- Already loaded, and source file hasn't changed
end
module:log("debug", "Reloading %s", script);
-- Already loaded, but the source file has changed
-- unload it now, and we'll load the new version below
unload_script(script, true);
end
local chain_functions, err = compile_firewall_rules(script);
if not chain_functions then
module:log("error", "Error compiling %s: %s", script, err or "unknown error");
return;
end
-- Loop through the chains in the script, and for each chain attach the compiled code to the
-- relevant events, keeping track in events_hooked so we can cleanly unload later
local events_hooked = {};
for chain, handler_code in pairs(chain_functions) do
local new_handler, err = compile_handler(handler_code, "mod_firewall::"..chain);
if not new_handler then
module:log("error", "Compilation error for %s: %s", script, err);
else
local chain_definition = chains[chain];
if chain_definition and chain_definition.type == "event" then
local handler = new_handler(chain_definition.pass_return);
for _, event_name in ipairs(chain_definition) do
events_hooked[event_name] = handler;
module:hook(event_name, handler, chain_definition.priority);
end
elseif not chain:sub(1, 5) == "user/" then
module:log("warn", "Unknown chain %q", chain);
end
local event_name, handler = "firewall/chains/"..chain, new_handler(false);
events_hooked[event_name] = handler;
module:hook(event_name, handler);
end
end
loaded_scripts[script] = { last_modified = last_modified, events_hooked = events_hooked };
module:log("debug", "Loaded %s", script);
end
--COMPAT w/0.9 (no module:unhook()!)
local function module_unhook(event, handler)
return module:unhook_object_event((hosts[module.host] or prosody).events, event, handler);
end
function unload_script(script, is_reload)
script = resolve_script_path(script);
local script_info = loaded_scripts[script];
if not script_info then
return; -- Script not loaded
end
local events_hooked = script_info.events_hooked;
for event_name, event_handler in pairs(events_hooked) do
module_unhook(event_name, event_handler);
events_hooked[event_name] = nil;
end
loaded_scripts[script] = nil;
if not is_reload then
module:log("debug", "Unloaded %s", script);
end
end
-- Given a set of scripts (e.g. from config) figure out which ones need to
-- be loaded, which are already loaded but need unloading, and which to reload
function load_unload_scripts(script_list)
local wanted_scripts = script_list / resolve_script_path;
local currently_loaded = set.new(it.to_array(it.keys(loaded_scripts)));
local scripts_to_unload = currently_loaded - wanted_scripts;
for script in wanted_scripts do
-- If the script is already loaded, this is fine - it will
-- reload the script for us if the file has changed
load_script(script);
end
for script in scripts_to_unload do
unload_script(script);
end
end
function module.load()
if not prosody.arg then return end -- Don't run in prosodyctl
local firewall_scripts = module:get_option_set("firewall_scripts", {});
load_unload_scripts(firewall_scripts);
-- Replace contents of definitions table (shared) with active definitions
for k in it.keys(definitions) do definitions[k] = nil; end
for k,v in pairs(active_definitions) do definitions[k] = v; end
end
function module.save()
return { active_definitions = active_definitions, loaded_scripts = loaded_scripts };
end
function module.restore(state)
active_definitions = state.active_definitions;
loaded_scripts = state.loaded_scripts;
end
module:hook_global("config-reloaded", function ()
load_unload_scripts(module:get_option_set("firewall_scripts", {}));
end);
function module.command(arg)
if not arg[1] or arg[1] == "--help" then
require"util.prosodyctl".show_usage([[mod_firewall <firewall.pfw>]], [[Compile files with firewall rules to Lua code]]);
return 1;
end
local verbose = arg[1] == "-v";
if verbose then table.remove(arg, 1); end
if arg[1] == "test" then
table.remove(arg, 1);
return module:require("test")(arg);
end
local serialize = require "util.serialization".serialize;
if verbose then
print("local logger = require \"util.logger\".init;");
print();
print("local function fire_event(name, data)\n\tmodule:fire_event(name, data)\nend");
print();
end
for _, filename in ipairs(arg) do
filename = resolve_script_path(filename);
print("do -- File "..filename);
local chain_functions = assert(compile_firewall_rules(filename));
if verbose then
print();
print("local active_definitions = "..serialize(active_definitions)..";");
print();
end
local c = 0;
for chain, handler_code in pairs(chain_functions) do
c = c + 1;
print("---- Chain "..chain:gsub("_", " "));
local chain_func_name = "chain_"..tostring(c).."_"..chain:gsub("%p", "_");
if not verbose then
print(("%s = %s;"):format(chain_func_name, handler_code:sub(8)));
else
print(("local %s = (%s)(active_definitions, fire_event, logger(%q));"):format(chain_func_name, handler_code:sub(8), filename));
print();
local chain_definition = chains[chain];
if chain_definition and chain_definition.type == "event" then
for _, event_name in ipairs(chain_definition) do
print(("module:hook(%q, %s, %d);"):format(event_name, chain_func_name, chain_definition.priority or 0));
end
end
print(("module:hook(%q, %s, %d);"):format("firewall/chains/"..chain, chain_func_name, chain_definition.priority or 0));
end
print("---- End of chain "..chain);
print();
end
print("end -- End of file "..filename);
end
end
-- Console
local console_env = module:shared("/*/admin_shell/env");
console_env.firewall = {};
function console_env.firewall:mark(user_jid, mark_name)
local username, host = jid.split(user_jid);
if not username or not hosts[host] then
return nil, "Invalid JID supplied";
elseif not idsafe(mark_name) then
return nil, "Invalid characters in mark name";
end
if not module:context(host):fire_event("firewall/marked/user", {
username = session.username;
mark = mark_name;
timestamp = os.time();
}) then
return nil, "Mark not set - is mod_firewall loaded on that host?";
end
return true, "User marked";
end
function console_env.firewall:unmark(jid, mark_name)
local username, host = jid.split(user_jid);
if not username or not hosts[host] then
return nil, "Invalid JID supplied";
elseif not idsafe(mark_name) then
return nil, "Invalid characters in mark name";
end
if not module:context(host):fire_event("firewall/unmarked/user", {
username = session.username;
mark = mark_name;
}) then
return nil, "Mark not removed - is mod_firewall loaded on that host?";
end
return true, "User unmarked";
end

View File

@@ -0,0 +1,75 @@
-- luacheck: globals load_unload_scripts
local set = require "util.set";
local ltn12 = require "ltn12";
local xmppstream = require "util.xmppstream";
local function stderr(...)
io.stderr:write("** ", table.concat({...}, "\t", 1, select("#", ...)), "\n");
end
return function (arg)
require "net.http".request = function (url, ex, cb)
stderr("Making HTTP request to "..url);
local body_table = {};
local ok, response_status, response_headers = require "ssl.https".request({
url = url;
headers = ex.headers;
method = ex.body and "POST" or "GET";
sink = ltn12.sink.table(body_table);
source = ex.body and ltn12.source.string(ex.body) or nil;
});
stderr("HTTP response "..response_status);
cb(table.concat(body_table), response_status, { headers = response_headers });
return true;
end;
local stats_dropped, stats_passed = 0, 0;
load_unload_scripts(set.new(arg));
local stream_callbacks = { default_ns = "jabber:client" };
function stream_callbacks.streamopened(session)
session.notopen = nil;
end
function stream_callbacks.streamclosed()
end
function stream_callbacks.error(session, error_name, error_message) -- luacheck: ignore 212/session
stderr("Fatal error parsing XML stream: "..error_name..": "..tostring(error_message))
assert(false);
end
function stream_callbacks.handlestanza(session, stanza)
if not module:fire_event("firewall/chains/deliver", { origin = session, stanza = stanza }) then
stats_passed = stats_passed + 1;
print(stanza);
print("");
else
stats_dropped = stats_dropped + 1;
end
end
local session = { notopen = true };
function session.send(stanza)
stderr("Reply:", "\n"..tostring(stanza).."\n");
end
local stream = xmppstream.new(session, stream_callbacks);
stream:feed("<stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client'>");
local line_count = 0;
for line in io.lines() do
line_count = line_count + 1;
local ok, err = stream:feed(line.."\n");
if not ok then
stderr("Fatal XML parse error on line "..line_count..": "..err);
return 1;
end
end
stderr("Summary");
stderr("-------");
stderr("");
stderr(stats_dropped + stats_passed, "processed");
stderr(stats_passed, "passed");
stderr(stats_dropped, "dropped");
stderr(line_count, "input lines");
stderr("");
end

View File

@@ -0,0 +1,811 @@
--- activate under main muc component
--- Add the following config under the main muc component
--- muc_room_default_presence_broadcast = {
--- visitor = false;
--- participant = true;
--- moderator = true;
--- };
--- Enable in global modules: 's2s_bidi'
--- Make sure 's2s' is not in modules_disabled
--- NOTE: Make sure all communication between prosodies is using the real jids ([foo]room1@muc.example.com), as there
--- are certain configs for whitelisted domains and connections that are domain based
--- TODO: filter presence from main occupants back to main prosody
local jid = require 'util.jid';
local st = require 'util.stanza';
local new_id = require 'util.id'.medium;
local filters = require 'util.filters';
local array = require 'util.array';
local set = require 'util.set';
local json = require 'cjson.safe';
local util = module:require 'util';
local is_admin = util.is_admin;
local ends_with = util.ends_with;
local is_vpaas = util.is_vpaas;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local get_room_from_jid = util.get_room_from_jid;
local get_focus_occupant = util.get_focus_occupant;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local presence_check_status = util.presence_check_status;
local respond_iq_result = util.respond_iq_result;
local table_compare = util.table_compare;
local PARTICIPANT_PROP_RAISE_HAND = 'jitsi_participant_raisedHand';
local PARTICIPANT_PROP_REQUEST_TRANSCRIPTION = 'jitsi_participant_requestingTranscription';
local PARTICIPANT_PROP_TRANSLATION_LANG = 'jitsi_participant_translation_language';
local TRANSCRIPT_DEFAULT_LANG = module:get_option_string('transcriptions_default_language', 'en');
-- this is the main virtual host of this vnode
local local_domain = module:get_option_string('muc_mapper_domain_base');
if not local_domain then
module:log('warn', "No 'muc_mapper_domain_base' option set, disabling fmuc plugin");
return;
end
-- this is the main virtual host of the main prosody that this vnode serves
local main_domain = module:get_option_string('main_domain');
if not main_domain then
module:log('warn', "No 'main_domain' option set, disabling fmuc plugin");
return;
end
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
local local_muc_domain = muc_domain_prefix..'.'..local_domain;
local NICK_NS = 'http://jabber.org/protocol/nick';
-- in certain cases we consider participants with token as moderators, this is the default behavior which can be turned off
local auto_promoted_with_token = module:get_option_boolean('visitors_auto_promoted_with_token', true);
-- we send stats for the total number of rooms, total number of participants and total number of visitors
local measure_rooms = module:measure('vnode-rooms', 'amount');
local measure_participants = module:measure('vnode-participants', 'amount');
local measure_visitors = module:measure('vnode-visitors', 'amount');
local sent_iq_cache = require 'util.cache'.new(200);
local sessions = prosody.full_sessions;
local function send_transcriptions_update(room)
-- let's notify main prosody
local lang_array = array();
local count = 0;
for k, v in pairs(room._transcription_languages) do
lang_array:push(v);
count = count + 1;
end
local iq_id = new_id();
sent_iq_cache:set(iq_id, socket.gettime());
module:send(st.iq({
type = 'set',
to = 'visitors.'..main_domain,
from = local_domain,
id = iq_id })
:tag('visitors', { xmlns = 'jitsi:visitors',
room = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain) })
:tag('transcription-languages', {
xmlns = 'jitsi:visitors',
langs = lang_array:unique():sort():concat(','),
count = tostring(count)
}):up());
end
local function remove_transcription(room, occupant)
local send_update = false;
if room._transcription_languages then
if room._transcription_languages[occupant.jid] then
send_update = true;
end
room._transcription_languages[occupant.jid] = nil;
end
if send_update then
send_transcriptions_update(room);
end
end
-- if lang is nil we will remove it from the list
local function add_transcription(room, occupant, lang)
if not room._transcription_languages then
room._transcription_languages = {};
end
local old = room._transcription_languages[occupant.jid];
room._transcription_languages[occupant.jid] = lang or TRANSCRIPT_DEFAULT_LANG;
if old ~= room._transcription_languages[occupant.jid] then
send_transcriptions_update(room);
end
end
-- mark all occupants as visitors
module:hook('muc-occupant-pre-join', function (event)
local occupant, room, origin, stanza = event.occupant, event.room, event.origin, event.stanza;
local node, host = jid.split(occupant.bare_jid);
local resource = jid.resource(occupant.nick);
if is_admin(occupant.bare_jid) then
return;
end
if prosody.hosts[host] then
-- local participants which host is defined in this prosody
if room._main_room_lobby_enabled then
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Visitors not allowed while lobby is on!')
:tag('no-visitors-lobby', { xmlns = 'jitsi:visitors' }));
return true;
else
occupant.role = 'visitor';
end
elseif room.moderators_list and room.moderators_list:contains(resource) then
-- remote participants, host is the main prosody
occupant.role = 'moderator';
end
end, 3);
-- if a visitor leaves we want to lower its hand if it was still raised before leaving
-- this is to clear indication for promotion on moderators visitors list
module:hook('muc-occupant-pre-leave', function (event)
local occupant = event.occupant;
---- we are interested only of visitors presence
if occupant.role ~= 'visitor' then
return;
end
local room = event.room;
-- let's check if the visitor has a raised hand send a lower hand
-- to main prosody
local pr = occupant:get_presence();
local raiseHand = pr:get_child_text(PARTICIPANT_PROP_RAISE_HAND);
-- a promotion detected let's send it to main prosody
if raiseHand and #raiseHand > 0 then
local iq_id = new_id();
sent_iq_cache:set(iq_id, socket.gettime());
local promotion_request = st.iq({
type = 'set',
to = 'visitors.'..main_domain,
from = local_domain,
id = iq_id })
:tag('visitors', { xmlns = 'jitsi:visitors',
room = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain) })
:tag('promotion-request', {
xmlns = 'jitsi:visitors',
jid = occupant.jid,
time = nil;
}):up();
module:send(promotion_request);
end
remove_transcription(room, occupant);
end, 1); -- rate limit is 0
-- Returns the main participants count and the visitors count
local function get_occupant_counts(room)
local main_count = 0;
local visitors_count = 0;
for _, o in room:each_occupant() do
if o.role == 'visitor' then
visitors_count = visitors_count + 1;
elseif not is_admin(o.bare_jid) then
main_count = main_count + 1;
end
end
return main_count, visitors_count;
end
local function cancel_destroy_timer(room)
if room.visitors_destroy_timer then
room.visitors_destroy_timer:stop();
room.visitors_destroy_timer = nil;
end
end
local function destroy_with_conference_ended(room)
-- if the room is being destroyed, ignore
if room.destroying then
return;
end
cancel_destroy_timer(room);
local main_count, visitors_count = get_occupant_counts(room);
module:log('info', 'Will destroy:%s main_occupants:%s visitors:%s', room.jid, main_count, visitors_count);
room:destroy(nil, 'Conference ended.');
return true;
end
-- schedules a new destroy timer which will destroy the room if there are no visitors after the timeout
local function schedule_destroy_timer(room)
cancel_destroy_timer(room);
room.visitors_destroy_timer = module:add_timer(15, function()
-- if the room is being destroyed, ignore
if room.destroying then
return;
end
local main_count, visitors_count = get_occupant_counts(room);
if visitors_count == 0 then
module:log('info', 'Will destroy:%s main_occupants:%s visitors:%s', room.jid, main_count, visitors_count);
room:destroy(nil, 'No visitors.');
end
end);
end
-- when occupant is leaving forward presences to jicofo for visitors
-- do not check occupant.role as it maybe already reset
-- if there are no main occupants or no visitors, destroy the room (give 15 seconds of grace period for reconnections)
module:hook('muc-occupant-left', function (event)
local room, occupant = event.room, event.occupant;
local occupant_domain = jid.host(occupant.bare_jid);
if prosody.hosts[occupant_domain] and not is_admin(occupant.bare_jid) then
local focus_occupant = get_focus_occupant(room);
if not focus_occupant then
if not room.destroying then
module:log('warn', 'No focus found for %s', room.jid);
end
return;
end
-- Let's forward unavailable presence to the special jicofo
room:route_stanza(st.presence({
to = focus_occupant.jid,
from = internal_room_jid_match_rewrite(occupant.nick),
type = 'unavailable' })
:tag('x', { xmlns = 'http://jabber.org/protocol/muc#user' })
:tag('item', {
affiliation = room:get_affiliation(occupant.bare_jid) or 'none';
role = 'none';
nick = event.nick;
jid = occupant.bare_jid }):up():up());
end
-- if the room is being destroyed, ignore
if room.destroying then
return;
end
-- if there are no main participants, the main room will be destroyed and
-- we can destroy and the visitor one as when jicofo leaves all visitors will reload
-- if there are no visitors give them 15 secs to reconnect, if not destroy it
local main_count, visitors_count = get_occupant_counts(room);
if visitors_count == 0 then
schedule_destroy_timer(room);
end
if main_count == 0 then
destroy_with_conference_ended(room);
end
end);
-- forward visitor presences to jicofo
-- detects raise hand in visitors presence, this is request for promotion
-- detects the requested transcription and its language to send updates for it
module:hook('muc-broadcast-presence', function (event)
local occupant = event.occupant;
---- we are interested only of visitors presence to send it to jicofo
if occupant.role ~= 'visitor' then
return;
end
local room = event.room;
local focus_occupant = get_focus_occupant(room);
if not focus_occupant then
return;
end
local actor, base_presence, nick, reason, x = event.actor, event.stanza, event.nick, event.reason, event.x;
local actor_nick;
if actor then
actor_nick = jid.resource(room:get_occupant_jid(actor));
end
-- create a presence to send it to jicofo, as jicofo is special :)
local full_x = st.clone(x.full or x);
room:build_item_list(occupant, full_x, false, nick, actor_nick, actor, reason);
local full_p = st.clone(base_presence):add_child(full_x);
full_p.attr.to = focus_occupant.jid;
room:route_to_occupant(focus_occupant, full_p);
local raiseHand = full_p:get_child_text(PARTICIPANT_PROP_RAISE_HAND);
-- a promotion detected let's send it to main prosody
if raiseHand then
local user_id;
local group_id;
local is_moderator;
local session = sessions[occupant.jid];
local identity = session and session.jitsi_meet_context_user;
if is_vpaas(room) and identity then
-- in case of moderator in vpaas meeting we want to do auto-promotion
local is_vpaas_moderator = identity.moderator;
if is_vpaas_moderator == 'true' or is_vpaas_moderator == true then
is_moderator = true;
end
else
-- The case with single moderator in the room, we want to report our id
-- so we can be auto promoted
if identity and identity.id then
user_id = session.jitsi_meet_context_user.id;
group_id = session.jitsi_meet_context_group;
if session.auth_token and auto_promoted_with_token then
if not session.jitsi_meet_tenant_mismatch or session.jitsi_web_query_prefix == '' then
-- non-vpaas and having a token is considered a moderator, and if it is not in '/' tenant
-- the tenant from url and token should match
is_moderator = true;
end
end
end
end
local iq_id = new_id();
sent_iq_cache:set(iq_id, socket.gettime());
local promotion_request = st.iq({
type = 'set',
to = 'visitors.'..main_domain,
from = local_domain,
id = iq_id })
:tag('visitors', { xmlns = 'jitsi:visitors',
room = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain) })
:tag('promotion-request', {
xmlns = 'jitsi:visitors',
jid = occupant.jid,
time = raiseHand,
userId = user_id,
groupId = group_id,
forcePromote = is_moderator and 'true' or 'false';
}):up();
local nick_element = occupant:get_presence():get_child('nick', NICK_NS);
if nick_element then
promotion_request:add_child(nick_element);
end
module:send(promotion_request);
end
local requestTranscriptionValue = full_p:get_child_text(PARTICIPANT_PROP_REQUEST_TRANSCRIPTION);
local hasTranscriptionEnabled = room._transcription_languages and room._transcription_languages[occupant.jid];
-- detect transcription
if requestTranscriptionValue == 'true' then
local lang = full_p:get_child_text(PARTICIPANT_PROP_TRANSLATION_LANG);
add_transcription(room, occupant, lang);
elseif hasTranscriptionEnabled then
remove_transcription(room, occupant, nil);
end
return;
end);
-- listens for responses to the iq sent for requesting promotion and forward it to the visitor
local function stanza_handler(event)
local origin, stanza = event.origin, event.stanza;
if stanza.name ~= 'iq' then
return;
end
if stanza.attr.type == 'result' and sent_iq_cache:get(stanza.attr.id) then
sent_iq_cache:set(stanza.attr.id, nil);
return true;
end
if stanza.attr.type ~= 'set' then
return;
end
local visitors_iq = event.stanza:get_child('visitors', 'jitsi:visitors');
if not visitors_iq then
return;
end
if stanza.attr.from ~= 'visitors.'..main_domain then
module:log('warn', 'not from visitors component, ignore! %s', stanza);
return true;
end
local room_jid = visitors_iq.attr.room;
local room = get_room_from_jid(room_jid_match_rewrite(room_jid));
if not room then
return;
end
local request_promotion = visitors_iq:get_child('promotion-response');
if not request_promotion then
return;
end
-- respond with successful receiving the iq
respond_iq_result(origin, stanza);
local req_jid = request_promotion.attr.jid;
-- now let's find the occupant and forward the response
local occupant = room:get_occupant_by_real_jid(req_jid);
if occupant then
stanza.attr.to = occupant.jid;
stanza.attr.from = room.jid;
room:route_to_occupant(occupant, stanza);
return true;
end
end
--process a host module directly if loaded or hooks to wait for its load
function process_host_module(name, callback)
local function process_host(host)
if host == name then
callback(module:context(host), host);
end
end
if prosody.hosts[name] == nil then
module:log('debug', 'No host/component found, will wait for it: %s', name)
-- when a host or component is added
prosody.events.add_handler('host-activated', process_host);
else
process_host(name);
end
end
-- if the message received ends with the main domain, these are system messages
-- for visitors, let's correct the room name there
local function message_handler(event)
local origin, stanza = event.origin, event.stanza;
if ends_with(stanza.attr.from, main_domain) then
stanza.attr.from = stanza.attr.from:sub(1, -(main_domain:len() + 1))..local_domain;
end
end
process_host_module(local_domain, function(host_module, host)
host_module:hook('iq/host', stanza_handler, 10);
host_module:hook('message/full', message_handler);
end);
-- only live chat is supported for visitors
module:hook('muc-occupant-groupchat', function(event)
local occupant, room, stanza = event.occupant, event.room, event.stanza;
local from = stanza.attr.from;
local occupant_host;
-- if there is no occupant this is a message from main, probably coming from other vnode
if occupant then
occupant_host = jid.host(occupant.bare_jid);
-- we manage nick only for visitors
if occupant_host ~= main_domain then
-- add to message stanza display name for the visitor
-- remove existing nick to avoid forgery
stanza:remove_children('nick', NICK_NS);
local nick_element = occupant:get_presence():get_child('nick', NICK_NS);
if nick_element then
stanza:add_child(nick_element);
else
stanza:tag('nick', { xmlns = NICK_NS })
:text('anonymous'):up();
end
end
stanza.attr.from = occupant.nick;
else
stanza.attr.from = jid.join(jid.node(from), module.host);
end
-- let's send it to main chat and rest of visitors here
for _, o in room:each_occupant() do
-- filter remote occupants
if jid.host(o.bare_jid) == local_domain then
room:route_to_occupant(o, stanza)
end
end
-- send to main participants only messages from local occupants (skip from remote vnodes)
if occupant and occupant_host == local_domain then
local main_message = st.clone(stanza);
main_message.attr.to = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain);
-- make sure we fix the from to be the real jid
main_message.attr.from = room_jid_match_rewrite(stanza.attr.from);
module:send(main_message);
end
stanza.attr.from = from; -- something prosody does internally
return true;
end, 55); -- prosody check for visitor's chat is prio 50, we want to override it
-- Private messaging support for visitors
module:hook('muc-private-message', function(event)
local room, stanza = event.room, event.stanza;
local from = stanza.attr.from;
local to = stanza.attr.to;
local recipient_occupant = room:get_occupant_by_nick(to);
local recipient_domain = recipient_occupant and jid.host(recipient_occupant.bare_jid) or nil;
local sender_occupant = room:get_occupant_by_nick(from);
local sender_domain = sender_occupant and jid.host(sender_occupant.bare_jid) or nil;
if sender_domain == nil or recipient_domain == nil then
return false;
end
-- If both sender and recipient are local (on this vnode)
if sender_domain == local_domain and recipient_domain == local_domain then
event.origin.send(st.error_reply(event.stanza, 'auth', 'forbidden',
'Private messaging between visitors is disabled on visitor nodes'));
return false; -- Prevent sending the original message and stop further processing
end
-- If sender is local (visitor node) and recipient is from main prosody, forward to main prosody
if sender_domain == local_domain and recipient_domain == main_domain then
local original_to = stanza.attr.to;
local original_from = stanza.attr.from;
-- Add nick element for visitor identification
-- remove existing nick to avoid forgery
stanza:remove_children('nick', NICK_NS);
local nick_element = sender_occupant:get_presence():get_child('nick', NICK_NS);
if nick_element then
stanza:add_child(nick_element);
else
stanza:tag('nick', { xmlns = NICK_NS }):text('anonymous'):up();
end
-- Forward to main prosody, preserving the resource and original from
stanza.attr.to = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain, jid.resource(to));
module:send(stanza);
return false; -- Prevent sending the original message and stop further processing
end
-- For main->visitor messages, let the default MUC handler process it
-- We don't need to do anything special
if sender_domain == main_domain and recipient_domain == local_domain then
return; -- Return nothing, let other handlers continue. The default MUC handler will process it.
end
return false; -- Prevent sending the original message and stop further processing
end, 100); -- Lower priority to run after other handlers
-- we calculate the stats on the configured interval (60 seconds by default)
module:hook_global('stats-update', function ()
local participants_count, rooms_count, visitors_count = 0, 0, 0;
-- iterate over all rooms
for room in prosody.hosts[module.host].modules.muc.each_room() do
rooms_count = rooms_count + 1;
for _, o in room:each_occupant() do
if not is_admin(o.bare_jid) then
local _, host = jid.split(o.bare_jid);
if prosody.hosts[host] then -- local hosts are visitors (including jigasi)
visitors_count = visitors_count + 1;
else
participants_count = participants_count + 1;
end
end
end
end
measure_rooms(rooms_count);
measure_visitors(visitors_count);
measure_participants(participants_count);
end);
-- we skip it till the main participants are added from the main prosody
module:hook('jicofo-unlock-room', function(e)
-- we do not block events we fired
if e.fmuc_fired then
return;
end
return true;
end);
-- handles incoming iq visitors stanzas
-- connect - sent after sending all main participant's presences
-- disconnect - sent when main room is destroyed or when we receive a 'disconnect-vnode' iq from jicofo
-- update - sent on:
-- * room secret is changed
-- * lobby enabled or disabled
-- * initially before connect to report currently joined moderators
-- * moderator participant joins main room
-- * a participant has been granted moderator rights
local function iq_from_main_handler(event)
local origin, stanza = event.origin, event.stanza;
if stanza.name ~= 'iq' then
return;
end
if stanza.attr.type == 'result' and sent_iq_cache:get(stanza.attr.id) then
sent_iq_cache:set(stanza.attr.id, nil);
return true;
end
if stanza.attr.type ~= 'set' then
return;
end
local visitors_iq = event.stanza:get_child('visitors', 'jitsi:visitors');
if not visitors_iq then
return;
end
if stanza.attr.from ~= main_domain then
module:log('warn', 'not from main prosody, ignore! %s', stanza);
return true;
end
local room_jid = visitors_iq.attr.room;
local room = get_room_from_jid(room_jid_match_rewrite(room_jid));
if not room then
module:log('warn', 'No room found %s in iq_from_main_handler for:%s', room_jid, visitors_iq);
return;
end
local node = visitors_iq:get_child('connect');
local fire_jicofo_unlock = true;
local process_disconnect = false;
if not node then
node = visitors_iq:get_child('update');
fire_jicofo_unlock = false;
end
if not node then
node = visitors_iq:get_child('disconnect');
process_disconnect = true;
end
if not node then
return;
end
-- respond with successful receiving the iq
respond_iq_result(origin, stanza);
if process_disconnect then
return destroy_with_conference_ended(room);
end
-- if there is password supplied use it
-- if this is update it will either set or remove the password
room:set_password(node.attr.password);
room._data.meetingId = node.attr.meetingId;
local createdTimestamp = node.attr.createdTimestamp;
room.created_timestamp = createdTimestamp and tonumber(createdTimestamp) or nil;
if node.attr.lobby == 'true' then
room._main_room_lobby_enabled = true;
elseif node.attr.lobby == 'false' then
room._main_room_lobby_enabled = false;
end
-- read the moderators list
room.moderators_list = room.moderators_list or set.new();
local moderators = node:get_child('moderators');
if moderators then
for _, child in ipairs(moderators.tags) do
if child.name == 'item' then
room.moderators_list:add(child.attr.epId);
end
end
-- let's check current occupants roles and promote them if needed
-- we change only main participants which are not moderators, but participant
for _, o in room:each_occupant() do
if not is_admin(o.bare_jid)
and o.role == 'participant'
and room.moderators_list:contains(jid.resource(o.nick)) then
room:set_affiliation(true, o.bare_jid, 'owner');
end
end
end
local files = node:get_child('files');
if files then
local received_files = {};
for _, child in ipairs(files.tags) do
if child.name == 'file' then
received_files[child.attr.id] = json.decode(child:get_text());
end
end
-- fire events so file sharing component will add/remove files and will notify clients
local removed, added = table_compare(room.jitsi_shared_files or {}, received_files)
for _, id in ipairs(removed) do
module:context(local_domain):fire_event('jitsi-filesharing-remove', {
room = room; id = id;
});
end
for _, id in ipairs(added) do
module:context(local_domain):fire_event('jitsi-filesharing-add', {
room = room; file = received_files[id];
});
end
end
if fire_jicofo_unlock then
-- everything is connected allow participants to join
module:fire_event('jicofo-unlock-room', { room = room; fmuc_fired = true; });
end
return true;
end
module:hook('iq/host', iq_from_main_handler, 10);
-- Filters presences (if detected) that are with destination the main prosody
function filter_stanza(stanza, session)
if (stanza.name == 'presence' or stanza.name == 'message') and session.type ~= 'c2s' then
-- we clone it so we do not affect broadcast using same stanza, sending it to clients
local f_st = st.clone(stanza);
f_st.skipMapping = true;
return f_st;
elseif stanza.name == 'presence' and session.type == 'c2s' and jid.node(stanza.attr.to) == 'focus' then
local x = stanza:get_child('x', 'http://jabber.org/protocol/muc#user');
if presence_check_status(x, '110') then
return stanza; -- no filter
end
-- we want to filter presences to jicofo for the main participants, skipping visitors
-- no point of having them, but if it is the one of the first to be sent
-- when first visitor is joining can produce the 'No hosts[from_host]' error as we
-- rewrite the from, but we need to not do it to be able to filter it later for the s2s
if jid.host(room_jid_match_rewrite(stanza.attr.from)) ~= local_muc_domain then
return nil; -- returning nil filters the stanza
end
end
return stanza; -- no filter
end
function filter_session(session)
-- domain mapper is filtering on default priority 0, and we need it before that
filters.add_filter(session, 'stanzas/out', filter_stanza, 2);
end
filters.add_filter_hook(filter_session);
function route_s2s_stanza(event)
local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza;
if to_host ~= main_domain then
return; -- continue with hook listeners
end
if stanza.name == 'message' then
if jid.resource(stanza.attr.to) then
-- there is no point of delivering messages to main participants individually
return true; -- drop it
end
return;
end
if stanza.name == 'presence' then
-- we want to leave only unavailable presences to go to main node
-- all other presences from jicofo or the main participants there is no point to go to the main node
-- they are anyway not handled
if stanza.attr.type ~= 'unavailable' then
return true; -- drop it
end
return;
end
end
-- routing to sessions in mod_s2s is -1 and -10, we want to hook before that to make sure to is correct
-- or if we want to filter that stanza
module:hook("route/remote", route_s2s_stanza, 10);

View File

@@ -0,0 +1,71 @@
local json = require 'cjson';
local util = module:require 'util';
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local get_room_from_jid = util.get_room_from_jid;
-- This needs to be attached to the main virtual host and the virtual host where jicofo is connected and authenticated.
-- The first pass is the iq coming from the client where we get the creator and attach it to the app_data.
-- The second pass is jicofo approving that and inviting jibri where we attach the session_id information to app_data
local function attachJibriSessionId(event)
local stanza = event.stanza;
if stanza.name == "iq" then
local jibri = stanza:get_child('jibri', 'http://jitsi.org/protocol/jibri');
if jibri then
if jibri.attr.action == 'start' then
local update_app_data = false;
local app_data = jibri.attr.app_data;
if app_data then
app_data = json.decode(app_data);
else
app_data = {};
end
if app_data.file_recording_metadata == nil then
app_data.file_recording_metadata = {};
end
if jibri.attr.room then
local jibri_room = jibri.attr.room;
jibri_room = room_jid_match_rewrite(jibri_room)
local room = get_room_from_jid(jibri_room);
if room then
local conference_details = {};
conference_details["session_id"] = room._data.meetingId;
app_data.file_recording_metadata.conference_details = conference_details;
update_app_data = true;
end
else
-- no room is because the iq received by the initiator in the room
local session = event.origin;
-- if a token is provided, add data to app_data
if session ~= nil then
local initiator = {};
if session.jitsi_meet_context_user ~= nil then
initiator.id = session.jitsi_meet_context_user.id;
else
initiator.id = session.granted_jitsi_meet_context_user_id;
end
initiator.group
= session.jitsi_meet_context_group or session.granted_jitsi_meet_context_group_id;
app_data.file_recording_metadata.initiator = initiator
update_app_data = true;
end
end
if update_app_data then
app_data = json.encode(app_data);
jibri.attr.app_data = app_data;
jibri:up()
stanza:up()
end
end
end
end
end
module:hook('pre-iq/full', attachJibriSessionId);

View File

@@ -0,0 +1,77 @@
local st = require "util.stanza";
local ext_services = module:depends("external_services");
local get_services = ext_services.get_services;
local services_xml = ext_services.services_xml;
-- Jitsi Connection Optimization
-- gathers needed information and pushes it with a message to clients
-- this way we skip 4 request responses during every client setup
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
if not main_virtual_host then
module:log('warn', 'No muc_mapper_domain_base option set.');
return;
end
local shard_name_config = module:get_option_string('shard_name');
if shard_name_config then
module:add_identity("server", "shard", shard_name_config);
end
local region_name_config = module:get_option_string('region_name');
if region_name_config then
module:add_identity("server", "region", region_name_config);
end
local release_number_config = module:get_option_string('release_number');
if release_number_config then
module:add_identity("server", "release", release_number_config);
end
-- we cache the query as server identities will not change dynamically, amd use its clone every time
local query_cache;
-- this is after xmpp-bind, the moment a client has resource and can be contacted
module:hook("resource-bind", function (event)
local session = event.session;
if query_cache == nil then
-- disco info data / all identity and features
local query = st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#info" });
local done = {};
-- to lod this module in different virtual hosts than the main, make sure we query here for main
for _,identity in ipairs(module:context(main_virtual_host):get_host_items("identity")) do
local identity_s = identity.category.."\0"..identity.type;
if not done[identity_s] then
query:tag("identity", identity):up();
done[identity_s] = true;
end
end
query_cache = query;
end
local query = st.clone(query_cache);
-- check whether room has lobby enabled and display name is required for those trying to join
local lobby_muc_component_config = module:get_option_string('lobby_muc');
module:context(lobby_muc_component_config):fire_event('host-disco-info-node',
{origin = session; reply = query; node = 'lobbyrooms';});
-- will add a rename feature for breakout rooms.
local breakout_rooms_muc_component_config = module:get_option_string('breakout_rooms_muc');
if breakout_rooms_muc_component_config then
module:context(breakout_rooms_muc_component_config):fire_event('host-disco-info-node',
{origin = session; reply = query; node = 'breakout_rooms';});
end
local stanza = st.message({
from = module.host;
to = session.full_jid; });
stanza:add_child(query):up();
--- get turnservers and credentials
stanza:add_child(services_xml(get_services()));
session.send(stanza);
end);

View File

@@ -0,0 +1,196 @@
-- this is auto loaded by meeting_id
local filters = require 'util.filters';
local jid = require 'util.jid';
local util = module:require 'util';
local is_admin = util.is_admin;
local get_room_from_jid = util.get_room_from_jid;
local is_healthcheck_room = util.is_healthcheck_room;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local ends_with = util.ends_with;
local presence_check_status = util.presence_check_status;
local MUC_NS = 'http://jabber.org/protocol/muc';
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
local muc_domain_base = module:get_option_string('muc_mapper_domain_base');
if not muc_domain_base then
module:log('warn', 'No "muc_domain_base" option set, disabling module.');
return ;
end
-- only the visitor prosody has main_domain setting
local is_visitor_prosody = module:get_option_string('main_domain') ~= nil;
-- load it only on the main muc component as it is loaded by muc_meeting_id which is loaded and for the breakout room muc
if muc_domain_prefix..'.'..muc_domain_base ~= module.host or is_visitor_prosody then
return;
end
local sessions = prosody.full_sessions;
local default_permissions;
local function load_config()
default_permissions = module:get_option('jitsi_default_permissions', {
livestreaming = true;
recording = true;
transcription = true;
['outbound-call'] = true;
['create-polls'] = true;
['send-groupchat'] = true;
flip = true;
});
end
load_config();
function process_set_affiliation(event)
local actor, affiliation, jid, previous_affiliation, room
= event.actor, event.affiliation, event.jid, event.previous_affiliation, event.room;
local actor_session = sessions[actor];
if is_admin(jid) or is_healthcheck_room(room.jid) or not actor or not previous_affiliation
or not actor_session or not actor_session.jitsi_meet_context_features then
return;
end
local occupant;
for _, o in room:each_occupant() do
if o.bare_jid == jid then
occupant = o;
end
end
if not occupant then
return;
end
local occupant_session = sessions[occupant.jid];
if not occupant_session then
return;
end
if (previous_affiliation == 'none' or previous_affiliation == 'member') and affiliation == 'owner' then
occupant_session.jitsi_meet_context_features = actor_session.jitsi_meet_context_features;
if actor_session.jitsi_meet_context_user then
occupant_session.granted_jitsi_meet_context_user_id = actor_session.jitsi_meet_context_user['id']
or actor_session.granted_jitsi_meet_context_user_id;
end
occupant_session.granted_jitsi_meet_context_group_id = actor_session.jitsi_meet_context_group
or actor_session.granted_jitsi_meet_context_group_id;
-- even if token and features are set we may want to re-send permissions
occupant_session.force_permissions_update = true;
elseif previous_affiliation == 'owner' and ( affiliation == 'member' or affiliation == 'none' ) then
occupant_session.granted_jitsi_meet_context_user_id = nil;
occupant_session.granted_jitsi_meet_context_group_id = nil;
-- on revoke
if not session.auth_token then
occupant_session.jitsi_meet_context_features = nil;
end
end
end
-- Detects when sending self-presence because of role change
-- we can end up here because of the following cases:
-- 1. user joins the room and is granted moderator by another moderator or jicofo
-- 2. Some module changes the role of the user by using set_affiliation method
-- In cases where authentication is 'anonymous', 'jitsi-anonymous', 'internal_hashed', 'internal_plain', 'cyrus' we
-- want to send default permissions all to indicate UI that everything is allowed (to not relay on the UI to check
-- is participant moderator or not), to allow finer control over the permissions.
-- In case the authentication is 'token' based we want to send permissions only if the token of the user does not include
-- features in the user.context.
-- In case of allowners we want to send the permissions, no matter of the authentication method.
-- In case permissions were granted we want to send the granted permissions in all cases except when the user is
-- using token that has features pre-defined (authentication is 'token').
function filter_stanza(stanza, session)
if not stanza.attr or not stanza.attr.to or stanza.name ~= 'presence'
or stanza.attr.type == 'unavailable' or ends_with(stanza.attr.from, '/focus') then
return stanza;
end
local bare_to = jid.bare(stanza.attr.to);
if is_admin(bare_to) then
return stanza;
end
local muc_x = stanza:get_child('x', MUC_NS..'#user');
if not muc_x or not presence_check_status(muc_x, '110') then
return stanza;
end
local room = get_room_from_jid(room_jid_match_rewrite(jid.bare(stanza.attr.from)));
if not room or is_healthcheck_room(room.jid) then
return stanza;
end
if not room.send_default_permissions_to then
room.send_default_permissions_to = {};
end
if not session.force_permissions_update then
if session.auth_token and session.jitsi_meet_context_features then -- token and features are set so skip
room.send_default_permissions_to[bare_to] = nil;
return stanza;
end
-- we are sending permissions only when becoming a member
local is_moderator = false;
for item in muc_x:childtags('item') do
if item.attr.role == 'moderator' then
is_moderator = true;
break;
end
end
if not is_moderator then
return stanza;
end
if not room.send_default_permissions_to[bare_to] then
return stanza;
end
end
session.force_permissions_update = false;
if not session.jitsi_meet_context_features then
session.jitsi_meet_context_features = default_permissions;
end
room.send_default_permissions_to[bare_to] = nil;
stanza:tag('permissions', { xmlns='http://jitsi.org/jitmeet' });
for k, v in pairs(session.jitsi_meet_context_features) do
local val = tostring(v);
stanza:tag('p', { name = k, val = val }):up();
end
stanza:up();
return stanza;
end
-- we need to indicate that we will send permissions if we need to
-- we need to handle granted features and stuff in the pre-set hook so they are unavailable
-- when the self presence is set, so we can update the client, the checks
-- whether the actor is allowed to set the affiliation are done before pre-set hook is fired
module:hook('muc-pre-set-affiliation', function(event)
local jid, room = event.jid, event.room;
if not room.send_default_permissions_to then
room.send_default_permissions_to = {};
end
room.send_default_permissions_to[jid] = true;
process_set_affiliation(event);
end);
function filter_session(session)
-- domain mapper is filtering on default priority 0
-- allowners is -1 and we need it after that
filters.add_filter(session, 'stanzas/out', filter_stanza, -2);
end
-- enable filtering presences
filters.add_filter_hook(filter_session);

View File

@@ -0,0 +1,33 @@
-- Jitsi session information
-- Copyright (C) 2021-present 8x8, Inc.
module:set_global();
local formdecode = require "util.http".formdecode;
local region_header_name = module:get_option_string('region_header_name', 'x_proxy_region');
-- Extract the following parameters from the URL and set them in the session:
-- * previd: for session resumption
function init_session(event)
local session, request = event.session, event.request;
local query = request.url.query;
if query ~= nil then
local params = formdecode(query);
-- previd is used together with https://modules.prosody.im/mod_smacks.html
-- the param is used to find resumed session and re-use anonymous(random) user id
session.previd = query and params.previd or nil;
-- customusername can be used with combination with "pre-jitsi-authentication" event to pre-set a known jid to a session
session.customusername = query and params.customusername or nil;
-- The room name and optional prefix from the web query
session.jitsi_web_query_room = params.room;
session.jitsi_web_query_prefix = params.prefix or "";
end
session.user_region = request.headers[region_header_name];
end
module:hook_global("bosh-session", init_session, 1);
module:hook_global("websocket-session", init_session, 1);

View File

@@ -0,0 +1,32 @@
-- we use async to detect Prosody 0.10 and earlier
local have_async = pcall(require, 'util.async');
if not have_async then
return;
end
local unlimited_jids = module:get_option_inherited_set("unlimited_jids", {});
-- rises the limit of the stanza size for the unlimited jids, default is 10MB
local unlimited_stanza_size_limit = module:get_option_number("unlimited_size", 10*1024*1024);
if unlimited_jids:empty() then
return;
end
module:hook("authentication-success", function (event)
local session = event.session;
local jid = session.username .. "@" .. session.host;
if unlimited_jids:contains(jid) then
if session.conn and session.conn.setlimit then
session.conn:setlimit(0);
elseif session.throttle then
session.throttle = nil;
end
if unlimited_stanza_size_limit and session.stream.set_stanza_size_limit then
module:log('info', 'Setting stanza size limits for %s to %s', jid, unlimited_stanza_size_limit)
session.stream:set_stanza_size_limit(unlimited_stanza_size_limit);
end
end
end);

View File

@@ -0,0 +1,120 @@
module:set_global();
local loggingmanager = require "core.loggingmanager";
local format = require "util.format".format;
local pposix = require "util.pposix";
local rb = require "util.ringbuffer";
local queue = require "util.queue";
local default_timestamp = "%b %d %H:%M:%S ";
local max_chunk_size = module:get_option_number("log_ringbuffer_chunk_size", 16384);
local os_date = os.date;
local default_filename_template = "{paths.data}/ringbuffer-logs-{pid}-{count}.log";
local render_filename = require "util.interpolation".new("%b{}", function (s) return s; end, {
yyyymmdd = function (t)
return os_date("%Y%m%d", t);
end;
hhmmss = function (t)
return os_date("%H%M%S", t);
end;
});
local dump_count = 0;
local function dump_buffer(dump, filename)
dump_count = dump_count + 1;
local f, err = io.open(filename, "a+");
if not f then
module:log("error", "Unable to open output file: %s", err);
return;
end
f:write(("-- Dumping log buffer at %s --\n"):format(os_date(default_timestamp)));
dump(f);
f:write("-- End of dump --\n\n");
f:close();
end
local function get_filename(filename_template)
filename_template = filename_template or default_filename_template;
return render_filename(filename_template, {
paths = prosody.paths;
pid = pposix.getpid();
count = dump_count;
time = os.time();
});
end
local function new_buffer(config)
local write, dump;
if config.lines then
local buffer = queue.new(config.lines, true);
function write(line)
buffer:push(line);
end
function dump(f)
-- COMPAT w/0.11 - update to use :consume()
for line in buffer.pop, buffer do
f:write(line);
end
end
else
local buffer_size = config.size or 100*1024;
local buffer = rb.new(buffer_size);
function write(line)
if not buffer:write(line) then
if #line > buffer_size then
buffer:discard(buffer_size);
buffer:write(line:sub(-buffer_size));
else
buffer:discard(#line);
buffer:write(line);
end
end
end
function dump(f)
local bytes_remaining = buffer:length();
while bytes_remaining > 0 do
local chunk_size = math.min(bytes_remaining, max_chunk_size);
local chunk = buffer:read(chunk_size);
if not chunk then
return;
end
f:write(chunk);
bytes_remaining = bytes_remaining - chunk_size;
end
end
end
return write, dump;
end
local function ringbuffer_log_sink_maker(sink_config)
local write, dump = new_buffer(sink_config);
local timestamps = sink_config.timestamps;
if timestamps == true or timestamps == nil then
timestamps = default_timestamp; -- Default format
elseif timestamps then
timestamps = timestamps .. " ";
end
local function handler()
dump_buffer(dump, sink_config.filename or get_filename(sink_config.filename_template));
end
if sink_config.signal then
require "util.signal".signal(sink_config.signal, handler);
elseif sink_config.event then
module:hook_global(sink_config.event, handler);
end
return function (name, level, message, ...)
local line = format("%s%s\t%s\t%s\n", timestamps and os_date(timestamps) or "", name, level, format(message, ...));
write(line);
end;
end
loggingmanager.register_sink_type("ringbuffer", ringbuffer_log_sink_maker);

View File

@@ -0,0 +1,166 @@
-- Measure the number of messages used in a meeting. Sends amplitude event.
-- Needs to be activated under the muc component where the limit needs to be applied (main muc and breakout muc)
-- Copyright (C) 2023-present 8x8, Inc.
local jid = require 'util.jid';
local http = require 'net.http';
local cjson_safe = require 'cjson.safe'
local amplitude_endpoint = module:get_option_string('amplitude_endpoint', 'https://api2.amplitude.com/2/httpapi');
local amplitude_api_key = module:get_option_string('amplitude_api_key');
if not amplitude_api_key then
module:log("warn", "No 'amplitude_api_key' option set, disabling amplitude reporting");
return
end
local muc_domain_base = module:get_option_string('muc_mapper_domain_base');
local isBreakoutRoom = module.host == 'breakout.' .. muc_domain_base;
local util = module:require 'util';
local is_healthcheck_room = util.is_healthcheck_room;
local extract_subdomain = util.extract_subdomain;
module:log('info', 'Loading measure message count');
local shard_name = module:context(muc_domain_base):get_option_string('shard_name');
local region_name = module:context(muc_domain_base):get_option_string('region_name');
local release_number = module:context(muc_domain_base):get_option_string('release_number');
local http_headers = {
['User-Agent'] = 'Prosody ('..prosody.version..'; '..prosody.platform..')',
['Content-Type'] = 'application/json'
};
local inspect = require "inspect"
function table.clone(t)
return {table.unpack(t)}
end
local function event_cb(content_, code_, response_, request_)
if code_ == 200 or code_ == 204 then
module:log('debug', 'URL Callback: Code %s, Content %s, Request (host %s, path %s, body %s), Response: %s',
code_, content_, request_.host, request_.path, inspect(request_.body), inspect(response_));
else
module:log('warn', 'URL Callback non successful: Code %s, Content %s, Request (%s), Response: %s',
code_, content_, inspect(request_), inspect(response_));
end
end
function send_event(room)
local user_properties = {
shard_name = shard_name;
region_name = region_name;
release_number = release_number;
};
local node = jid.split(room.jid);
local subdomain, room_name = extract_subdomain(node);
user_properties.tenant = subdomain or '/';
user_properties.conference_name = room_name or node;
local event_properties = {
messages_count = room._muc_messages_count or 0;
polls_count = room._muc_polls_count or 0;
tenant_mismatch = room.jitsi_meet_tenant_mismatch or false;
};
if room.created_timestamp then
event_properties.duration = (os.time() * 1000 - room.created_timestamp) / 1000;
end
local event = {
api_key = amplitude_api_key;
events = {
{
user_id = room._data.meetingId;
device_id = room._data.meetingId;
event_type = 'conference_ended';
event_properties = event_properties;
user_properties = user_properties;
}
};
};
local request = http.request(amplitude_endpoint, {
headers = http_headers,
method = "POST",
body = cjson_safe.encode(event)
}, event_cb);
end
function on_message(event)
local stanza = event.stanza;
local body = stanza:get_child('body');
if not body then
-- we ignore messages without body - lobby, polls ...
return;
end
local session = event.origin;
if not session or not session.jitsi_web_query_room then
return;
end
-- get room name with tenant and find room.
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
module:log('warn', 'No room found found for %s/%s',
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return;
end
if not room._muc_messages_count then
room._muc_messages_count = 0;
end
room._muc_messages_count = room._muc_messages_count + 1;
end
-- Conference ended, send stats
function room_destroyed(event)
local room, session = event.room, event.origin;
if is_healthcheck_room(room.jid) then
return;
end
if isBreakoutRoom then
return;
end
send_event(room);
end
function poll_created(event)
local session = event.event.origin;
-- get room name with tenant and find room.
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
module:log('warn', 'No room found found for %s/%s',
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return false;
end
if not room._muc_polls_count then
room._muc_polls_count = 0;
end
room._muc_polls_count = room._muc_polls_count + 1;
end
module:hook('message/full', on_message); -- private messages
module:hook('message/bare', on_message); -- room messages
module:hook('muc-room-destroyed', room_destroyed, -1);
module:hook("muc-occupant-left", function(event)
local occupant, room = event.occupant, event.room;
local session = event.origin;
if session and session.jitsi_meet_tenant_mismatch then
room.jitsi_meet_tenant_mismatch = true;
end
end);
module:hook('poll-created', poll_created);

View File

@@ -0,0 +1,32 @@
module:set_global()
local filters = require"util.filters";
local stanzas_in = module:metric(
"counter", "received", "",
"Stanzas received by Prosody",
{ "session_type", "stanza_kind" }
)
local stanzas_out = module:metric(
"counter", "sent", "",
"Stanzas sent by prosody",
{ "session_type", "stanza_kind" }
)
local stanza_kinds = { message = true, presence = true, iq = true };
local function rate(metric_family)
return function (stanza, session)
if stanza.attr and not stanza.attr.xmlns and stanza_kinds[stanza.name] then
metric_family:with_labels(session.type, stanza.name):add(1);
end
return stanza;
end
end
local function measure_stanza_counts(session)
filters.add_filter(session, "stanzas/in", rate(stanzas_in));
filters.add_filter(session, "stanzas/out", rate(stanzas_out));
end
filters.add_filter_hook(measure_stanza_counts);

View File

@@ -0,0 +1,160 @@
--- activate under the main muc component
local filters = require 'util.filters';
local jid = require "util.jid";
local jid_bare = require "util.jid".bare;
local jid_host = require "util.jid".host;
local st = require "util.stanza";
local util = module:require "util";
local is_admin = util.is_admin;
local is_healthcheck_room = util.is_healthcheck_room;
local is_moderated = util.is_moderated;
local get_room_from_jid = util.get_room_from_jid;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local presence_check_status = util.presence_check_status;
local MUC_NS = 'http://jabber.org/protocol/muc';
local disable_revoke_owners;
local function load_config()
disable_revoke_owners = module:get_option_boolean("allowners_disable_revoke_owners", false);
end
load_config();
-- List of the bare_jids of all occupants that are currently joining (went through pre-join) and will be promoted
-- as moderators. As pre-join (where added) and joined event (where removed) happen one after another this list should
-- have length of 1
local joining_moderator_participants = module:shared('moderators/joining_moderator_participants');
module:hook("muc-room-created", function(event)
local room = event.room;
if room.jitsiMetadata then
room.jitsiMetadata.allownersEnabled = true;
end
end, -2); -- room_metadata should run before this module on -1
module:hook("muc-occupant-pre-join", function (event)
local room, occupant = event.room, event.occupant;
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
return;
end
local moderated, room_name, subdomain = is_moderated(room.jid);
if moderated then
local session = event.origin;
local token = session.auth_token;
if not token then
module:log('debug', 'skip allowners for non-auth user subdomain:%s room_name:%s', subdomain, room_name);
return;
end
if not (room_name == session.jitsi_meet_room or session.jitsi_meet_room == '*') then
module:log('debug', 'skip allowners for auth user and non matching room name: %s, jwt room name: %s',
room_name, session.jitsi_meet_room);
return;
end
if session.jitsi_meet_domain ~= '*' and subdomain ~= session.jitsi_meet_domain then
module:log('debug', 'skip allowners for auth user and non matching room subdomain: %s, jwt subdomain: %s',
subdomain, session.jitsi_meet_domain);
return;
end
end
-- mark this participant that it will be promoted and is currently joining
joining_moderator_participants[occupant.bare_jid] = true;
end, 2);
module:hook("muc-occupant-joined", function (event)
local room, occupant = event.room, event.occupant;
local promote_to_moderator = joining_moderator_participants[occupant.bare_jid];
-- clear it
joining_moderator_participants[occupant.bare_jid] = nil;
if promote_to_moderator ~= nil then
room:set_affiliation(true, occupant.bare_jid, "owner");
end
end, 2);
module:hook_global('config-reloaded', load_config);
-- Filters self-presences to a jid that exist in joining_participants array
-- We want to filter those presences where we send first `participant` and just after it `moderator`
function filter_stanza(stanza)
-- when joining_moderator_participants is empty there is nothing to filter
if next(joining_moderator_participants) == nil
or not stanza.attr
or not stanza.attr.to
or stanza.name ~= "presence" then
return stanza;
end
-- we want to filter presences only on this host for allowners and skip anything like lobby etc.
local host_from = jid_host(room_jid_match_rewrite(stanza.attr.from));
if host_from ~= module.host then
return stanza;
end
local bare_to = jid_bare(stanza.attr.to);
if stanza:get_error() and joining_moderator_participants[bare_to] then
-- pre-join succeeded but joined did not so we need to clear cache
joining_moderator_participants[bare_to] = nil;
return stanza;
end
local muc_x = stanza:get_child('x', MUC_NS..'#user');
if not muc_x then
return stanza;
end
if joining_moderator_participants[bare_to] and presence_check_status(muc_x, '110') then
-- skip the local presence for participant
return nil;
end
-- skip sending the 'participant' presences to all other people in the room
for item in muc_x:childtags('item') do
if joining_moderator_participants[jid_bare(item.attr.jid)] then
return nil;
end
end
return stanza;
end
function filter_session(session)
-- domain mapper is filtering on default priority 0, and we need it after that
filters.add_filter(session, 'stanzas/out', filter_stanza, -1);
end
-- enable filtering presences
filters.add_filter_hook(filter_session);
-- filters any attempt to revoke owner rights on non moderated rooms
function filter_admin_set_query(event)
local origin, stanza = event.origin, event.stanza;
local room_jid = jid_bare(stanza.attr.to);
local room = get_room_from_jid(room_jid);
local item = stanza.tags[1].tags[1];
local _aff = item.attr.affiliation;
-- if it is a moderated room we skip it
if room and is_moderated(room.jid) then
return nil;
end
-- any revoking is disabled, everyone should be owners
if _aff == 'none' or _aff == 'outcast' or _aff == 'member' then
origin.send(st.error_reply(stanza, "auth", "forbidden"));
return true;
end
end
if not disable_revoke_owners then
-- default prosody priority for handling these is -2
module:hook("iq-set/bare/http://jabber.org/protocol/muc#admin:query", filter_admin_set_query, 5);
module:hook("iq-set/host/http://jabber.org/protocol/muc#admin:query", filter_admin_set_query, 5);
end

View File

@@ -0,0 +1,88 @@
-- Can be used to ban users based on external http service
-- Copyright (C) 2023-present 8x8, Inc.
local ACCESS_MANAGER_URL = module:get_option_string("muc_prosody_jitsi_access_manager_url");
if not ACCESS_MANAGER_URL then
module:log("warn", "No 'muc_prosody_jitsi_access_manager_url' option set, disabling module");
return
end
local json = require "cjson.safe";
local http = require "net.http";
local inspect = require 'inspect';
local ban_check_count = module:measure("muc_auth_ban_check", "rate")
local ban_check_users_banned_count = module:measure("muc_auth_ban_users_banned", "rate")
-- we will cache banned tokens to avoid extra requests
-- on destroying session, websocket retries 2 more times before giving up
local cache = require "util.cache".new(100);
local CACHE_DURATION = 5*60; -- 5 mins
local cache_timer = module:add_timer(CACHE_DURATION, function()
for k, v in cache:items() do
if socket.gettime() > v + CACHE_DURATION then
cache:set(k, nil);
end
end
if cache:count() > 0 then
-- rescheduling the timer
return CACHE_DURATION;
end
-- skipping return value stops the timer
end);
local function shouldAllow(session)
local token = session.auth_token;
if token ~= nil then
-- module:log("debug", "Checking whether user should be banned ")
-- cached tokens are banned
if cache:get(token) then
return false;
end
-- TODO: do this only for enabled customers
ban_check_count();
local function cb(content, code, response, request)
if code == 200 then
local r = json.decode(content)
if r['access'] ~= nil and r['access'] == false then
module:log("info", "User is banned room:%s tenant:%s user_id:%s group:%s",
session.jitsi_meet_room, session.jitsi_web_query_prefix,
inspect(session.jitsi_meet_context_user), session.jitsi_meet_context_group);
ban_check_users_banned_count();
session:close();
-- if the cache is empty and the timer is not running reschedule it
if cache:count() == 0 then
cache_timer:reschedule(CACHE_DURATION);
end
cache:set(token, socket.gettime());
end
end
end
local request_headers = {}
request_headers['Authorization'] = 'Bearer ' .. token;
http.request(ACCESS_MANAGER_URL, {
headers = request_headers,
method = "GET",
}, cb);
return true;
end
end
prosody.events.add_handler("jitsi-access-ban-check", function(session)
return shouldAllow(session)
end)

View File

@@ -0,0 +1,670 @@
-- This module is added under the main virtual host domain
-- It needs a breakout rooms muc component
--
-- VirtualHost "jitmeet.example.com"
-- modules_enabled = {
-- "muc_breakout_rooms"
-- }
-- breakout_rooms_muc = "breakout.jitmeet.example.com"
-- main_muc = "muc.jitmeet.example.com"
--
-- Component "breakout.jitmeet.example.com" "muc"
-- restrict_room_creation = true
-- storage = "memory"
-- admins = { "focusUser@auth.jitmeet.example.com" }
-- muc_room_locking = false
-- muc_room_default_public_jids = true
--
module:depends('room_destroy');
-- we use async to detect Prosody 0.10 and earlier
local have_async = pcall(require, 'util.async');
if not have_async then
module:log('warn', 'Breakout rooms will not work with Prosody version 0.10 or less.');
return;
end
local jid_node = require 'util.jid'.node;
local jid_host = require 'util.jid'.host;
local jid_split = require 'util.jid'.split;
local json = require 'cjson.safe';
local st = require 'util.stanza';
local uuid_gen = require 'util.uuid'.generate;
local util = module:require 'util';
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local is_healthcheck_room = util.is_healthcheck_room;
local process_host_module = util.process_host_module;
local BREAKOUT_ROOMS_IDENTITY_TYPE = 'breakout_rooms';
-- Available breakout room functionality
local RENAME_FEATURE = 'http://jitsi.org/protocol/breakout_rooms#rename';
-- only send at most this often updates on breakout rooms to avoid flooding.
local BROADCAST_ROOMS_INTERVAL = .3;
-- close conference after this amount of seconds if all leave.
local ROOMS_TTL_IF_ALL_LEFT = 5;
local JSON_TYPE_ADD_BREAKOUT_ROOM = 'features/breakout-rooms/add';
local JSON_TYPE_MOVE_TO_ROOM_REQUEST = 'features/breakout-rooms/move-to-room';
local JSON_TYPE_REMOVE_BREAKOUT_ROOM = 'features/breakout-rooms/remove';
local JSON_TYPE_RENAME_BREAKOUT_ROOM = 'features/breakout-rooms/rename';
local JSON_TYPE_UPDATE_BREAKOUT_ROOMS = 'features/breakout-rooms/update';
local main_muc_component_config = module:get_option_string('main_muc');
if main_muc_component_config == nil then
module:log('error', 'breakout rooms not enabled missing main_muc config');
return ;
end
local breakout_rooms_muc_component_config = module:get_option_string('breakout_rooms_muc', 'breakout.'..module.host);
module:depends('jitsi_session');
local breakout_rooms_muc_service;
local main_muc_service;
-- Maps a breakout room jid to the main room jid
local main_rooms_map = {};
-- Utility functions
function get_main_room_jid(room_jid)
local _, host = jid_split(room_jid);
return
host == main_muc_component_config
and room_jid
or main_rooms_map[room_jid];
end
function get_main_room(room_jid)
local main_room_jid = get_main_room_jid(room_jid);
return main_muc_service.get_room_from_jid(main_room_jid), main_room_jid;
end
function get_room_from_jid(room_jid)
local host = jid_host(room_jid);
return
host == main_muc_component_config
and main_muc_service.get_room_from_jid(room_jid)
or breakout_rooms_muc_service.get_room_from_jid(room_jid);
end
function send_json_msg(to_jid, json_msg)
local stanza = st.message({ from = breakout_rooms_muc_component_config; to = to_jid; })
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_msg):up();
module:send(stanza);
end
function get_participants(room)
local participants = {};
if room then
for room_nick, occupant in room:each_occupant() do
-- Filter focus as we keep it as a hidden participant
if jid_node(occupant.jid) ~= 'focus' then
local display_name = occupant:get_presence():get_child_text(
'nick', 'http://jabber.org/protocol/nick');
local real_nick = internal_room_jid_match_rewrite(room_nick);
participants[real_nick] = {
jid = occupant.jid,
role = occupant.role,
displayName = display_name
};
end
end
end
return participants;
end
function broadcast_breakout_rooms(room_jid)
local main_room = get_main_room(room_jid);
if not main_room or main_room.broadcast_timer then
return;
end
-- Only send each BROADCAST_ROOMS_INTERVAL seconds to prevent flooding of messages.
main_room.broadcast_timer = module:add_timer(BROADCAST_ROOMS_INTERVAL, function()
local main_room, main_room_jid = get_main_room(room_jid);
if not main_room then
return;
end
main_room.broadcast_timer = nil;
local real_jid = internal_room_jid_match_rewrite(main_room_jid);
local real_node = jid_node(real_jid);
local rooms = {
[real_node] = {
isMainRoom = true,
id = real_node,
jid = real_jid,
name = main_room._data.subject,
participants = get_participants(main_room)
};
}
for breakout_room_jid, subject in pairs(main_room._data.breakout_rooms or {}) do
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
local breakout_room_node = jid_node(breakout_room_jid)
rooms[breakout_room_node] = {
id = breakout_room_node,
jid = breakout_room_jid,
name = subject,
participants = {}
}
-- The room may not physically exist yet.
if breakout_room then
rooms[breakout_room_node].participants = get_participants(breakout_room);
end
end
local json_msg, error = json.encode({
type = BREAKOUT_ROOMS_IDENTITY_TYPE,
event = JSON_TYPE_UPDATE_BREAKOUT_ROOMS,
roomCounter = main_room._data.breakout_rooms_counter,
rooms = rooms
});
if not json_msg then
module:log('error', 'not broadcasting breakout room information room:%s error:%s', main_room_jid, error);
return;
end
for _, occupant in main_room:each_occupant() do
if jid_node(occupant.jid) ~= 'focus' then
send_json_msg(occupant.jid, json_msg)
end
end
for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do
local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
if room then
for _, occupant in room:each_occupant() do
if jid_node(occupant.jid) ~= 'focus' then
send_json_msg(occupant.jid, json_msg)
end
end
end
end
end);
end
-- Managing breakout rooms
function create_breakout_room(orig_room, subject)
local main_room, main_room_jid = get_main_room(orig_room.jid);
if orig_room ~= main_room then
module:log('warn', 'Invalid create breakout room request for %s', orig_room.jid);
return;
end
local breakout_room_jid = uuid_gen() .. '@' .. breakout_rooms_muc_component_config;
if not main_room._data.breakout_rooms then
main_room._data.breakout_rooms = {};
main_room._data.breakout_rooms_counter = 0;
end
main_room._data.breakout_rooms_counter = main_room._data.breakout_rooms_counter + 1;
main_room._data.breakout_rooms[breakout_room_jid] = subject;
main_room._data.breakout_rooms_active = true;
-- Make room persistent - not to be destroyed - if all participants join breakout rooms.
main_room:set_persistent(true);
main_room:save(true);
main_rooms_map[breakout_room_jid] = main_room_jid;
broadcast_breakout_rooms(main_room_jid);
end
function destroy_breakout_room(orig_room, room_jid, message)
local main_room, main_room_jid = get_main_room(room_jid);
if room_jid == main_room_jid then
return;
end
if orig_room ~= main_room then
module:log('warn', 'Invalid destroy breakout room request for %s', orig_room.jid);
return;
end
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(room_jid);
if breakout_room then
message = message or 'Breakout room removed.';
breakout_room:destroy(main_room and main_room_jid or nil, message);
end
if main_room then
if main_room._data.breakout_rooms then
main_room._data.breakout_rooms[room_jid] = nil;
end
main_room:save(true);
main_rooms_map[room_jid] = nil;
broadcast_breakout_rooms(main_room_jid);
end
end
function rename_breakout_room(orig_room, room_jid, name)
local main_room, main_room_jid = get_main_room(room_jid);
if room_jid == main_room_jid then
return;
end
if orig_room ~= main_room then
module:log('warn', 'Invalid rename breakout room request for %s', orig_room.jid);
return;
end
if main_room then
if main_room._data.breakout_rooms then
main_room._data.breakout_rooms[room_jid] = name;
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(room_jid);
if breakout_room then
breakout_room:set_subject(breakout_room.jid, name);
end
end
main_room:save(true);
broadcast_breakout_rooms(main_room_jid);
end
end
-- Handling events
function on_message(event)
local session = event.origin;
-- Check the type of the incoming stanza to avoid loops:
if event.stanza.attr.type == 'error' then
return; -- We do not want to reply to these, so leave.
end
if not session or not session.jitsi_web_query_room then
return false;
end
local message = event.stanza:get_child(BREAKOUT_ROOMS_IDENTITY_TYPE);
if not message then
return false;
end
-- get room name with tenant and find room
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
module:log('warn', 'No room found for %s/%s',
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return false;
end
-- check that the participant requesting is a moderator and is an occupant in the room
local from = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant then
-- Check if the participant is in any breakout room.
for breakout_room_jid in pairs(room._data.breakout_rooms or {}) do
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
if breakout_room then
occupant = breakout_room:get_occupant_by_real_jid(from);
if occupant then
break;
end
end
end
if not occupant then
module:log('warn', 'No occupant %s found for %s', from, room.jid);
return false;
end
end
if occupant.role ~= 'moderator' then
module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
return false;
end
if message.attr.type == JSON_TYPE_ADD_BREAKOUT_ROOM then
create_breakout_room(room, message.attr.subject);
return true;
elseif message.attr.type == JSON_TYPE_REMOVE_BREAKOUT_ROOM then
destroy_breakout_room(room, message.attr.breakoutRoomJid);
return true;
elseif message.attr.type == JSON_TYPE_RENAME_BREAKOUT_ROOM then
rename_breakout_room(room, message.attr.breakoutRoomJid, message.attr.subject);
return true;
elseif message.attr.type == JSON_TYPE_MOVE_TO_ROOM_REQUEST then
local participant_jid = message.attr.participantJid;
local target_room_jid = message.attr.roomJid;
if not room._data.breakout_rooms or not (
room._data.breakout_rooms[target_room_jid] or target_room_jid == internal_room_jid_match_rewrite(room.jid))
then
module:log('warn', 'Invalid breakout room %s for %s', target_room_jid, room.jid);
return false
end
local json_msg, error = json.encode({
type = BREAKOUT_ROOMS_IDENTITY_TYPE,
event = JSON_TYPE_MOVE_TO_ROOM_REQUEST,
roomJid = target_room_jid
});
if not json_msg then
module:log('error', 'skip sending request room:%s error:%s', room.jid, error);
return false
end
send_json_msg(participant_jid, json_msg)
return true;
end
-- return error.
return false;
end
function on_breakout_room_pre_create(event)
local breakout_room = event.room;
local main_room, main_room_jid = get_main_room(breakout_room.jid);
-- Only allow existent breakout rooms to be started.
-- Authorisation of breakout rooms is done by their random uuid name
if main_room and main_room._data.breakout_rooms and main_room._data.breakout_rooms[breakout_room.jid] then
breakout_room:set_subject(breakout_room.jid, main_room._data.breakout_rooms[breakout_room.jid]);
else
module:log('debug', 'Invalid breakout room %s will not be created.', breakout_room.jid);
breakout_room:destroy(main_room_jid, 'Breakout room is invalid.');
return true;
end
end
function on_occupant_joined(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
local main_room, main_room_jid = get_main_room(room.jid);
if main_room and main_room._data.breakout_rooms_active then
if jid_node(event.occupant.jid) ~= 'focus' then
broadcast_breakout_rooms(main_room_jid);
end
-- Prevent closing all rooms if a participant has joined (see on_occupant_left).
if main_room.close_timer then
main_room.close_timer:stop();
main_room.close_timer = nil;
end
end
end
function exist_occupants_in_room(room)
if not room then
return false;
end
for _, occupant in room:each_occupant() do
if jid_node(occupant.jid) ~= 'focus' then
return true;
end
end
return false;
end
function exist_occupants_in_rooms(main_room)
if exist_occupants_in_room(main_room) then
return true;
end
for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do
local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
if exist_occupants_in_room(room) then
return true;
end
end
return false;
end
function on_occupant_pre_leave(event)
local room, occupant, session, stanza = event.room, event.occupant, event.origin, event.stanza;
local main_room = get_main_room(room.jid);
prosody.events.fire_event('jitsi-breakout-occupant-leaving', {
room = room; main_room = main_room; occupant = occupant; stanza = stanza; session = session;
});
end
function on_occupant_left(event)
local room_jid = event.room.jid;
if is_healthcheck_room(room_jid) then
return;
end
local main_room, main_room_jid = get_main_room(room_jid);
if not main_room then
return;
end
if main_room._data.breakout_rooms_active and jid_node(event.occupant.jid) ~= 'focus' then
broadcast_breakout_rooms(main_room_jid);
end
-- Close the conference if all left for good.
if main_room._data.breakout_rooms_active and not main_room.close_timer and not exist_occupants_in_rooms(main_room) then
main_room.close_timer = module:add_timer(ROOMS_TTL_IF_ALL_LEFT, function()
-- we need to look up again the room as till the timer is fired, the room maybe already destroyed/recreated
-- and we will have the old instance
local main_room, main_room_jid = get_main_room(room_jid);
if main_room and main_room.close_timer then
prosody.events.fire_event("maybe-destroy-room", {
room = main_room;
reason = 'All occupants left.';
caller = module:get_name();
});
end
end);
end
end
-- Stop other modules from destroying room if breakout rooms not empty
function handle_maybe_destroy_main_room(event)
local main_room = event.room;
local caller = event.caller;
if caller == module:get_name() then
-- we were the one that requested the deletion. Do not override.
return nil; -- stop room destruction
end
-- deletion was requested by another module. Check for break room occupants.
for breakout_room_jid, _ in pairs(main_room._data.breakout_rooms or {}) do
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
if breakout_room and breakout_room:has_occupant() then
module:log('info', 'Suppressing room destroy. Breakout room still occupied %s', breakout_room_jid);
return true; -- stop room destruction
end
end
end
module:hook_global("maybe-destroy-room", handle_maybe_destroy_main_room)
function on_main_room_destroyed(event)
local main_room = event.room;
if is_healthcheck_room(main_room.jid) then
return;
end
for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do
destroy_breakout_room(main_room, breakout_room_jid, event.reason)
end
end
-- Module operations
-- operates on already loaded breakout rooms muc module
function process_breakout_rooms_muc_loaded(breakout_rooms_muc, host_module)
module:log('debug', 'Breakout rooms muc loaded');
-- Advertise the breakout rooms component so clients can pick up the address and use it
module:add_identity('component', BREAKOUT_ROOMS_IDENTITY_TYPE, breakout_rooms_muc_component_config);
-- Tag the disco#info response with available features of breakout rooms.
host_module:hook('host-disco-info-node', function (event)
local session, reply, node = event.origin, event.reply, event.node;
if node == BREAKOUT_ROOMS_IDENTITY_TYPE and session.jitsi_web_query_room then
reply:tag('feature', { var = RENAME_FEATURE }):up();
end
event.exists = true;
end);
breakout_rooms_muc_service = breakout_rooms_muc;
module:log("info", "Hook to muc events on %s", breakout_rooms_muc_component_config);
host_module:hook('message/host', on_message);
host_module:hook('muc-occupant-joined', on_occupant_joined);
host_module:hook('muc-occupant-left', on_occupant_left);
host_module:hook('muc-room-pre-create', on_breakout_room_pre_create);
host_module:hook('muc-occupant-pre-leave', on_occupant_pre_leave);
host_module:hook('muc-disco#info', function (event)
local room = event.room;
local main_room, main_room_jid = get_main_room(room.jid);
-- Breakout room metadata.
table.insert(event.form, {
name = 'muc#roominfo_isbreakout';
label = 'Is this a breakout room?';
type = "boolean";
});
event.formdata['muc#roominfo_isbreakout'] = true;
table.insert(event.form, {
name = 'muc#roominfo_breakout_main_room';
label = 'The main room associated with this breakout room';
});
event.formdata['muc#roominfo_breakout_main_room'] = internal_room_jid_match_rewrite(main_room_jid);
-- If the main room has a lobby, make it so this breakout room also uses it.
if (main_room and main_room._data.lobbyroom and main_room:get_members_only()) then
table.insert(event.form, {
name = 'muc#roominfo_lobbyroom';
label = 'Lobby room jid';
});
event.formdata['muc#roominfo_lobbyroom'] = main_room._data.lobbyroom;
end
end);
host_module:hook("muc-config-form", function(event)
local room = event.room;
local _, main_room_jid = get_main_room(room.jid);
-- Breakout room metadata.
table.insert(event.form, {
name = 'muc#roominfo_isbreakout';
label = 'Is this a breakout room?';
type = "boolean";
value = true;
});
table.insert(event.form, {
name = 'muc#roominfo_breakout_main_room';
label = 'The main room associated with this breakout room';
value = internal_room_jid_match_rewrite(main_room_jid);
});
end);
local room_mt = breakout_rooms_muc_service.room_mt;
room_mt.get_members_only = function(room)
local main_room = get_main_room(room.jid);
if not main_room then
module:log('error', 'No main room (%s)!', room.jid);
return false;
end
return main_room.get_members_only(main_room)
end
-- we base affiliations (roles) in breakout rooms muc component to be based on the roles in the main muc
room_mt.get_affiliation = function(room, jid)
local main_room, _ = get_main_room(room.jid);
if not main_room then
module:log('error', 'No main room(%s) for %s!', room.jid, jid);
return 'none';
end
-- moderators in main room are moderators here
local role = main_room.get_affiliation(main_room, jid);
if role then
return role;
end
return 'none';
end
end
-- process or waits to process the breakout rooms muc component
process_host_module(breakout_rooms_muc_component_config, function(host_module, host)
module:log('info', 'Breakout rooms component created %s', host);
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_breakout_rooms_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_breakout_rooms_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
-- operates on already loaded main muc module
function process_main_muc_loaded(main_muc, host_module)
module:log('debug', 'Main muc loaded');
main_muc_service = main_muc;
module:log("info", "Hook to muc events on %s", main_muc_component_config);
host_module:hook('muc-occupant-joined', on_occupant_joined);
host_module:hook('muc-occupant-left', on_occupant_left);
host_module:hook('muc-room-destroyed', on_main_room_destroyed);
end
-- process or waits to process the main muc component
process_host_module(main_muc_component_config, function(host_module, host)
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_main_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);

View File

@@ -0,0 +1,125 @@
local jid = require "util.jid"
local extract_subdomain = module:require "util".extract_subdomain;
-- Options and configuration
local poltergeist_component = module:get_option_string(
"poltergeist_component",
module.host
);
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
if not muc_domain_base then
module:log(
"warn",
"No 'muc_domain_base' option set, unable to send call events."
);
return
end
-- Status strings that trigger call events.
local calling_status = "calling"
local busy_status = "busy"
local rejected_status = "rejected"
local connected_status = "connected"
local expired_status = "expired"
-- url_from_room_jid will determine the url for a conference
-- provided a room jid. It is required that muc domain mapping
-- is enabled and configured. There are two url formats that are supported.
-- The following urls are examples of the supported formats.
-- https://meet.jit.si/jitsi/ProductiveMeeting
-- https://meet.jit.si/MoreProductiveMeeting
-- The urls are derived from portions of the room jid.
local function url_from_room_jid(room_jid)
local node, _, _ = jid.split(room_jid)
if not node then return nil end
local target_subdomain, target_node = extract_subdomain(node);
if not(target_node or target_subdomain) then
return "https://"..muc_domain_base.."/"..node
else
return "https://"..muc_domain_base.."/"..target_subdomain.."/"..target_node
end
end
-- Listening for all muc presences stanza events. If a presence stanza is from
-- a poltergeist then it will be further processed to determine if a call
-- event should be triggered. Call events are triggered by status strings
-- the status strings supported are:
-- -------------------------
-- Status | Event Type
-- _________________________
-- "calling" | INVITE
-- "busy" | CANCEL
-- "rejected" | CANCEL
-- "connected" | CANCEL
module:hook(
"muc-broadcast-presence",
function (event)
-- Detect if the presence is for a poltergeist or not.
-- FIX ME: luacheck warning 581
-- not (x == y)' can be replaced by 'x ~= y' (if neither side is a table or NaN)
if not (jid.bare(event.occupant.jid) == poltergeist_component) then
return
end
-- A presence stanza is needed in order to trigger any calls.
if not event.stanza then
return
end
local call_id = event.stanza:get_child_text("call_id")
if not call_id then
module:log("info", "A call id was not provided in the status.")
return
end
local invite = function()
local url = assert(url_from_room_jid(event.stanza.attr.from))
module:fire_event('jitsi-call-invite', { stanza = event.stanza; url = url; call_id = call_id; });
end
local cancel = function()
local url = assert(url_from_room_jid(event.stanza.attr.from))
local status = event.stanza:get_child_text("status")
module:fire_event('jitsi-call-cancel', {
stanza = event.stanza;
url = url;
reason = string.lower(status);
call_id = call_id;
});
end
-- If for any reason call_cancel is set to true then a cancel
-- is sent regardless of the rest of the presence info.
local should_cancel = event.stanza:get_child_text("call_cancel")
if should_cancel == "true" then
cancel()
return
end
local missed = function()
cancel()
module:fire_event('jitsi-call-missed', { stanza = event.stanza; call_id = call_id; });
end
-- All other call flow actions will require a status.
if event.stanza:get_child_text("status") == nil then
return
end
local switch = function(status)
case = {
[calling_status] = function() invite() end,
[busy_status] = function() cancel() end,
[rejected_status] = function() missed() end,
[expired_status] = function() missed() end,
[connected_status] = function() cancel() end
}
if case[status] then case[status]() end
end
switch(event.stanza:get_child_text("status"))
end,
-101
);

View File

@@ -0,0 +1,106 @@
-- provides an http endpoint at /room-census that reports list of rooms with the
-- number of members and created date in this JSON format:
--
-- {
-- "room_census": [
-- {
-- "room_name": "<muc name>",
-- "participants": <# participants>,
-- "created_time": <unix timestamp>,
-- },
-- ...
-- ]
-- }
--
-- to activate, add "muc_census" to the modules_enabled table in prosody.cfg.lua
--
-- warning: this module is unprotected and intended for server admin use only.
-- when enabled, make sure to secure the endpoint at the web server or via
-- network filters
local jid = require "util.jid";
local json = require 'cjson.safe';
local iterators = require "util.iterators";
local util = module:require "util";
local is_healthcheck_room = util.is_healthcheck_room;
local have_async = pcall(require, "util.async");
if not have_async then
module:log("error", "requires a version of Prosody with util.async");
return;
end
local async_handler_wrapper = module:require "util".async_handler_wrapper;
local tostring = tostring;
-- required parameter for custom muc component prefix, defaults to "conference"
local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference");
local leaked_rooms = 0;
--- handles request to get number of participants in all rooms
-- @return GET response
function handle_get_room_census(event)
local host_session = prosody.hosts[muc_domain_prefix .. "." .. tostring(module.host)]
if not host_session or not host_session.modules.muc then
return { status_code = 400; }
end
room_data = {}
leaked_rooms = 0;
for room in host_session.modules.muc.each_room() do
if not is_healthcheck_room(room.jid) then
local occupants = room._occupants;
local participant_count = 0;
local missing_connections_count = 0;
if occupants then
for _, o in room:each_occupant() do
participant_count = participant_count + 1;
-- let's check whether that occupant has connection in the full_sessions of prosody
-- attempt to detect leaked occupants/rooms.
if prosody.full_sessions[o.jid] == nil then
missing_connections_count = missing_connections_count + 1;
end
end
participant_count = participant_count - 1; -- subtract focus
end
local leaked = false;
if participant_count > 0 and missing_connections_count == participant_count then
leaked = true;
leaked_rooms = leaked_rooms + 1;
end
table.insert(room_data, {
room_name = room.jid;
participants = participant_count;
created_time = room.created_timestamp;
leaked = leaked;
});
end
end
census_resp = json.encode({
room_census = room_data;
});
return { status_code = 200; body = census_resp }
end
function module.load()
module:depends("http");
module:provides("http", {
default_path = "/";
route = {
["GET room-census"] = function (event) return async_handler_wrapper(event,handle_get_room_census) end;
};
});
end
-- we calculate the stats on the configured interval (60 seconds by default)
local measure_leaked_rooms = module:measure('leaked_rooms', 'amount');
module:hook_global('stats-update', function ()
measure_leaked_rooms(leaked_rooms);
end);

View File

@@ -0,0 +1,56 @@
--- This module removes identity information from presence stanzas when the
--- hideDisplayNameForAll or hideDisplayNameForGuests options are enabled
--- for a room.
--- To be enabled under the main muc component
local filters = require 'util.filters';
local st = require 'util.stanza';
local util = module:require 'util';
local filter_identity_from_presence = util.filter_identity_from_presence;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local is_admin = util.is_admin;
local ends_with = util.ends_with;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
-- we need to get the shared resource for joining moderators, as participants are marked as moderators
-- after joining which is after the filter for stanza/out, but we need to know will this participant be a moderator
local joining_moderator_participants = module:shared('moderators/joining_moderator_participants');
--- Filter presence sent to non-moderator members of a room when the hideDisplayNameForGuests option is set.
function filter_stanza_out(stanza, session)
if stanza.name ~= 'presence' or stanza.attr.type == 'error'
or stanza.attr.type == 'unavailable' or ends_with(stanza.attr.from, '/focus') then
return stanza;
end
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
local shouldFilter = false;
if room and (room._data.hideDisplayNameForGuests == true or room._data.hideDisplayNameForAll == true) then
local occupant = room:get_occupant_by_real_jid(stanza.attr.to);
-- don't touch self-presence
if occupant and stanza.attr.from ~= internal_room_jid_match_rewrite(occupant.nick) then
local isModerator = (occupant.role == 'moderator' or joining_moderator_participants[occupant.bare_jid]);
shouldFilter = room._data.hideDisplayNameForAll or not isModerator;
end
end
if shouldFilter then
return filter_identity_from_presence(stanza);
else
return stanza;
end
end
function filter_session(session)
filters.add_filter(session, 'stanzas/out', filter_stanza_out, -100);
end
function module.load()
filters.add_filter_hook(filter_session);
end
function module.unload()
filters.remove_filter_hook(filter_session);
end

View File

@@ -0,0 +1,107 @@
-- Maps MUC JIDs like room1@muc.foo.example.com to JIDs like [foo]room1@muc.example.com
-- Must be loaded on the client host in Prosody
-- It is recommended to set muc_mapper_domain_base to the main domain being served (example.com)
local filters = require "util.filters";
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
if not muc_domain_base then
module:log("warn", "No 'muc_mapper_domain_base' option set, disabling muc_mapper plugin inactive");
return
end
local log_not_allowed_errors = module:get_option_boolean('muc_mapper_log_not_allowed_errors', false);
local util = module:require "util";
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
-- We must filter stanzas in order to hook in to all incoming and outgoing messaging which skips the stanza routers
function filter_stanza(stanza, session)
if stanza.skipMapping then
return stanza;
end
if stanza.name == "message" or stanza.name == "iq" or stanza.name == "presence" then
-- module:log("debug", "Filtering stanza type %s to %s from %s",stanza.name,stanza.attr.to,stanza.attr.from);
if stanza.name == "iq" then
local conf = stanza:get_child('conference')
if conf then
-- module:log("debug", "Filtering stanza conference %s to %s from %s",conf.attr.room,stanza.attr.to,stanza.attr.from);
conf.attr.room = room_jid_match_rewrite(conf.attr.room, stanza)
end
end
if stanza.attr.to then
stanza.attr.to = room_jid_match_rewrite(stanza.attr.to, stanza)
end
if stanza.attr.from then
stanza.attr.from = internal_room_jid_match_rewrite(stanza.attr.from, stanza)
end
if log_not_allowed_errors and stanza.name == 'presence' and stanza.attr.type == 'error' then
local error = stanza:get_child('error');
if error and error.attr.type == 'cancel'
and error:get_child('not-allowed', 'urn:ietf:params:xml:ns:xmpp-stanzas')
and not session.jitsi_not_allowed_logged then
session.jitsi_not_allowed_logged = true;
session.log('error', 'Not allowed presence %s', stanza);
end
end
end
return stanza;
end
function filter_session(session)
-- module:log("warn", "Session filters applied");
filters.add_filter(session, "stanzas/out", filter_stanza);
end
function module.load()
if module.reloading then
module:log("debug", "Reloading MUC mapper!");
else
module:log("debug", "First load of MUC mapper!");
end
filters.add_filter_hook(filter_session);
end
function module.unload()
filters.remove_filter_hook(filter_session);
end
local function outgoing_stanza_rewriter(event)
local stanza = event.stanza;
if stanza.attr.to then
stanza.attr.to = room_jid_match_rewrite(stanza.attr.to, stanza)
end
end
local function incoming_stanza_rewriter(event)
local stanza = event.stanza;
if stanza.attr.from then
stanza.attr.from = internal_room_jid_match_rewrite(stanza.attr.from, stanza)
end
end
-- The stanza rewriters helper functions are attached for all stanza router hooks
local function hook_all_stanzas(handler, host_module, event_prefix)
for _, stanza_type in ipairs({ "message", "presence", "iq" }) do
for _, jid_type in ipairs({ "host", "bare", "full" }) do
host_module:hook((event_prefix or "")..stanza_type.."/"..jid_type, handler);
end
end
end
function add_host(host)
module:log("info", "Loading mod_muc_domain_mapper for host %s!", host);
local host_module = module:context(host);
hook_all_stanzas(incoming_stanza_rewriter, host_module);
hook_all_stanzas(outgoing_stanza_rewriter, host_module, "pre-");
end
prosody.events.add_handler("host-activated", add_host);
for host in pairs(prosody.hosts) do
add_host(host);
end

View File

@@ -0,0 +1,113 @@
-- A global module which can be used as http endpoint to end meetings. The provided token
--- in the request is verified whether it has the right to do so.
-- Copyright (C) 2023-present 8x8, Inc.
module:set_global();
local util = module:require "util";
local async_handler_wrapper = util.async_handler_wrapper;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local get_room_from_jid = util.get_room_from_jid;
local starts_with = util.starts_with;
local neturl = require "net.url";
local parse = neturl.parseQuery;
-- will be initialized once the main virtual host module is initialized
local token_util;
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
local asapKeyServer = module:get_option_string("prosody_password_public_key_repo_url", "");
local event_count = module:measure("muc_end_meeting_rate", "rate")
local event_count_success = module:measure("muc_end_meeting_success", "rate")
function verify_token(token)
if token == nil then
module:log("warn", "no token provided");
return false;
end
local session = {};
session.auth_token = token;
local verified, reason, msg = token_util:process_and_verify_token(session);
if not verified then
module:log("warn", "not a valid token %s %s", tostring(reason), tostring(msg));
return false;
end
return true;
end
function handle_terminate_meeting (event)
module:log("info", "Request for terminate meeting received: reqid %s", event.request.headers["request_id"])
event_count()
if not event.request.url.query then
return { status_code = 400 };
end
local params = parse(event.request.url.query);
local conference = params["conference"];
local room_jid;
if conference then
room_jid = room_jid_match_rewrite(conference)
else
module:log('warn', "conference param was not provided")
return { status_code = 400 };
end
-- verify access
local token = event.request.headers["authorization"]
if not token then
module:log("error", "Authorization header was not provided for conference %s", conference)
return { status_code = 401 };
end
if starts_with(token, 'Bearer ') then
token = token:sub(8, #token)
else
module:log("error", "Authorization header is invalid")
return { status_code = 401 };
end
if not verify_token(token, room_jid) then
return { status_code = 401 };
end
local room = get_room_from_jid(room_jid);
if not room then
module:log("warn", "Room not found")
return { status_code = 404 };
else
module:log("info", "Destroy room jid %s", room.jid)
room:destroy(nil, "The meeting has been terminated")
end
event_count_success()
return { status_code = 200 };
end
-- module API called on virtual host added, passing the host module
function module.add_host(host_module)
if host_module.host == muc_domain_base then
-- the main virtual host
module:log("info", "Initialize token_util using %s", host_module.host)
token_util = module:require "token/util".new(host_module);
if asapKeyServer then
-- init token util with our asap keyserver
token_util:set_asap_key_server(asapKeyServer)
end
module:log("info", "Adding http handler for /end-meeting on %s", host_module.host);
host_module:depends("http");
host_module:provides("http", {
default_path = "/";
route = {
["POST end-meeting"] = function(event)
return async_handler_wrapper(event, handle_terminate_meeting)
end;
};
});
end
end

View File

@@ -0,0 +1,27 @@
-- Restricts access to a muc component to certain domains
-- Copyright (C) 2023-present 8x8, Inc.
-- a list of (authenticated)domains that can access rooms(send presence)
local whitelist = module:get_option_set("muc_filter_whitelist");
if not whitelist then
module:log("warn", "No 'muc_filter_whitelist' option set, disabling muc_filter_access, plugin inactive");
return
end
local jid_split = require "util.jid".split;
local function incoming_presence_filter(event)
local stanza = event.stanza;
local _, domain, _ = jid_split(stanza.attr.from);
if not stanza.attr.from or not whitelist:contains(domain) then
-- Filter presence
module:log("error", "Filtering unauthorized presence: %s", stanza:top_tag());
return true;
end
end
for _, jid_type in ipairs({ "host", "bare", "full" }) do
module:hook("presence/"..jid_type, incoming_presence_filter, 2000);
end

View File

@@ -0,0 +1,207 @@
-- Allows flipping device. When a presence contains flip_device tag
-- and the used jwt matches the id(session.jitsi_meet_context_user.id) of another user this is indication that the user
-- is moving from one device to another. The flip feature should be present and enabled in the token features.
-- Copyright (C) 2023-present 8x8, Inc.
local oss_util = module:require "util";
local is_admin = oss_util.is_admin;
local is_healthcheck_room = oss_util.is_healthcheck_room;
local process_host_module = oss_util.process_host_module;
local inspect = require('inspect');
local jid_bare = require "util.jid".bare;
local jid = require "util.jid";
local MUC_NS = "http://jabber.org/protocol/muc";
local lobby_host;
local lobby_muc_service;
local lobby_muc_component_config = 'lobby.' .. module:get_option_string("muc_mapper_domain_base");
if lobby_muc_component_config == nil then
module:log('error', 'lobby not enabled missing lobby_muc config');
return ;
end
local function remove_flip_tag(stanza)
stanza:maptags(function(tag)
if tag and tag.name == "flip_device" then
-- module:log("debug", "Removing %s tag from presence stanza!", tag.name);
return nil;
else
return tag;
end
end)
end
-- Make user that switch devices bypass lobby or password.
-- A user is considered to join from another device if the
-- id from jwt is the same as another occupant and the presence
-- stanza has flip_device tag
module:hook("muc-occupant-pre-join", function(event)
local room, occupant = event.room, event.occupant;
local session = event.origin;
local stanza = event.stanza;
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
return ;
end
local flip_device_tag = stanza:get_child("flip_device");
if session.jitsi_meet_context_user and session.jitsi_meet_context_user.id then
local participants = room._data.participants_details or {};
local id = session.jitsi_meet_context_user.id;
local first_device_occ_nick = participants[id];
if flip_device_tag then
if first_device_occ_nick and session.jitsi_meet_context_features.flip and (session.jitsi_meet_context_features.flip == true or session.jitsi_meet_context_features.flip == "true") then
room._data.kicked_participant_nick = first_device_occ_nick;
room._data.flip_participant_nick = occupant.nick;
-- allow participant from flip device to bypass Lobby
local occupant_jid = stanza.attr.from;
local affiliation = room:get_affiliation(occupant_jid);
if not affiliation or affiliation == 'none' or affiliation == 'member' then
-- module:log("debug", "Bypass lobby invitee %s", occupant_jid)
occupant.role = "participant";
room:set_affiliation(true, jid_bare(occupant_jid), "member")
room:save_occupant(occupant);
end
if room:get_password() then
-- bypass password on the flip device
local join = stanza:get_child("x", MUC_NS);
if not join then
join = stanza:tag("x", { xmlns = MUC_NS });
end
local password = join:get_child("password", MUC_NS);
if password then
join:maptags(
function(tag)
for k, v in pairs(tag) do
if k == "name" and v == "password" then
return nil
end
end
return tag
end);
end
join:tag("password", { xmlns = MUC_NS }):text(room:get_password());
end
elseif not session.jitsi_meet_context_features.flip or session.jitsi_meet_context_features.flip == false or session.jitsi_meet_context_features.flip == "false" then
module:log("warn", "Flip device tag present without jwt permission")
--remove flip_device tag if somebody wants to abuse this feature
remove_flip_tag(stanza)
else
module:log("warn", "Flip device tag present without user from different device")
--remove flip_device tag if somebody wants to abuse this feature
remove_flip_tag(stanza)
end
end
-- update authenticated participant list
participants[id] = occupant.nick;
room._data.participants_details = participants
-- module:log("debug", "current details list %s", inspect(participants))
else
if flip_device_tag then
module:log("warn", "Flip device tag present for a guest user")
-- remove flip_device tag because a guest want to do a sneaky join
remove_flip_tag(stanza)
end
end
end)
-- Kick participant from the the first device from the main room and lobby if applies
-- and transfer role from the previous participant, this will take care of the grant
-- moderation case
module:hook("muc-occupant-joined", function(event)
local room, occupant = event.room, event.occupant;
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
return;
end
if room._data.flip_participant_nick and occupant.nick == room._data.flip_participant_nick then
-- make joining participant from flip device have the same role and affiliation as for the previous device
local kicked_occupant = room:get_occupant_by_nick(room._data.kicked_participant_nick);
if not kicked_occupant then
module:log("info", "Kick participant not found, nick %s from main room jid %s",
room._data.kicked_participant_nick, room.jid)
return;
end
local initial_affiliation = room:get_affiliation(kicked_occupant.jid) or "member";
-- module:log("debug", "Transfer affiliation %s to occupant jid %s", initial_affiliation, occupant.jid)
room:set_affiliation(true, occupant.bare_jid, initial_affiliation)
if initial_affiliation == "owner" then
event.occupant.role = "moderator";
elseif initial_affiliation == "member" then
event.occupant.role = "participant";
end
-- Kick participant from the first device from the main room
local kicked_participant_node_jid = jid.split(kicked_occupant.jid);
module:log("info", "Kick participant jid %s nick %s from main room jid %s", kicked_occupant.jid, room._data.kicked_participant_nick, room.jid)
room:set_role(true, room._data.kicked_participant_nick, 'none')
room:save_occupant(occupant);
-- Kick participant from the first device from the lobby room
if room._data.lobbyroom then
local lobby_room_jid = room._data.lobbyroom;
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid)
for _, occupant in lobby_room:each_occupant() do
local node = jid.split(occupant.jid);
if kicked_participant_node_jid == node then
module:log("info", "Kick participant from lobby %s", occupant.jid)
lobby_room:set_role(true, occupant.nick, 'none')
end
end
end
event.room._data.flip_participant_nick = nil
event.room._data.kicked_participant_nick = nil;
end
end,-2)
-- Update the local table after a participant leaves
module:hook("muc-occupant-left", function(event)
local occupant = event.occupant;
local session = event.origin;
if is_healthcheck_room(event.room.jid) or is_admin(occupant.bare_jid) then
return ;
end
if session and session.jitsi_meet_context_user and session.jitsi_meet_context_user.id then
local id = session.jitsi_meet_context_user.id
local participants = event.room._data.participants_details or {};
local occupant_left_nick = participants[id]
if occupant_left_nick == occupant.nick then
participants[id] = nil
event.room._data.participants_details = participants
end
end
end)
-- Add a flip_device tag on the unavailable presence from the kicked participant in order to silent the notifications
module:hook('muc-broadcast-presence', function(event)
local kicked_participant_nick = event.room._data.kicked_participant_nick
local stanza = event.stanza;
if kicked_participant_nick and stanza.attr.from == kicked_participant_nick and stanza.attr.type == 'unavailable' then
-- module:log("debug", "Add flip_device tag for presence unavailable from occupant nick %s", kicked_participant_nick)
stanza:tag("flip_device"):up();
end
end)
function process_lobby_muc_loaded(lobby_muc, host_module)
module:log('info', 'Lobby muc loaded');
lobby_muc_service = lobby_muc;
lobby_host = module:context(host_module);
end
-- process or waits to process the lobby muc component
process_host_module(lobby_muc_component_config, function(host_module, host)
-- lobby muc component created
module:log('info', 'Lobby component loaded %s', host);
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_lobby_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_lobby_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);

View File

@@ -0,0 +1,30 @@
-- This module makes all MUCs in Prosody unavailable on disco#items query
-- Copyright (C) 2023-present 8x8, Inc.
local jid = require 'util.jid';
local st = require 'util.stanza';
local util = module:require 'util';
local get_room_from_jid = util.get_room_from_jid;
module:hook('muc-room-pre-create', function(event)
event.room:set_hidden(true);
end, -1);
for _, event_name in pairs {
'iq-get/bare/http://jabber.org/protocol/disco#info:query';
'iq-get/host/http://jabber.org/protocol/disco#info:query';
} do
module:hook(event_name, function (event)
local origin, stanza = event.origin, event.stanza;
local room_jid = jid.bare(stanza.attr.to);
local room = get_room_from_jid(room_jid);
if room then
if not room:get_occupant_by_real_jid(stanza.attr.from) then
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
end
end
-- prosody will send item-not-found
end, 1) -- make sure we handle it before prosody that uses priority -2 for this
end

View File

@@ -0,0 +1,191 @@
-- A http endpoint to invite jigasi to a meeting via http endpoint
-- jwt is used to validate access
-- Copyright (C) 2023-present 8x8, Inc.
local jid_split = require "util.jid".split;
local hashes = require "util.hashes";
local random = require "util.random";
local st = require("util.stanza");
local json = require 'cjson.safe';
local util = module:require "util";
local async_handler_wrapper = util.async_handler_wrapper;
local process_host_module = util.process_host_module;
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
-- This module chooses jigasi from the brewery room, so it needs information for the configured brewery
local muc_domain = module:get_option_string("muc_internal_domain_base", 'internal.auth.' .. muc_domain_base);
local jigasi_brewery_room_jid = module:get_option_string("muc_jigasi_brewery_jid", 'jigasibrewery@' .. muc_domain);
local jigasi_bare_jid = module:get_option_string("muc_jigasi_jid", "jigasi@auth." .. muc_domain_base);
local focus_jid = module:get_option_string("muc_jicofo_brewery_jid", jigasi_brewery_room_jid .. "/focus");
local main_muc_service;
local JSON_CONTENT_TYPE = "application/json";
local event_count = module:measure("muc_invite_jigasi_rate", "rate")
local event_count_success = module:measure("muc_invite_jigasi_success", "rate")
local ASAP_KEY_SERVER = module:get_option_string("prosody_password_public_key_repo_url", "");
local token_util = module:require "token/util".new(module);
if ASAP_KEY_SERVER then
-- init token util with our asap keyserver
token_util:set_asap_key_server(ASAP_KEY_SERVER)
end
local function invite_jigasi(conference, phone_no)
local jigasi_brewery_room = main_muc_service.get_room_from_jid(jigasi_brewery_room_jid);
if not jigasi_brewery_room then
module:log("error", "Jigasi brewery room not found")
return 404, 'Brewery room was not found'
end
module:log("info", "Invite jigasi from %s to join conference %s and outbound phone_no %s", jigasi_brewery_room.jid, conference, phone_no)
--select least stressed Jigasi
local least_stressed_value = math.huge;
local least_stressed_jigasi_jid;
for occupant_jid, occupant in jigasi_brewery_room:each_occupant() do
local _, _, resource = jid_split(occupant_jid);
if resource ~= 'focus' then
local occ = occupant:get_presence();
local stats_child = occ:get_child("stats", "http://jitsi.org/protocol/colibri")
local is_sip_jigasi = true;
for stats_tag in stats_child:children() do
if stats_tag.attr.name == 'supports_sip' and stats_tag.attr.value == 'false' then
is_sip_jigasi = false;
end
end
if is_sip_jigasi then
for stats_tag in stats_child:children() do
if stats_tag.attr.name == 'stress_level' then
local stress_level = tonumber(stats_tag.attr.value);
module:log("debug", "Stressed level %s %s ", stress_level, occupant_jid)
if stress_level < least_stressed_value then
least_stressed_jigasi_jid = occupant_jid
least_stressed_value = stress_level
end
end
end
end
end
end
module:log("debug", "Least stressed jigasi selected jid %s value %s", least_stressed_jigasi_jid, least_stressed_value)
if not least_stressed_jigasi_jid then
module:log("error", "Cannot invite jigasi from room %s", jigasi_brewery_room.jid)
return 404, 'Jigasi not found'
end
-- invite Jigasi to join the conference
local _, _, jigasi_res = jid_split(least_stressed_jigasi_jid)
local jigasi_full_jid = jigasi_bare_jid .. "/" .. jigasi_res;
local stanza_id = hashes.sha256(random.bytes(8), true);
local invite_jigasi_stanza = st.iq({ xmlns = "jabber:client", type = "set", to = jigasi_full_jid, from = focus_jid, id = stanza_id })
:tag("dial", { xmlns = "urn:xmpp:rayo:1", from = "fromnumber", to = phone_no })
:tag("header", { xmlns = "urn:xmpp:rayo:1", name = "JvbRoomName", value = conference })
module:log("debug", "Invite jigasi stanza %s", invite_jigasi_stanza)
jigasi_brewery_room:route_stanza(invite_jigasi_stanza);
return 200
end
local function is_token_valid(token)
if token == nil then
module:log("warn", "no token provided");
return false;
end
local session = {};
session.auth_token = token;
local verified, reason, msg = token_util:process_and_verify_token(session);
if not verified then
module:log("warn", "not a valid token %s %s", tostring(reason), tostring(msg));
return false;
end
return true;
end
local function handle_jigasi_invite(event)
module:log("debug", "Request for invite jigasi received: reqId %s", event.request.headers["request_id"])
event_count()
local request = event.request;
-- verify access
local token = event.request.headers["authorization"]
if not token then
module:log("error", "Authorization header was not provided for conference %s", conference)
return { status_code = 401 };
end
if util.starts_with(token, 'Bearer ') then
token = token:sub(8, #token)
else
module:log("error", "Authorization header is invalid")
return { status_code = 401 };
end
if not is_token_valid(token) then
return { status_code = 401 };
end
-- verify payload
if request.headers.content_type ~= JSON_CONTENT_TYPE
or (not request.body or #request.body == 0) then
module:log("warn", "Wrong content type: %s or missing payload", request.headers.content_type);
return { status_code = 400; }
end
local payload, error = json.decode(request.body);
if not payload then
module:log('error', 'Cannot decode json error:%s', error);
return { status_code = 400; }
end
local conference = payload["conference"];
local phone_no = payload["phoneNo"];
if not conference then
module:log("warn", "Missing conference param")
return { status_code = 400; }
end
if not phone_no then
module:log("warn", "Missing phone no param")
return { status_code = 400; }
end
--invite jigasi
local status_code, error_msg = invite_jigasi(conference, phone_no)
if not error_msg then
event_count_success()
return { status_code = 200 }
else
return { status_code = status_code, body = json.encode({ error = error_msg }) }
end
end
module:log("info", "Adding http handler for /invite-jigasi on %s", module.host);
module:depends("http");
module:provides("http", {
default_path = "/";
route = {
["POST invite-jigasi"] = function(event)
return async_handler_wrapper(event, handle_jigasi_invite)
end;
};
});
process_host_module(muc_domain, function(_, host)
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
main_muc_service = muc_module;
module:log('info', 'Found main_muc_service: %s', main_muc_service);
else
module:log('info', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
main_muc_service = prosody.hosts[host].modules.muc;
module:log('info', 'Found(on loaded) main_muc_service: %s', main_muc_service);
end
end);
end
end);

View File

@@ -0,0 +1,171 @@
-- http endpoint to kick participants, access is based on provided jwt token
-- the correct jigasi we fined based on the display name and the number provided
-- Copyright (C) 2023-present 8x8, Inc.
local util = module:require "util";
local async_handler_wrapper = util.async_handler_wrapper;
local is_sip_jigasi = util.is_sip_jigasi;
local starts_with = util.starts_with;
local formdecode = require "util.http".formdecode;
local urlencode = require "util.http".urlencode;
local jid = require "util.jid";
local json = require 'cjson.safe';
local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference");
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
if not muc_domain_base then
module:log("warn", "No 'muc_domain_base' option set, disabling kick check endpoint.");
return ;
end
local json_content_type = "application/json";
local token_util = module:require "token/util".new(module);
local asapKeyServer = module:get_option_string('prosody_password_public_key_repo_url', '');
if asapKeyServer == '' then
module:log('warn', 'No "prosody_password_public_key_repo_url" option set, disabling kick endpoint.');
return ;
end
token_util:set_asap_key_server(asapKeyServer);
--- Verifies the token
-- @param token the token we received
-- @param room_address the full room address jid
-- @return true if values are ok or false otherwise
function verify_token(token, room_address)
if token == nil then
module:log("warn", "no token provided for %s", room_address);
return false;
end
local session = {};
session.auth_token = token;
local verified, reason, msg = token_util:process_and_verify_token(session);
if not verified then
module:log("warn", "not a valid token %s %s for %s", tostring(reason), tostring(msg), room_address);
return false;
end
return true;
end
-- Validates the request by checking for required url param room and
-- validates the token provided with the request
-- @param request - The request to validate.
-- @return [error_code, room]
local function validate_and_get_room(request)
if not request.url.query then
module:log("warn", "No query");
return 400, nil;
end
local params = formdecode(request.url.query);
local room_name = urlencode(params.room) or "";
local subdomain = urlencode(params.prefix) or "";
if not room_name then
module:log("warn", "Missing room param for %s", room_name);
return 400, nil;
end
local room_address = jid.join(room_name, muc_domain_prefix.."."..muc_domain_base);
if subdomain and subdomain ~= "" then
room_address = "["..subdomain.."]"..room_address;
end
-- verify access
local token = request.headers["authorization"]
if token and starts_with(token,'Bearer ') then
token = token:sub(8,#token)
end
if not verify_token(token, room_address) then
return 403, nil;
end
local room = get_room_from_jid(room_address);
if not room then
module:log("warn", "No room found for %s", room_address);
return 404, nil;
else
return 200, room;
end
end
function handle_kick_participant (event)
local request = event.request;
if request.headers.content_type ~= json_content_type
or (not request.body or #request.body == 0) then
module:log("warn", "Wrong content type: %s", request.headers.content_type);
return { status_code = 400; }
end
local params, error = json.decode(request.body);
if not params then
module:log("warn", "Missing params error:%s", error);
return { status_code = 400; }
end
local number = params["number"];
local participantId = params["participantId"];
if (not number and not participantId) or (number and participantId) then
module:log("warn", "Invalid parameters: exactly one of 'number' or 'participantId' must be provided.");
return { status_code = 400; };
end
local error_code, room = validate_and_get_room(request);
if error_code and error_code ~= 200 then
module:log("error", "Error validating %s", error_code);
return { error_code = 400; }
end
if not room then
return { status_code = 404; }
end
for _, occupant in room:each_occupant() do
local pr = occupant:get_presence();
if is_participant_match(pr, number, participantId) then
room:set_role(true, occupant.nick, nil);
module:log('info', 'Occupant kicked %s from %s', occupant.nick, room.jid);
return { status_code = 200; }
end
end
-- not found participant to kick
return { status_code = 404; };
end
function is_participant_match(pr, number, participantId)
if number then
local displayName = pr:get_child_text('nick', 'http://jabber.org/protocol/nick');
return is_sip_jigasi(pr) and displayName and starts_with(displayName, number);
elseif participantId then
local from = pr.attr.from;
local _, _, from_resource = jid.split(from);
if from_resource then
return from_resource == participantId;
end
end
return false;
end
module:log("info","Adding http handler for /kick-participant on %s", module.host);
module:depends("http");
module:provides("http", {
default_path = "/";
route = {
["PUT kick-participant"] = function (event) return async_handler_wrapper(event, handle_kick_participant) end;
};
});

View File

@@ -0,0 +1,103 @@
-- A module to limit the number of messages in a meeting
-- Needs to be activated under the muc component where the limit needs to be applied
-- Copyright (C) 2023-present 8x8, Inc.
local id = require 'util.id';
local st = require 'util.stanza';
local get_room_by_name_and_subdomain = module:require 'util'.get_room_by_name_and_subdomain;
local count;
local check_token;
local function load_config()
count = module:get_option_number('muc_limit_messages_count');
check_token = module:get_option_boolean('muc_limit_messages_check_token', false);
end
load_config();
if not count then
module:log('warn', "No 'muc_limit_messages_count' option set, disabling module");
return
end
module:log('info', 'Loaded muc limits for %s, limit:%s, will check for authenticated users:%s',
module.host, count, check_token);
local error_text = 'The message limit for the room has been reached. Messaging is now disabled.';
function on_message(event)
local stanza = event.stanza;
local body = stanza:get_child('body');
-- we ignore any non groupchat message without a body
if not body then
if stanza.attr.type ~= 'groupchat' then -- lobby messages
return;
else
-- we want to pass through only polls answers
local json_data = stanza:get_child_text('json-message', 'http://jitsi.org/jitmeet');
if json_data and string.find(json_data, 'answer-poll', 1, true) then
return;
end
end
end
local session = event.origin;
if not session or not session.jitsi_web_query_room then
-- if this is a message from visitor, pass it through. Limits are applied in the visitor node.
if event.origin.type == 's2sin' then
return;
end
return false;
end
-- get room name with tenant and find room
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
module:log('warn', 'No room found found for %s/%s',
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return false;
end
if check_token and session.auth_token then
-- there is an authenticated participant drop all limits
room._muc_messages_limit = false;
end
if room._muc_messages_limit == false then
-- no limits for this room, just skip
return;
end
if not room._muc_messages_limit_count then
room._muc_messages_limit_count = 0;
end
room._muc_messages_limit_count = room._muc_messages_limit_count + 1;
-- on the first message above the limit we set the limit and we send an announcement to the room
if room._muc_messages_limit_count == count + 1 then
module:log('warn', 'Room message limit reached: %s', room.jid);
-- send a message to the room
local announcement = st.message({ from = room.jid, type = 'groupchat', id = id.medium(), })
:tag('body'):text(error_text);
room:broadcast_message(announcement);
room._muc_messages_limit = true;
end
if room._muc_messages_limit == true then
-- return error to the sender of this message
event.origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', error_text));
return true;
end
end
-- handle messages sent in the component
-- 'message/host' is used for breakout rooms
module:hook('message/full', on_message); -- private messages
module:hook('message/bare', on_message); -- room messages
module:hook_global('config-reloaded', load_config);

View File

@@ -0,0 +1,664 @@
-- This module added under the main virtual host domain
-- It needs a lobby muc component
--
-- VirtualHost "jitmeet.example.com"
-- modules_enabled = {
-- "muc_lobby_rooms"
-- }
-- lobby_muc = "lobby.jitmeet.example.com"
-- main_muc = "conference.jitmeet.example.com"
--
-- Component "lobby.jitmeet.example.com" "muc"
-- storage = "memory"
-- muc_room_cache_size = 1000
-- restrict_room_creation = true
-- muc_room_locking = false
-- muc_room_default_public_jids = true
--
-- we use async to detect Prosody 0.10 and earlier
local have_async = pcall(require, 'util.async');
if not have_async then
module:log('warn', 'Lobby rooms will not work with Prosody version 0.10 or less.');
return;
end
module:depends("jitsi_session");
local jid_split = require 'util.jid'.split;
local jid_bare = require 'util.jid'.bare;
local jid_prep = require "util.jid".prep;
local jid_resource = require "util.jid".resource;
local resourceprep = require "util.encodings".stringprep.resourceprep;
local json = require 'cjson.safe';
local filters = require 'util.filters';
local st = require 'util.stanza';
local muc_util = module:require "muc/util";
local valid_affiliations = muc_util.valid_affiliations;
local MUC_NS = 'http://jabber.org/protocol/muc';
local MUC_USER_NS = 'http://jabber.org/protocol/muc#user';
local DISCO_INFO_NS = 'http://jabber.org/protocol/disco#info';
local DISPLAY_NAME_REQUIRED_FEATURE = 'http://jitsi.org/protocol/lobbyrooms#displayname_required';
local LOBBY_IDENTITY_TYPE = 'lobbyrooms';
local NOTIFY_JSON_MESSAGE_TYPE = 'lobby-notify';
local NOTIFY_LOBBY_ENABLED = 'LOBBY-ENABLED';
local NOTIFY_LOBBY_ACCESS_GRANTED = 'LOBBY-ACCESS-GRANTED';
local NOTIFY_LOBBY_ACCESS_DENIED = 'LOBBY-ACCESS-DENIED';
local util = module:require "util";
local ends_with = util.ends_with;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local is_healthcheck_room = util.is_healthcheck_room;
local presence_check_status = util.presence_check_status;
local process_host_module = util.process_host_module;
local main_muc_component_config = module:get_option_string('main_muc');
if main_muc_component_config == nil then
module:log('error', 'lobby not enabled missing main_muc config');
return ;
end
local lobby_muc_component_config = module:get_option_string('lobby_muc');
if lobby_muc_component_config == nil then
module:log('error', 'lobby not enabled missing lobby_muc config');
return ;
end
local whitelist;
local check_display_name_required;
local function load_config()
whitelist = module:get_option_set('muc_lobby_whitelist', {});
check_display_name_required
= module:get_option_boolean('muc_lobby_check_display_name_required', true);
end
load_config();
local lobby_muc_service;
local main_muc_service;
function broadcast_json_msg(room, from, json_msg)
json_msg.type = NOTIFY_JSON_MESSAGE_TYPE;
local occupant = room:get_occupant_by_real_jid(from);
if occupant then
local json_msg_str, error = json.encode(json_msg);
if not json_msg_str then
module:log('error', 'Error broadcasting message room:%s', room.jid, error);
return;
end
room:broadcast_message(
st.message({ type = 'groupchat', from = occupant.nick })
:tag('json-message', {xmlns='http://jitsi.org/jitmeet'})
:text(json_msg_str):up());
end
end
-- Sends a json message notifying for lobby enabled/disable
-- the message from is the actor that did the operation
function notify_lobby_enabled(room, actor, value)
broadcast_json_msg(room, actor, {
event = NOTIFY_LOBBY_ENABLED,
value = value
});
end
-- Sends a json message notifying that the jid was granted/denied access in lobby
-- the message from is the actor that did the operation
function notify_lobby_access(room, actor, jid, display_name, granted)
local notify_json = {
value = jid,
name = display_name
};
if granted then
notify_json.event = NOTIFY_LOBBY_ACCESS_GRANTED;
else
notify_json.event = NOTIFY_LOBBY_ACCESS_DENIED;
end
broadcast_json_msg(room, actor, notify_json);
end
function filter_stanza(stanza)
if not stanza.attr or not stanza.attr.from or not main_muc_service or not lobby_muc_service then
return stanza;
end
-- Allow self-presence (code=110)
local node, from_domain = jid_split(stanza.attr.from);
if from_domain == lobby_muc_component_config then
if stanza.name == 'presence' then
local muc_x = stanza:get_child('x', MUC_NS..'#user');
if not muc_x or presence_check_status(muc_x, '110') then
return stanza;
end
local lobby_room_jid = jid_bare(stanza.attr.from);
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid);
if not lobby_room then
module:log('warn', 'No lobby room found %s', lobby_room_jid);
return stanza;
end
-- check is an owner, only owners can receive the presence
-- do not forward presence of owners (other than unavailable)
local room = main_muc_service.get_room_from_jid(jid_bare(node .. '@' .. main_muc_component_config));
local item = muc_x:get_child('item');
if not room
or stanza.attr.type == 'unavailable'
or (room.get_affiliation(room, stanza.attr.to) == 'owner'
and room.get_affiliation(room, item.attr.jid) ~= 'owner') then
return stanza;
end
local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner';
local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from);
if not from_occupant then
if is_to_moderator then
return stanza;
end
module:log('warn', 'No lobby occupant found %s', stanza.attr.from);
return nil;
end
local from_real_jid;
for real_jid in from_occupant:each_session() do
from_real_jid = real_jid;
end
if is_to_moderator and lobby_room:get_affiliation(from_real_jid) ~= 'owner' then
return stanza;
end
elseif stanza.name == 'iq' and stanza:get_child('query', DISCO_INFO_NS) then
-- allow disco info from the lobby component
return stanza;
elseif stanza.name == 'message' then
-- allow messages to or from moderator
local lobby_room_jid = jid_bare(stanza.attr.from);
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid);
if not lobby_room then
module:log('warn', 'No lobby room found %s', stanza.attr.from);
return nil;
end
local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner';
local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from);
local from_real_jid;
if from_occupant then
for real_jid in from_occupant:each_session() do
from_real_jid = real_jid;
end
end
if not from_real_jid then
return nil;
end
local is_from_moderator = lobby_room:get_affiliation(from_real_jid) == 'owner';
if is_to_moderator or is_from_moderator then
return stanza;
end
return nil;
end
return nil;
else
return stanza;
end
end
function filter_session(session)
-- domain mapper is filtering on default priority 0, and we need it after that
filters.add_filter(session, 'stanzas/out', filter_stanza, -1);
end
-- actor can be null if called from backend (another module using hook create-lobby-room)
function attach_lobby_room(room, actor)
local node = jid_split(room.jid);
local lobby_room_jid = node .. '@' .. lobby_muc_component_config;
if not lobby_muc_service.get_room_from_jid(lobby_room_jid) then
local new_room = lobby_muc_service.create_room(lobby_room_jid);
-- set persistent the lobby room to avoid it to be destroyed
-- there are cases like when selecting new moderator after the current one leaves
-- which can leave the room with no occupants and it will be destroyed and we want to
-- avoid lobby destroy while it is enabled
new_room:set_persistent(true);
module:log("info","Lobby room jid = %s created from:%s", lobby_room_jid, actor);
new_room.main_room = room;
room._data.lobbyroom = new_room.jid;
room:save(true);
return true
end
return false
end
-- destroys lobby room for the supplied main room
function destroy_lobby_room(room, newjid, message)
if not message then
message = 'Lobby room closed.';
end
if lobby_muc_service and room and room._data.lobbyroom then
local lobby_room_obj = lobby_muc_service.get_room_from_jid(room._data.lobbyroom);
if lobby_room_obj then
lobby_room_obj:set_persistent(false);
lobby_room_obj:destroy(newjid, message);
end
room._data.lobbyroom = nil;
end
end
-- This is a copy of the function(handle_admin_query_set_command) from prosody 12 (d7857ef7843a)
function handle_admin_query_set_command_item(self, origin, stanza, item)
if not item then
origin.send(st.error_reply(stanza, "cancel", "bad-request"));
return true;
end
if item.attr.jid then -- Validate provided JID
item.attr.jid = jid_prep(item.attr.jid);
if not item.attr.jid then
origin.send(st.error_reply(stanza, "modify", "jid-malformed"));
return true;
elseif jid_resource(item.attr.jid) then
origin.send(st.error_reply(stanza, "modify", "jid-malformed", "Bare JID expected, got full JID"));
return true;
end
end
if item.attr.nick then -- Validate provided nick
item.attr.nick = resourceprep(item.attr.nick);
if not item.attr.nick then
origin.send(st.error_reply(stanza, "modify", "jid-malformed", "invalid nickname"));
return true;
end
end
if not item.attr.jid and item.attr.nick then
-- COMPAT Workaround for Miranda sending 'nick' instead of 'jid' when changing affiliation
local occupant = self:get_occupant_by_nick(self.jid.."/"..item.attr.nick);
if occupant then item.attr.jid = occupant.bare_jid; end
elseif item.attr.role and not item.attr.nick and item.attr.jid then
-- Role changes should use nick, but we have a JID so pull the nick from that
local nick = self:get_occupant_jid(item.attr.jid);
if nick then item.attr.nick = jid_resource(nick); end
end
local actor = stanza.attr.from;
local reason = item:get_child_text("reason");
local success, errtype, err
if item.attr.affiliation and item.attr.jid and not item.attr.role then
local registration_data;
if item.attr.nick then
local room_nick = self.jid.."/"..item.attr.nick;
local existing_occupant = self:get_occupant_by_nick(room_nick);
if existing_occupant and existing_occupant.bare_jid ~= item.attr.jid then
module:log("debug", "Existing occupant for %s: %s does not match %s", room_nick, existing_occupant.bare_jid, item.attr.jid);
self:set_role(true, room_nick, nil, "This nickname is reserved");
end
module:log("debug", "Reserving %s for %s (%s)", item.attr.nick, item.attr.jid, item.attr.affiliation);
registration_data = { reserved_nickname = item.attr.nick };
end
success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, reason, registration_data);
elseif item.attr.role and item.attr.nick and not item.attr.affiliation then
success, errtype, err = self:set_role(actor, self.jid.."/"..item.attr.nick, item.attr.role, reason);
else
success, errtype, err = nil, "cancel", "bad-request";
end
self:save(true);
if not success then
origin.send(st.error_reply(stanza, errtype, err));
else
origin.send(st.reply(stanza));
end
end
-- this is extracted from prosody to handle multiple invites
function handle_mediated_invite(room, origin, stanza, payload, host_module)
local invitee = jid_prep(payload.attr.to);
if not invitee then
origin.send(st.error_reply(stanza, "cancel", "jid-malformed"));
return true;
elseif host_module:fire_event("muc-pre-invite", {room = room, origin = origin, stanza = stanza}) then
return true;
end
local invite = muc_util.filter_muc_x(st.clone(stanza));
invite.attr.from = room.jid;
invite.attr.to = invitee;
invite:tag('x', { xmlns = MUC_USER_NS })
:tag('invite', {from = stanza.attr.from;})
:tag('reason'):text(payload:get_child_text("reason")):up()
:up()
:up();
if not host_module:fire_event("muc-invite", {room = room, stanza = invite, origin = origin, incoming = stanza}) then
local join = invite:get_child('x', MUC_USER_NS);
-- make sure we filter password added by any module
if join then
local password = join:get_child('password');
if password then
join:maptags(
function(tag)
for k, v in pairs(tag) do
if k == 'name' and v == 'password' then
return nil
end
end
return tag
end
);
end
end
room:route_stanza(invite);
end
return true;
end
local prosody_overrides = {
-- handle multiple items at once
handle_admin_query_set_command = function(self, origin, stanza)
for i=1,#stanza.tags[1] do
if handle_admin_query_set_command_item(self, origin, stanza, stanza.tags[1].tags[i]) then
return true;
end
end
return true;
end,
-- this is extracted from prosody to handle multiple invites
handle_message_to_room = function(room, origin, stanza, host_module)
local type = stanza.attr.type;
if type == nil or type == "normal" then
local x = stanza:get_child("x", MUC_USER_NS);
if x then
local handled = false;
for _, payload in pairs(x.tags) do
if payload ~= nil and payload.name == "invite" and payload.attr.to then
handled = true;
handle_mediated_invite(room, origin, stanza, payload, host_module)
end
end
return handled;
end
end
end
};
-- operates on already loaded lobby muc module
function process_lobby_muc_loaded(lobby_muc, host_module)
module:log('debug', 'Lobby muc loaded');
lobby_muc_service = lobby_muc;
-- enable filtering presences in the lobby muc rooms
filters.add_filter_hook(filter_session);
-- Advertise lobbyrooms support on main domain so client can pick up the address and use it
module:add_identity('component', LOBBY_IDENTITY_TYPE, lobby_muc_component_config);
-- Tag the disco#info response with a feature that display name is required
-- when the conference name from the web request has a lobby enabled.
host_module:hook('host-disco-info-node', function (event)
local session, reply, node = event.origin, event.reply, event.node;
if node == LOBBY_IDENTITY_TYPE
and session.jitsi_web_query_room
and check_display_name_required then
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if room and room._data.lobbyroom then
reply:tag('feature', { var = DISPLAY_NAME_REQUIRED_FEATURE }):up();
end
end
event.exists = true;
end);
local room_mt = lobby_muc_service.room_mt;
-- we base affiliations (roles) in lobby muc component to be based on the roles in the main muc
room_mt.get_affiliation = function(room, jid)
if not room.main_room then
module:log('error', 'No main room(%s) for %s!', room.jid, jid);
return 'none';
end
-- moderators in main room are moderators here
local role = room.main_room.get_affiliation(room.main_room, jid);
if role then
return role;
end
return 'none';
end
-- listens for kicks in lobby room, 307 is the status for kick according to xep-0045
host_module:hook('muc-broadcast-presence', function (event)
local actor, occupant, room, x = event.actor, event.occupant, event.room, event.x;
if presence_check_status(x, '307') then
local display_name = occupant:get_presence():get_child_text(
'nick', 'http://jabber.org/protocol/nick');
-- we need to notify in the main room
notify_lobby_access(room.main_room, actor, occupant.nick, display_name, false);
end
end);
end
-- process or waits to process the lobby muc component
process_host_module(lobby_muc_component_config, function(host_module, host)
-- lobby muc component created
module:log('info', 'Lobby component loaded %s', host);
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_lobby_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_lobby_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
-- process or waits to process the main muc component
process_host_module(main_muc_component_config, function(host_module, host)
main_muc_service = prosody.hosts[host].modules.muc;
-- hooks when lobby is enabled to create its room, only done here or by admin
host_module:hook('muc-config-submitted', function(event)
local actor, room = event.actor, event.room;
local actor_node = jid_split(actor);
if actor_node == 'focus' then
return;
end
local members_only = event.fields['muc#roomconfig_membersonly'] and true or nil;
if members_only then
local lobby_created = attach_lobby_room(room, actor);
if lobby_created then
module:fire_event('jitsi-lobby-enabled', { room = room; });
event.status_codes['104'] = true;
notify_lobby_enabled(room, actor, true);
end
elseif room._data.lobbyroom then
destroy_lobby_room(room, room.jid);
module:fire_event('jitsi-lobby-disabled', { room = room; });
notify_lobby_enabled(room, actor, false);
end
end);
host_module:hook('muc-room-destroyed',function(event)
local room = event.room;
if room._data.lobbyroom then
destroy_lobby_room(room, nil);
end
end);
host_module:hook('muc-disco#info', function (event)
local room = event.room;
if (room._data.lobbyroom and room:get_members_only()) then
table.insert(event.form, {
name = 'muc#roominfo_lobbyroom';
label = 'Lobby room jid';
value = '';
});
event.formdata['muc#roominfo_lobbyroom'] = room._data.lobbyroom;
end
end);
host_module:hook('muc-occupant-pre-join', function (event)
local occupant, room, stanza = event.occupant, event.room, event.stanza;
if is_healthcheck_room(room.jid) or not room:get_members_only() or ends_with(occupant.nick, '/focus') then
return;
end
local join = stanza:get_child('x', MUC_NS);
if not join then
return;
end
local invitee = event.stanza.attr.from;
local invitee_bare_jid = jid_bare(invitee);
local _, invitee_domain = jid_split(invitee);
local whitelistJoin = false;
-- whitelist participants
if whitelist:contains(invitee_domain) or whitelist:contains(invitee_bare_jid) then
whitelistJoin = true;
end
local password = join:get_child_text('password', MUC_NS);
if password and room:get_password() and password == room:get_password() then
whitelistJoin = true;
end
if whitelistJoin then
local affiliation = room:get_affiliation(invitee);
-- if it was already set to be whitelisted member
if not affiliation or affiliation == 'none' or affiliation == 'member' then
occupant.role = 'participant';
room:set_affiliation(true, invitee_bare_jid, 'member');
room:save_occupant(occupant);
return;
end
elseif room:get_password() then
local affiliation = room:get_affiliation(invitee);
-- if pre-approved and password is set for the room, add the password to allow joining
if affiliation == 'member' and not password then
join:tag('password', { xmlns = MUC_NS }):text(room:get_password());
end
end
-- Check for display name if missing return an error
local displayName = stanza:get_child_text('nick', 'http://jabber.org/protocol/nick');
if (not displayName or #displayName == 0) and not room._data.lobby_skip_display_name_check then
local reply = st.error_reply(stanza, 'modify', 'not-acceptable');
reply.tags[1].attr.code = '406';
reply:tag('displayname-required', { xmlns = 'http://jitsi.org/jitmeet', lobby = 'true' }):up():up();
event.origin.send(reply:tag('x', {xmlns = MUC_NS}));
return true;
end
-- we want to add the custom lobbyroom field to fill in the lobby room jid
local invitee = event.stanza.attr.from;
local affiliation = room:get_affiliation(invitee);
if not affiliation or affiliation == 'none' then
local reply = st.error_reply(stanza, 'auth', 'registration-required');
reply.tags[1].attr.code = '407';
if room._data.lobby_extra_reason then
reply:tag(room._data.lobby_extra_reason, { xmlns = 'http://jitsi.org/jitmeet' }):up();
end
reply:tag('lobbyroom', { xmlns = 'http://jitsi.org/jitmeet' }):text(room._data.lobbyroom):up():up();
-- TODO: Drop this tag at some point (when all mobile clients and jigasi are updated), as this violates the rfc
reply:tag('lobbyroom'):text(room._data.lobbyroom):up();
event.origin.send(reply:tag('x', {xmlns = MUC_NS}));
return true;
end
end, -4); -- the default hook on members_only module is on -5
-- listens for invites for participants to join the main room
host_module:hook('muc-invite', function(event)
local room, stanza = event.room, event.stanza;
local invitee = stanza.attr.to;
local from = stanza:get_child('x', MUC_USER_NS)
:get_child('invite').attr.from;
if lobby_muc_service and room._data.lobbyroom then
local lobby_room_obj = lobby_muc_service.get_room_from_jid(room._data.lobbyroom);
if lobby_room_obj then
local occupant = lobby_room_obj:get_occupant_by_real_jid(invitee);
if occupant then
local display_name = occupant:get_presence():get_child_text(
'nick', 'http://jabber.org/protocol/nick');
notify_lobby_access(room, from, occupant.nick, display_name, true);
end
end
end
end);
-- listen for admin set
for event_name, method in pairs {
-- Normal room interactions
["iq-set/bare/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ;
["message/bare"] = "handle_message_to_room" ;
-- Host room
["iq-set/host/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ;
["message/host"] = "handle_message_to_room" ;
} do
host_module:hook(event_name, function (event)
local origin, stanza = event.origin, event.stanza;
local room_jid = jid_bare(stanza.attr.to);
local room = get_room_from_jid(room_jid);
if room then
return prosody_overrides[method](room, origin, stanza, host_module);
end
end, 1) -- make sure we handle it before prosody that uses priority -2 for this
end
end);
function handle_create_lobby(event)
local room = event.room;
-- since this is called by backend rather than triggered by UI, we need to handle a few additional things:
-- 1. Make sure existing participants are already members or they will get kicked out when set_members_only(true)
-- 2. Trigger a 104 (config change) status message so UI state is properly updated for existing users
-- make sure all existing occupants are members
for _, occupant in room:each_occupant() do
local affiliation = room:get_affiliation(occupant.bare_jid);
if valid_affiliations[affiliation or "none"] < valid_affiliations.member then
room:set_affiliation(true, occupant.bare_jid, 'member');
end
end
-- Now it is safe to set the room to members only
room:set_members_only(true);
room._data.lobby_extra_reason = event.reason;
room._data.lobby_skip_display_name_check = event.skip_display_name_check;
-- Trigger a presence with 104 so existing participants retrieves new muc#roomconfig
room:broadcast_message(
st.message({ type='groupchat', from=room.jid })
:tag('x', { xmlns = MUC_USER_NS })
:tag('status', { code='104' })
);
-- Attach the lobby room.
attach_lobby_room(room);
end
function handle_destroy_lobby(event)
local room = event.room;
-- since this is called by backend rather than triggered by UI, we need to
-- trigger a 104 (config change) status message so UI state is properly updated for existing users (and jicofo)
destroy_lobby_room(room, event.newjid, event.message);
-- Trigger a presence with 104 so existing participants retrieves new muc#roomconfig
room:broadcast_message(
st.message({ type='groupchat', from=room.jid })
:tag('x', { xmlns = MUC_USER_NS })
:tag('status', { code='104' })
);
end
module:hook_global('config-reloaded', load_config);
module:hook_global('create-lobby-room', handle_create_lobby);
module:hook_global('destroy-lobby-room', handle_destroy_lobby);

View File

@@ -0,0 +1,72 @@
-- MUC Max Occupants
-- Configuring muc_max_occupants will set a limit of the maximum number
-- of participants that will be able to join in a room.
-- Participants in muc_access_whitelist will not be counted for the
-- max occupants value (values are jids like recorder@jitsi.meeet.example.com).
-- This module is configured under the muc component that is used for jitsi-meet
local split_jid = require "util.jid".split;
local st = require "util.stanza";
local it = require "util.iterators";
local is_healthcheck_room = module:require "util".is_healthcheck_room;
local whitelist = module:get_option_set("muc_access_whitelist");
local MAX_OCCUPANTS = module:get_option_number("muc_max_occupants", -1);
local function count_keys(t)
return it.count(it.keys(t));
end
local function check_for_max_occupants(event)
local room, origin, stanza = event.room, event.origin, event.stanza;
local user, domain, res = split_jid(stanza.attr.from);
if is_healthcheck_room(room.jid) then
return;
end
--no user object means no way to check for max occupants
if user == nil then
return
end
-- If we're a whitelisted user joining the room, don't bother checking the max
-- occupants.
if whitelist and (whitelist:contains(domain) or whitelist:contains(user..'@'..domain)) then
return;
end
if room and not room._jid_nick[stanza.attr.from] then
local max_occupants_by_room = event.room._data.max_occupants;
local count = count_keys(room._occupants);
-- if no of occupants limit is set per room basis use
-- that settings otherwise use the global one
local slots = max_occupants_by_room or MAX_OCCUPANTS;
-- If there is no whitelist, just check the count.
if not whitelist and count >= slots then
module:log("info", "Attempt to enter a maxed out MUC");
origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
return true;
end
-- TODO: Are Prosody hooks atomic, or is this a race condition?
-- For each person in the room that's not on the whitelist, subtract one
-- from the count.
for _, occupant in room:each_occupant() do
user, domain, res = split_jid(occupant.bare_jid);
if not whitelist or (not whitelist:contains(domain) and not whitelist:contains(user..'@'..domain)) then
slots = slots - 1
end
end
-- If the room is full (<0 slots left), error out.
if slots <= 0 then
module:log("info", "Attempt to enter a maxed out MUC");
origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
return true;
end
end
end
if MAX_OCCUPANTS > 0 then
module:hook("muc-occupant-pre-join", check_for_max_occupants, 10);
end

View File

@@ -0,0 +1,243 @@
local jid = require 'util.jid';
local json = require 'cjson.safe';
local queue = require "util.queue";
local uuid_gen = require "util.uuid".generate;
local main_util = module:require "util";
local is_admin = main_util.is_admin;
local ends_with = main_util.ends_with;
local get_room_from_jid = main_util.get_room_from_jid;
local is_healthcheck_room = main_util.is_healthcheck_room;
local internal_room_jid_match_rewrite = main_util.internal_room_jid_match_rewrite;
local presence_check_status = main_util.presence_check_status;
local extract_subdomain = main_util.extract_subdomain;
local QUEUE_MAX_SIZE = 500;
module:depends("jitsi_permissions");
-- Common module for all logic that can be loaded under the conference muc component.
--
-- This module:
-- a) Generates a unique meetingId, attaches it to the room and adds it to all disco info form data
-- (when room is queried or in the initial room owner config).
-- b) Updates user region (obtain it from the incoming http headers) in the occupant's presence on pre-join.
-- c) Avoids any participant joining the room in the interval between creating the room and jicofo entering the room.
-- d) Removes any nick that maybe set to messages being sent to the room.
-- e) Fires event for received endpoint messages (optimization to decode them once).
-- Hook to assign meetingId for new rooms
module:hook("muc-room-created", function(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
room._data.meetingId = uuid_gen();
module:log("debug", "Created meetingId:%s for %s",
room._data.meetingId, room.jid);
end);
-- Returns the meeting config Id form data.
function getMeetingIdConfig(room)
return {
name = "muc#roominfo_meetingId";
type = "text-single";
label = "The meeting unique id.";
value = room._data.meetingId or "";
};
end
-- add meeting Id to the disco info requests to the room
module:hook("muc-disco#info", function(event)
table.insert(event.form, getMeetingIdConfig(event.room));
end);
-- add the meeting Id in the default config we return to jicofo
module:hook("muc-config-form", function(event)
table.insert(event.form, getMeetingIdConfig(event.room));
end, 90-3);
-- disabled few options for room config, to not mess with visitor logic
module:hook("muc-config-submitted/muc#roomconfig_moderatedroom", function()
return true;
end, 99);
module:hook("muc-config-submitted/muc#roomconfig_presencebroadcast", function()
return true;
end, 99);
module:hook("muc-config-submitted/muc#roominfo_meetingId", function(event)
-- we allow jicofo to overwrite the meetingId
if is_admin(event.actor) then
event.room._data.meetingId = event.value;
return;
end
return true;
end, 99);
module:hook('muc-broadcast-presence', function (event)
local actor, occupant, room, x = event.actor, event.occupant, event.room, event.x;
if presence_check_status(x, '307') then
-- make sure we update and affiliation for kicked users
room:set_affiliation(actor, occupant.bare_jid, 'none');
end
end);
local function process_region(session, stanza)
if not session.user_region then
return;
end
local region = stanza:get_child_text('jitsi_participant_region');
if region then
return;
end
stanza:tag('jitsi_participant_region'):text(session.user_region):up();
end
--- Avoids any participant joining the room in the interval between creating the room
--- and jicofo entering the room
module:hook('muc-occupant-pre-join', function (event)
local occupant, room, stanza = event.occupant, event.room, event.stanza;
local is_health_room = is_healthcheck_room(room.jid);
-- check for region
if not is_admin(occupant.bare_jid) and not is_health_room then
process_region(event.origin, stanza);
end
-- we skip processing only if jicofo_lock is set to false
if room._data.jicofo_lock == false or is_health_room then
return;
end
if ends_with(occupant.nick, '/focus') then
module:fire_event('jicofo-unlock-room', { room = room; });
else
room._data.jicofo_lock = true;
if not room.pre_join_queue then
room.pre_join_queue = queue.new(QUEUE_MAX_SIZE);
end
if not room.pre_join_queue:push(event) then
module:log('error', 'Error enqueuing occupant event for: %s', occupant.nick);
return true;
end
module:log('debug', 'Occupant pushed to prejoin queue %s', occupant.nick);
-- stop processing
return true;
end
end, 8); -- just after the rate limit
function handle_jicofo_unlock(event)
local room = event.room;
room._data.jicofo_lock = false;
if not room.pre_join_queue then
return;
end
-- and now let's handle all pre_join_queue events
for _, ev in room.pre_join_queue:items() do
-- if the connection was closed while waiting in the queue, ignore
if ev.origin.conn then
module:log('debug', 'Occupant processed from queue %s', ev.occupant.nick);
room:handle_normal_presence(ev.origin, ev.stanza);
end
end
room.pre_join_queue = nil;
end
module:hook('jicofo-unlock-room', handle_jicofo_unlock);
-- make sure we remove nick if someone is sending it with a message to protect
-- forgery of display name
module:hook("muc-occupant-groupchat", function(event)
event.stanza:remove_children('nick', 'http://jabber.org/protocol/nick');
end, 45); -- prosody check is prio 50, we want to run after it
module:hook('message/bare', function(event)
local stanza = event.stanza;
if stanza.attr.type ~= 'groupchat' then
return nil;
end
-- we are interested in all messages without a body
local body = stanza:get_child('body')
if body then
return;
end
local room = get_room_from_jid(stanza.attr.to);
if not room then
module:log('warn', 'No room found found for %s', stanza.attr.to);
return;
end
local occupant_jid = stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(occupant_jid);
if not occupant then
module:log("error", "Occupant sending msg %s was not found in room %s", occupant_jid, room.jid)
return;
end
local json_message = stanza:get_child_text('json-message', 'http://jitsi.org/jitmeet')
or stanza:get_child_text('json-message');
if not json_message then
return;
end
-- TODO: add optimization by moving type and certain fields like is_interim as attribute on 'json-message'
-- using string find is roughly 70x faster than json decode for checking the value
if string.find(json_message, '"is_interim":true', 1, true) then
return;
end
local msg_obj, error = json.decode(json_message);
if error then
module:log('error', 'Error decoding data error:%s Sender: %s to:%s', error, stanza.attr.from, stanza.attr.to);
return true;
end
if msg_obj.transcript ~= nil then
local transcription = msg_obj;
-- in case of the string matching optimization above failed
if transcription.is_interim then
return;
end
-- TODO what if we have multiple alternative transcriptions not just 1
local text_message = transcription.transcript[1].text;
--do not send empty messages
if text_message == '' then
return;
end
local user_id = transcription.participant.id;
local who = room:get_occupant_by_nick(jid.bare(room.jid)..'/'..user_id);
transcription.jid = who and who.jid;
transcription.session_id = room._data.meetingId;
local tenant, conference_name, id = extract_subdomain(jid.node(room.jid));
if tenant then
transcription.fqn = tenant..'/'..conference_name;
else
transcription.fqn = conference_name;
end
transcription.customer_id = id;
return module:fire_event('jitsi-transcript-received', {
room = room, occupant = occupant, transcription = transcription, stanza = stanza });
end
return module:fire_event('jitsi-endpoint-message-received', {
room = room, occupant = occupant, message = msg_obj,
origin = event.origin,
stanza = stanza, raw_message = json_message });
end);

View File

@@ -0,0 +1,186 @@
local inspect = require "inspect";
local formdecode = require "util.http".formdecode;
local urlencode = require "util.http".urlencode;
local jid = require "util.jid";
local json = require 'cjson.safe';
local util = module:require "util";
local async_handler_wrapper = util.async_handler_wrapper;
local starts_with = util.starts_with;
local process_host_module = util.process_host_module;
local token_util = module:require "token/util".new(module);
-- option to enable/disable room API token verifications
local enableTokenVerification
= module:get_option_boolean("enable_password_token_verification", true);
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
if not muc_domain_base then
module:log("warn", "No 'muc_domain_base' option set, disabling password check endpoint.");
return ;
end
local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference");
local json_content_type = "application/json";
--- Verifies the token
-- @param token the token we received
-- @param room_address the full room address jid
-- @return true if values are ok or false otherwise
function verify_token(token, room_address)
if not enableTokenVerification then
return true;
end
-- if enableTokenVerification is enabled and we do not have token
-- stop here, cause the main virtual host can have guest access enabled
-- (allowEmptyToken = true) and we will allow access to rooms info without
-- a token
if token == nil then
module:log("warn", "no token provided for %s", room_address);
return false;
end
local session = {};
session.auth_token = token;
local verified, reason, msg = token_util:process_and_verify_token(session);
if not verified then
module:log("warn", "not a valid token %s %s for %s", tostring(reason), tostring(msg), room_address);
return false;
end
return true;
end
-- Validates the request by checking for required url param room and
-- validates the token provided with the request
-- @param request - The request to validate.
-- @return [error_code, room]
local function validate_and_get_room(request)
if not request.url.query then
module:log("warn", "No query");
return 400, nil;
end
local params = formdecode(request.url.query);
local room_name = urlencode(params.room) or "";
local subdomain = urlencode(params.prefix) or "";
if not room_name then
module:log("warn", "Missing room param for %s", room_name);
return 400, nil;
end
local room_address = jid.join(room_name, muc_domain_prefix.."."..muc_domain_base);
if subdomain and subdomain ~= "" then
room_address = "["..subdomain.."]"..room_address;
end
-- verify access
local token = request.headers["authorization"]
if token and starts_with(token,'Bearer ') then
token = token:sub(8,#token)
end
if not verify_token(token, room_address) then
return 403, nil;
end
local room = get_room_from_jid(room_address);
if not room then
module:log("warn", "No room found for %s", room_address);
return 404, nil;
else
return 200, room;
end
end
function handle_validate_room_password (event)
local request = event.request;
if request.headers.content_type ~= json_content_type
or (not request.body or #request.body == 0) then
module:log("warn", "Wrong content type: %s", request.headers.content_type);
return { status_code = 400; }
end
local params, error = json.decode(request.body);
if not params then
module:log("warn", "Missing params error:%s", error);
return { status_code = 400; }
end
local passcode = params["passcode"];
if not passcode then
module:log("warn", "Missing passcode param");
return { status_code = 400; };
end
local error_code, room = validate_and_get_room(request);
if not room then
return { status_code = error_code; }
end
local json_msg_str, error_encode = json.encode({ valid = (room:get_password() == passcode) });
if not json_msg_str then
module:log('error', 'Cannot encode json room:%s error:%s', room.jid, error_encode);
return { status_code = 400; };
end
local PUT_response = {
headers = { content_type = "application/json"; };
body = json_msg_str;
};
-- module:log("debug","Sending response for room password validate: %s", inspect(PUT_response));
return PUT_response;
end
--- Handles request for retrieving the room participants details
-- @param event the http event, holds the request query
-- @return GET response, containing a json with participants details
function handle_get_room_password (event)
local error_code, room = validate_and_get_room(event.request);
if not room then
return { status_code = error_code; }
end
room_details = {};
room_details["conference"] = room.jid;
room_details["passcodeProtected"] = room:get_password() ~= nil;
room_details["lobbyEnabled"] = room._data ~= nil and room._data.lobbyroom ~= nil;
local json_msg_str, error = json.encode(room_details);
if not json_msg_str then
module:log('error', 'Cannot encode json room:%s error:%s', room.jid, error);
return { status_code = 400; };
end
local GET_response = {
headers = {
content_type = "application/json";
};
body = json_msg_str;
};
-- module:log("debug","Sending response for room password: %s", inspect(GET_response));
return GET_response;
end
process_host_module(muc_domain_base, function(host_module, host)
module:log("info","Adding http handler for /room-info on %s", host_module.host);
host_module:depends("http");
host_module:provides("http", {
default_path = "/";
route = {
["GET room-info"] = function (event) return async_handler_wrapper(event, handle_get_room_password) end;
["PUT room-info"] = function (event) return async_handler_wrapper(event, handle_validate_room_password) end;
};
});
end);

View File

@@ -0,0 +1,56 @@
--- AUTHOR: https://gist.github.com/legastero Lance Stout
local jid_split = require "util.jid".split;
local whitelist = module:get_option_set("muc_password_whitelist");
local MUC_NS = "http://jabber.org/protocol/muc";
module:hook("muc-occupant-pre-join", function (event)
local room, stanza = event.room, event.stanza;
local user, domain, res = jid_split(event.stanza.attr.from);
--no user object means no way to check whitelist
if user == nil then
return
end
if not whitelist then
return;
end
if not whitelist:contains(domain) and not whitelist:contains(user..'@'..domain) then
return;
end
local join = stanza:get_child("x", MUC_NS);
if not join then
join = stanza:tag("x", { xmlns = MUC_NS });
end
local password = join:get_child("password", MUC_NS);
if password then
-- removes <password... node,
-- seems like password:text( appends text, not replacing it
join:maptags(
function(tag)
for k, v in pairs(tag) do
if k == "name" and v == "password" then
return nil
end
end
return tag
end
);
end
join:tag("password", { xmlns = MUC_NS }):text(room:get_password());
-- module:log("debug", "Applied password access whitelist for %s in room %s", event.stanza.attr.from, room.jid);
end, -7); --- Run before the password check (priority -20), runs after lobby(priority -4) and members-only (priority -5).
module:hook_global("config-reloaded", function (event)
module:log("debug", "Reloading MUC password access whitelist");
whitelist = module:get_option_set("muc_password_whitelist");
end)

View File

@@ -0,0 +1,319 @@
local bare = require "util.jid".bare;
local get_room_by_name_and_subdomain = module:require "util".get_room_by_name_and_subdomain;
local jid = require "util.jid";
local neturl = require "net.url";
local parse = neturl.parseQuery;
local poltergeist = module:require "poltergeist";
local have_async = pcall(require, "util.async");
if not have_async then
module:log("error", "requires a version of Prosody with util.async");
return;
end
module:depends("jitsi_session");
local async_handler_wrapper = module:require "util".async_handler_wrapper;
-- Options
local poltergeist_component
= module:get_option_string("poltergeist_component", module.host);
-- this basically strips the domain from the conference.domain address
local parentHostName = string.gmatch(tostring(module.host), "%w+.(%w.+)")();
if parentHostName == nil then
log("error", "Failed to start - unable to get parent hostname");
return;
end
local parentCtx = module:context(parentHostName);
if parentCtx == nil then
log("error",
"Failed to start - unable to get parent context for host: %s",
tostring(parentHostName));
return;
end
local token_util = module:require "token/util".new(parentCtx);
-- option to enable/disable token verifications
local disableTokenVerification
= module:get_option_boolean("disable_polergeist_token_verification", false);
-- poltergaist management functions
--- Verifies room name, domain name with the values in the token
-- @param token the token we received
-- @param room_name the room name
-- @param group name of the group (optional)
-- @param session the session to use for storing token specific fields
-- @return true if values are ok or false otherwise
function verify_token(token, room_name, group, session)
if disableTokenVerification then
return true;
end
-- if not disableTokenVerification and we do not have token
-- stop here, cause the main virtual host can have guest access enabled
-- (allowEmptyToken = true) and we will allow access to rooms info without
-- a token
if token == nil then
log("warn", "no token provided");
return false;
end
session.auth_token = token;
local verified, reason = token_util:process_and_verify_token(session);
if not verified then
log("warn", "not a valid token %s", tostring(reason));
return false;
end
local room_address = jid.join(room_name, module:get_host());
-- if there is a group we are in multidomain mode and that group is not
-- our parent host
if group and group ~= "" and group ~= parentHostName then
room_address = "["..group.."]"..room_address;
end
if not token_util:verify_room(session, room_address) then
log("warn", "Token %s not allowed to join: %s",
tostring(token), tostring(room_address));
return false;
end
return true;
end
-- Event handlers
-- if we found that a session for a user with id has a poltergiest already
-- created, retrieve its jid and return it to the authentication
-- so we can reuse it and we that real user will replace the poltergiest
prosody.events.add_handler("pre-jitsi-authentication", function(session)
if (session.jitsi_meet_context_user) then
local room = get_room_by_name_and_subdomain(
session.jitsi_web_query_room,
session.jitsi_web_query_prefix);
if (not room) then
return nil;
end
local username = poltergeist.get_username(
room,
session.jitsi_meet_context_user["id"]
);
if (not username) then
return nil;
end
log("debug", "Found predefined username %s", username);
-- let's find the room and if the poltergeist occupant is there
-- lets remove him before the real participant joins
-- when we see the unavailable presence to go out the server
-- we will mark it with ignore tag
local nick = poltergeist.create_nick(username);
if (poltergeist.occupies(room, nick)) then
module:log("info", "swapping poltergeist for user: %s/%s", room, nick)
-- notify that user connected using the poltergeist
poltergeist.update(room, nick, "connected");
poltergeist.remove(room, nick, true);
end
return username;
end
return nil;
end);
--- Note: mod_muc and some of its sub-modules add event handlers between 0 and -100,
--- e.g. to check for banned users, etc.. Hence adding these handlers at priority -100.
module:hook("muc-decline", function (event)
poltergeist.remove(event.room, bare(event.stanza.attr.from), false);
end, -100);
-- before sending the presence for a poltergeist leaving add ignore tag
-- as poltergeist is leaving just before the real user joins and in the client
-- we ignore this presence to avoid leaving/joining experience and the real
-- user will reuse all currently created UI components for the same nick
module:hook("muc-broadcast-presence", function (event)
if (bare(event.occupant.jid) == poltergeist_component) then
if(event.stanza.attr.type == "unavailable"
and poltergeist.should_ignore(event.occupant.nick)) then
event.stanza:tag(
"ignore", { xmlns = "http://jitsi.org/jitmeet/" }):up();
poltergeist.reset_ignored(event.occupant.nick);
end
end
end, -100);
-- cleanup room table after room is destroyed
module:hook(
"muc-room-destroyed",
function(event)
poltergeist.remove_room(event.room);
end
);
--- Handles request for creating/managing poltergeists
-- @param event the http event, holds the request query
-- @return GET response, containing a json with response details
function handle_create_poltergeist (event)
if (not event.request.url.query) then
return { status_code = 400; };
end
local params = parse(event.request.url.query);
local user_id = params["user"];
local room_name = params["room"];
local group = params["group"];
local name = params["name"];
local avatar = params["avatar"];
local status = params["status"];
local conversation = params["conversation"];
local session = {};
if not verify_token(params["token"], room_name, group, session) then
return { status_code = 403; };
end
-- If the provided room conference doesn't exist then we
-- can't add a poltergeist to it.
local room = get_room_by_name_and_subdomain(room_name, group);
if (not room) then
log("error", "no room found %s", room_name);
return { status_code = 404; };
end
-- If the poltergiest is already in the conference then it will
-- be in our username store and another can't be added.
local username = poltergeist.get_username(room, user_id);
if (username ~=nil and
poltergeist.occupies(room, poltergeist.create_nick(username))) then
log("warn",
"poltergeist for username:%s already in the room:%s",
username,
room_name
);
return { status_code = 202; };
end
local context = {
user = {
id = user_id;
};
group = group;
creator_user = session.jitsi_meet_context_user;
creator_group = session.jitsi_meet_context_group;
};
if avatar ~= nil then
context.user.avatar = avatar
end
local resources = {};
if conversation ~= nil then
resources["conversation"] = conversation
end
poltergeist.add_to_muc(room, user_id, name, avatar, context, status, resources)
return { status_code = 200; };
end
--- Handles request for updating poltergeists status
-- @param event the http event, holds the request query
-- @return GET response, containing a json with response details
function handle_update_poltergeist (event)
if (not event.request.url.query) then
return { status_code = 400; };
end
local params = parse(event.request.url.query);
local user_id = params["user"];
local room_name = params["room"];
local group = params["group"];
local status = params["status"];
local call_id = params["callid"];
local call_cancel = false
if params["callcancel"] == "true" then
call_cancel = true;
end
if not verify_token(params["token"], room_name, group, {}) then
return { status_code = 403; };
end
local room = get_room_by_name_and_subdomain(room_name, group);
if (not room) then
log("error", "no room found %s", room_name);
return { status_code = 404; };
end
local username = poltergeist.get_username(room, user_id);
if (not username) then
return { status_code = 404; };
end
local call_details = {
["cancel"] = call_cancel;
["id"] = call_id;
};
local nick = poltergeist.create_nick(username);
if (not poltergeist.occupies(room, nick)) then
return { status_code = 404; };
end
poltergeist.update(room, nick, status, call_details);
return { status_code = 200; };
end
--- Handles remove poltergeists
-- @param event the http event, holds the request query
-- @return GET response, containing a json with response details
function handle_remove_poltergeist (event)
if (not event.request.url.query) then
return { status_code = 400; };
end
local params = parse(event.request.url.query);
local user_id = params["user"];
local room_name = params["room"];
local group = params["group"];
if not verify_token(params["token"], room_name, group, {}) then
return { status_code = 403; };
end
local room = get_room_by_name_and_subdomain(room_name, group);
if (not room) then
log("error", "no room found %s", room_name);
return { status_code = 404; };
end
local username = poltergeist.get_username(room, user_id);
if (not username) then
return { status_code = 404; };
end
local nick = poltergeist.create_nick(username);
if (not poltergeist.occupies(room, nick)) then
return { status_code = 404; };
end
poltergeist.remove(room, nick, false);
return { status_code = 200; };
end
log("info", "Loading poltergeist service");
module:depends("http");
module:provides("http", {
default_path = "/";
name = "poltergeist";
route = {
["GET /poltergeist/create"] = function (event) return async_handler_wrapper(event,handle_create_poltergeist) end;
["GET /poltergeist/update"] = function (event) return async_handler_wrapper(event,handle_update_poltergeist) end;
["GET /poltergeist/remove"] = function (event) return async_handler_wrapper(event,handle_remove_poltergeist) end;
};
});

View File

@@ -0,0 +1,231 @@
-- enable under the main muc component
local queue = require "util.queue";
local new_throttle = require "util.throttle".create;
local timer = require "util.timer";
local st = require "util.stanza";
-- we max to 500 participants per meeting so this should be enough, we are not suppose to handle all
-- participants in one meeting
local PRESENCE_QUEUE_MAX_SIZE = 1000;
-- default to 3 participants per second
local join_rate_per_conference = module:get_option_number("muc_rate_joins", 3);
local leave_rate_per_conference = module:get_option_number("muc_rate_leaves", 5);
-- Measure/monitor the room rate limiting queue
local measure = require "core.statsmanager".measure;
local measure_longest_queue = measure("distribution",
"/mod_" .. module.name .. "/longest_queue");
local measure_rooms_with_queue = measure("rate",
"/mod_" .. module.name .. "/rooms_with_queue");
-- throws a stat that the queue was full, counts the total number of times we hit it
local measure_full_queue = measure("rate",
"/mod_" .. module.name .. "/full_queue");
-- keeps track of the total times we had an error processing the queue
local measure_errors_processing_queue = measure("rate",
"/mod_" .. module.name .. "/errors_processing_queue");
-- we keep track here what was the longest queue we have seen
local stat_longest_queue = 0;
-- Adds item to the queue
-- @returns false if queue is full and item was not added, true otherwise
local function add_item_to_queue(queue, item, room, from, send_stats)
if not queue:push(item) then
module:log('error',
'Error pushing item in %s queue for %s in %s', send_stats and 'join' or 'leave', from, room.jid);
if send_stats then
measure_full_queue();
end
return false;
else
-- check is this the longest queue and if so throws a stat
if send_stats and queue:count() > stat_longest_queue then
stat_longest_queue = queue:count();
measure_longest_queue(stat_longest_queue);
end
return true;
end
end
-- process join_rate_presence_queue in the room and pops element passing them to handle_normal_presence
-- returns 1 if we want to reschedule it after 1 second
local function timer_process_queue_elements (rate, queue, process, queue_empty_cb)
if not queue or queue:count() == 0 or queue.empty then
return;
end
for _ = 1, rate do
local ev = queue:pop();
if ev then
process(ev);
end
end
-- if there are elements left, schedule an execution in a second
if queue:count() > 0 then
return 1;
else
queue_empty_cb();
end
end
-- we check join rate before occupant joins. If rate is exceeded we queue the events and start a timer
-- that will run every second processing the events passing them to the room handling function handle_normal_presence
-- from where those arrived, this way we keep a maximum rate of joining
module:hook("muc-occupant-pre-join", function (event)
local room, stanza = event.room, event.stanza;
-- skipping events we had produced and clear our flag
if stanza.delayed_join_skip == true then
event.stanza.delayed_join_skip = nil;
return nil;
end
local throttle = room.join_rate_throttle;
if not room.join_rate_throttle then
throttle = new_throttle(join_rate_per_conference, 1); -- rate per one second
room.join_rate_throttle = throttle;
end
if not throttle:poll(1) then
if not room.join_rate_presence_queue then
-- if this is the first item for a room we increment the stat for rooms with queues
measure_rooms_with_queue();
room.join_rate_presence_queue = queue.new(PRESENCE_QUEUE_MAX_SIZE);
end
if not add_item_to_queue(room.join_rate_presence_queue, event, room, stanza.attr.from, true) then
-- let's not stop processing the event
return nil;
end
if not room.join_rate_queue_timer then
timer.add_task(1, function ()
local status, result = pcall(timer_process_queue_elements,
join_rate_per_conference,
room.join_rate_presence_queue,
function(ev)
-- we mark what we pass here so we can skip it on the next muc-occupant-pre-join event
ev.stanza.delayed_join_skip = true;
room:handle_normal_presence(ev.origin, ev.stanza);
end,
function() -- empty callback
room.join_rate_queue_timer = false;
end
);
if not status then
-- there was an error in the timer function
module:log('error', 'Error processing join queue: %s', result);
measure_errors_processing_queue();
-- let's re-schedule timer so we do not lose the queue
return 1;
end
return result;
end);
room.join_rate_queue_timer = true;
end
return true; -- we stop execution, so we do not process this join at the moment
end
if room.join_rate_queue_timer then
-- there is timer so we need to order the presences, put it in the queue
-- if add fails as queue is full we return false and the event will continue processing, we risk re-order
-- but not losing it
return add_item_to_queue(room.join_rate_presence_queue, event, room, stanza.attr.from, true);
end
end, 9); -- as we will rate limit joins we need to be the first to execute
-- we ran it after muc_max_occupants which is with priority 10, there is nothing to rate limit
-- if max number of occupants is reached
-- clear queue on room destroy so timer will skip next run if any
module:hook('muc-room-destroyed',function(event)
if event.room.join_rate_presence_queue then
event.room.join_rate_presence_queue.empty = true;
end
if event.room.leave_rate_presence_queue then
event.room.leave_rate_presence_queue.empty = true;
end
end);
module:hook('muc-occupant-pre-leave', function (event)
local occupant, room, stanza = event.occupant, event.room, event.stanza;
local throttle = room.leave_rate_throttle;
if not throttle then
throttle = new_throttle(leave_rate_per_conference, 1); -- rate per one second
room.leave_rate_throttle = throttle;
end
if not throttle:poll(1) then
if not room.leave_rate_presence_queue then
room.leave_rate_presence_queue = queue.new(PRESENCE_QUEUE_MAX_SIZE);
end
-- we need it later when processing the event
event.orig_role = occupant.role;
if not add_item_to_queue(room.leave_rate_presence_queue, event, room, stanza.attr.from, false) then
-- let's not stop processing the event
return nil;
end
-- set role to nil so the occupant will be removed from room occupants when we save it
-- we remove occupant from the list early on batches so we can spare sending few presences
occupant.role = nil;
room:save_occupant(occupant);
if not room.leave_rate_queue_timer then
timer.add_task(1, function ()
local status, result = pcall(timer_process_queue_elements,
leave_rate_per_conference,
room.leave_rate_presence_queue,
function(ev)
local occupant, orig_role, origin, room, stanza
= ev.occupant, ev.orig_role, ev.origin, ev.room, ev.stanza;
room:publicise_occupant_status(
occupant,
st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}),
nil, nil, nil, orig_role);
module:fire_event("muc-occupant-left", {
room = room;
nick = occupant.nick;
occupant = occupant;
origin = origin;
stanza = stanza;
});
end,
function() -- empty callback
room.leave_rate_queue_timer = false;
end
);
if not status then
-- there was an error in the timer function
module:log('error', 'Error processing leave queue: %s', result);
-- let's re-schedule timer so we do not lose the queue
return 1;
end
return result;
end);
room.leave_rate_queue_timer = true;
end
return true; -- we stop execution, so we do not process this leave at the moment
end
end);

View File

@@ -0,0 +1,197 @@
-- Prosody IM
-- Copyright (C) 2021-present 8x8, Inc.
--
local jid = require "util.jid";
local it = require "util.iterators";
local json = require 'cjson.safe';
local iterators = require "util.iterators";
local array = require"util.array";
local have_async = pcall(require, "util.async");
if not have_async then
module:log("error", "requires a version of Prosody with util.async");
return;
end
local async_handler_wrapper = module:require "util".async_handler_wrapper;
local tostring = tostring;
local neturl = require "net.url";
local parse = neturl.parseQuery;
-- option to enable/disable room API token verifications
local enableTokenVerification
= module:get_option_boolean("enable_roomsize_token_verification", false);
local token_util = module:require "token/util".new(module);
local get_room_from_jid = module:require "util".get_room_from_jid;
-- no token configuration but required
if token_util == nil and enableTokenVerification then
log("error", "no token configuration but it is required");
return;
end
-- required parameter for custom muc component prefix,
-- defaults to "conference"
local muc_domain_prefix
= module:get_option_string("muc_mapper_domain_prefix", "conference");
--- Verifies room name, domain name with the values in the token
-- @param token the token we received
-- @param room_address the full room address jid
-- @return true if values are ok or false otherwise
function verify_token(token, room_address)
if not enableTokenVerification then
return true;
end
-- if enableTokenVerification is enabled and we do not have token
-- stop here, cause the main virtual host can have guest access enabled
-- (allowEmptyToken = true) and we will allow access to rooms info without
-- a token
if token == nil then
log("warn", "no token provided");
return false;
end
local session = {};
session.auth_token = token;
local verified, reason = token_util:process_and_verify_token(session);
if not verified then
log("warn", "not a valid token %s", tostring(reason));
return false;
end
if not token_util:verify_room(session, room_address) then
log("warn", "Token %s not allowed to join: %s",
tostring(token), tostring(room_address));
return false;
end
return true;
end
--- Handles request for retrieving the room size
-- @param event the http event, holds the request query
-- @return GET response, containing a json with participants count,
-- the value is without counting the focus.
function handle_get_room_size(event)
if (not event.request.url.query) then
return { status_code = 400; };
end
local params = parse(event.request.url.query);
local room_name = params["room"];
local domain_name = params["domain"];
local subdomain = params["subdomain"];
local room_address
= jid.join(room_name, muc_domain_prefix.."."..domain_name);
if subdomain and subdomain ~= "" then
room_address = "["..subdomain.."]"..room_address;
end
if not verify_token(params["token"], room_address) then
return { status_code = 403; };
end
local room = get_room_from_jid(room_address);
local participant_count = 0;
log("debug", "Querying room %s", tostring(room_address));
if room then
local occupants = room._occupants;
if occupants then
participant_count = iterators.count(room:each_occupant());
end
log("debug",
"there are %s occupants in room", tostring(participant_count));
else
log("debug", "no such room exists");
return { status_code = 404; };
end
if participant_count > 1 then
participant_count = participant_count - 1;
end
return { status_code = 200; body = [[{"participants":]]..participant_count..[[}]] };
end
--- Handles request for retrieving the room participants details
-- @param event the http event, holds the request query
-- @return GET response, containing a json with participants details
function handle_get_room (event)
if (not event.request.url.query) then
return { status_code = 400; };
end
local params = parse(event.request.url.query);
local room_name = params["room"];
local domain_name = params["domain"];
local subdomain = params["subdomain"];
local room_address
= jid.join(room_name, muc_domain_prefix.."."..domain_name);
if subdomain and subdomain ~= "" then
room_address = "["..subdomain.."]"..room_address;
end
if not verify_token(params["token"], room_address) then
return { status_code = 403; };
end
local room = get_room_from_jid(room_address);
local participant_count = 0;
local occupants_json = array();
log("debug", "Querying room %s", tostring(room_address));
if room then
local occupants = room._occupants;
if occupants then
participant_count = iterators.count(room:each_occupant());
for _, occupant in room:each_occupant() do
-- filter focus as we keep it as hidden participant
if string.sub(occupant.nick,-string.len("/focus"))~="/focus" then
for _, pr in occupant:each_session() do
local nick = pr:get_child_text("nick", "http://jabber.org/protocol/nick") or "";
local email = pr:get_child_text("email") or "";
occupants_json:push({
jid = tostring(occupant.nick),
email = tostring(email),
display_name = tostring(nick)});
end
end
end
end
log("debug",
"there are %s occupants in room", tostring(participant_count));
else
log("debug", "no such room exists");
return { status_code = 404; };
end
if participant_count > 1 then
participant_count = participant_count - 1;
end
return { status_code = 200; body = json.encode(occupants_json); };
end;
function module.load()
module:depends("http");
module:provides("http", {
default_path = "/";
route = {
["GET room-size"] = function (event) return async_handler_wrapper(event,handle_get_room_size) end;
["GET sessions"] = function () return tostring(it.count(it.keys(prosody.full_sessions))); end;
["GET room"] = function (event) return async_handler_wrapper(event,handle_get_room) end;
};
});
end

View File

@@ -0,0 +1,98 @@
-- This module is activated under the main muc component
-- This will prevent anyone joining the call till jicofo and one moderator join the room
-- for the rest of the participants lobby will be turned on and they will be waiting there till
-- the main participant joins and lobby will be turned off at that time and rest of the participants will
-- join the room. It expects main virtual host to be set to require jwt tokens and guests to use
-- the guest domain which is anonymous.
-- The module has the option to set participants to moderators when connected via token/when they are authenticated
-- This module depends on mod_persistent_lobby.
local jid = require 'util.jid';
local util = module:require "util";
local is_admin = util.is_admin;
local is_healthcheck_room = util.is_healthcheck_room;
local is_moderated = util.is_moderated;
local process_host_module = util.process_host_module;
local disable_auto_owners = module:get_option_boolean('wait_for_host_disable_auto_owners', false);
local muc_domain_base = module:get_option_string('muc_mapper_domain_base');
if not muc_domain_base then
module:log('warn', "No 'muc_mapper_domain_base' option set, disabling module");
return
end
-- to activate this you need the following config in general config file in log = { }
-- { to = 'file', filename = '/var/log/prosody/prosody.audit.log', levels = { 'audit' } }
local logger = require 'util.logger';
local audit_logger = logger.make_logger('mod_'..module.name, 'audit');
local lobby_muc_component_config = 'lobby.' .. muc_domain_base;
local lobby_host;
if not disable_auto_owners then
module:hook('muc-occupant-joined', function (event)
local room, occupant, session = event.room, event.occupant, event.origin;
local is_moderated_room = is_moderated(room.jid);
-- for jwt authenticated and username and password authenticated
-- only if it is not a moderated room
if not is_moderated_room and
(session.auth_token or (session.username and jid.host(occupant.bare_jid) == muc_domain_base)) then
room:set_affiliation(true, occupant.bare_jid, 'owner');
end
end, 2);
end
-- if not authenticated user is trying to join the room we enable lobby in it
-- and wait for the moderator to join
module:hook('muc-occupant-pre-join', function (event)
local room, occupant, session = event.room, event.occupant, event.origin;
-- we ignore jicofo as we want it to join the room or if the room has already seen its
-- authenticated host
if is_admin(occupant.bare_jid) or is_healthcheck_room(room.jid) or room.has_host then
return;
end
local has_host = false;
for _, o in room:each_occupant() do
if jid.host(o.bare_jid) == muc_domain_base then
room.has_host = true;
end
end
if not room.has_host then
if session.auth_token or (session.username and jid.host(occupant.bare_jid) == muc_domain_base) then
-- the host is here, let's drop the lobby
room:set_members_only(false);
-- let's set the default role of 'participant' for the newly created occupant as it was nil when created
-- when the room was still members_only, later if not disabled this participant will become a moderator
occupant.role = room:get_default_role(room:get_affiliation(occupant.bare_jid)) or 'participant';
module:log('info', 'Host %s arrived in %s.', occupant.bare_jid, room.jid);
audit_logger('room_jid:%s created_by:%s', room.jid,
session.jitsi_meet_context_user and session.jitsi_meet_context_user.id or 'nil');
module:fire_event('room_host_arrived', room.jid, session);
lobby_host:fire_event('destroy-lobby-room', {
room = room,
newjid = room.jid,
message = 'Host arrived.',
});
elseif not room:get_members_only() then
-- let's enable lobby
module:log('info', 'Will wait for host in %s.', room.jid);
prosody.events.fire_event('create-persistent-lobby-room', {
room = room;
reason = 'waiting-for-host',
skip_display_name_check = true;
});
end
end
end);
process_host_module(lobby_muc_component_config, function(host_module, host)
-- lobby muc component created
module:log('info', 'Lobby component loaded %s', host);
lobby_host = module:context(host_module);
end);

View File

@@ -0,0 +1,199 @@
-- This module allows lobby room to be created even when the main room is empty.
-- Without this module, the empty main room will get deleted after grace period
-- which triggers lobby room deletion even if there are still people in the lobby.
--
-- This module should be added to the main virtual host domain.
-- It assumes you have properly configured the muc_lobby_rooms module and lobby muc component.
--
-- To trigger creation of lobby room:
-- prosody.events.fire_event("create-persistent-lobby-room", { room = room; });
--
module:depends('room_destroy');
local util = module:require "util";
local is_healthcheck_room = util.is_healthcheck_room;
local main_muc_component_host = module:get_option_string('main_muc');
local lobby_muc_component_host = module:get_option_string('lobby_muc');
if main_muc_component_host == nil then
module:log('error', 'main_muc not configured. Cannot proceed.');
return;
end
if lobby_muc_component_host == nil then
module:log('error', 'lobby not enabled missing lobby_muc config');
return;
end
-- Helper function to wait till a component is loaded before running the given callback
local function run_when_component_loaded(component_host_name, callback)
local function trigger_callback()
module:log('info', 'Component loaded %s', component_host_name);
callback(module:context(component_host_name), component_host_name);
end
if prosody.hosts[component_host_name] == nil then
module:log('debug', 'Host %s not yet loaded. Will trigger when it is loaded.', component_host_name);
prosody.events.add_handler('host-activated', function (host)
if host == component_host_name then
trigger_callback();
end
end);
else
trigger_callback();
end
end
-- Helper function to wait till a component's muc module is loaded before running the given callback
local function run_when_muc_module_loaded(component_host_module, component_host_name, callback)
local function trigger_callback()
module:log('info', 'MUC module loaded for %s', component_host_name);
callback(prosody.hosts[component_host_name].modules.muc, component_host_module);
end
if prosody.hosts[component_host_name].modules.muc == nil then
module:log('debug', 'MUC module for %s not yet loaded. Will trigger when it is loaded.', component_host_name);
prosody.hosts[component_host_name].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
trigger_callback();
end
end);
else
trigger_callback()
end
end
local lobby_muc_service;
local main_muc_service;
local main_muc_module;
-- Helper methods to track rooms that have persistent lobby
local function set_persistent_lobby(room)
room._data.persist_lobby = true;
end
local function has_persistent_lobby(room)
if room._data.persist_lobby == true then
return true;
else
return false;
end
end
-- Helper method to trigger main room destroy
local function trigger_room_destroy(room)
prosody.events.fire_event("maybe-destroy-room", {
room = room;
reason = 'main room and lobby now empty';
caller = module:get_name();
});
end
-- For rooms with persistent lobby, we need to trigger deletion ourselves when both the main room
-- and the lobby room are empty. This will be checked each time an occupant leaves the main room
-- of if someone drops off the lobby.
-- Handle events on main muc module
run_when_component_loaded(main_muc_component_host, function(host_module, host_name)
run_when_muc_module_loaded(host_module, host_name, function (main_muc, main_module)
main_muc_service = main_muc; -- so it can be accessed from lobby muc event handlers
main_muc_module = main_module;
main_module:hook("muc-occupant-left", function(event)
-- Check if room should be destroyed when someone leaves the main room
local main_room = event.room;
if is_healthcheck_room(main_room.jid) or not has_persistent_lobby(main_room) then
return;
end
local lobby_room_jid = main_room._data.lobbyroom;
-- If occupant leaving results in main room being empty, we trigger room destroy if
-- a) lobby exists and is not empty
-- b) lobby does not exist (possible for lobby to be disabled manually by moderator in meeting)
--
-- (main room destroy also triggers lobby room destroy in muc_lobby_rooms)
if not main_room:has_occupant() then
if lobby_room_jid == nil then -- lobby disabled
trigger_room_destroy(main_room);
else -- lobby exists
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid);
if lobby_room and not lobby_room:has_occupant() then
trigger_room_destroy(main_room);
end
end
end
end);
end);
end);
-- Handle events on lobby muc module
run_when_component_loaded(lobby_muc_component_host, function(host_module, host_name)
run_when_muc_module_loaded(host_module, host_name, function (lobby_muc, lobby_module)
lobby_muc_service = lobby_muc; -- so it can be accessed from main muc event handlers
lobby_module:hook("muc-occupant-left", function(event)
-- Check if room should be destroyed when someone leaves the lobby
local lobby_room = event.room;
local main_room = lobby_room.main_room;
if not main_room or is_healthcheck_room(main_room.jid) or not has_persistent_lobby(main_room) then
return;
end
-- If both lobby room and main room are empty, we destroy main room.
-- (main room destroy also triggers lobby room destroy in muc_lobby_rooms)
if not lobby_room:has_occupant() and main_room and not main_room:has_occupant() then
trigger_room_destroy(main_room);
end
end);
end);
end);
function handle_create_persistent_lobby(event)
local room = event.room;
prosody.events.fire_event("create-lobby-room", event);
set_persistent_lobby(room);
room:set_persistent(true);
end
module:hook_global('create-persistent-lobby-room', handle_create_persistent_lobby);
-- Stop other modules from destroying room if persistent lobby not empty
function handle_maybe_destroy_main_room(event)
local main_room = event.room;
local caller = event.caller;
if caller == module:get_name() then
-- we were the one that requested the deletion. Do not override.
return nil;
end
-- deletion was requested by another module. Check for lobby occupants.
if has_persistent_lobby(main_room) and main_room._data.lobbyroom then
local lobby_room_jid = main_room._data.lobbyroom;
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid);
if lobby_room and lobby_room:has_occupant() then
module:log('info', 'Suppressing room destroy. Persistent lobby still occupied %s', lobby_room_jid);
return true; -- stop room destruction
end
end
end
module:hook_global("maybe-destroy-room", handle_maybe_destroy_main_room);

View File

@@ -0,0 +1,207 @@
-- This module provides persistence for the "polls" feature,
-- by keeping track of the state of polls in each room, and sending
-- that state to new participants when they join.
local json = require 'cjson.safe';
local st = require("util.stanza");
local jid = require "util.jid";
local util = module:require("util");
local muc = module:depends("muc");
local NS_NICK = 'http://jabber.org/protocol/nick';
local is_healthcheck_room = util.is_healthcheck_room;
local POLLS_LIMIT = 128;
local POLL_PAYLOAD_LIMIT = 1024;
-- Logs a warning and returns true if a room does not
-- have poll data associated with it.
local function check_polls(room)
if room.polls == nil then
module:log("warn", "no polls data in room");
return true;
end
return false;
end
--- Returns a table having occupant id and occupant name.
--- If the id cannot be extracted from nick a nil value is returned
--- if the occupant name cannot be extracted from presence the Fellow Jitster
--- name is used
local function get_occupant_details(occupant)
if not occupant then
return nil
end
local presence = occupant:get_presence();
local occupant_name;
if presence then
occupant_name = presence:get_child("nick", NS_NICK) and presence:get_child("nick", NS_NICK):get_text() or 'Fellow Jitster';
else
occupant_name = 'Fellow Jitster'
end
local _, _, occupant_id = jid.split(occupant.nick)
if not occupant_id then
return nil
end
return { ["occupant_id"] = occupant_id, ["occupant_name"] = occupant_name }
end
-- Sets up poll data in new rooms.
module:hook("muc-room-created", function(event)
local room = event.room;
if is_healthcheck_room(room.jid) then return end
module:log("debug", "setting up polls in room %s", room.jid);
room.polls = {
by_id = {};
order = {};
count = 0;
};
end);
-- Keeps track of the current state of the polls in each room,
-- by listening to "new-poll" and "answer-poll" messages,
-- and updating the room poll data accordingly.
-- This mirrors the client-side poll update logic.
module:hook('jitsi-endpoint-message-received', function(event)
local data, error, occupant, room, origin, stanza
= event.message, event.error, event.occupant, event.room, event.origin, event.stanza;
if not data or (data.type ~= "new-poll" and data.type ~= "answer-poll") then
return;
end
if string.len(event.raw_message) >= POLL_PAYLOAD_LIMIT then
module:log('error', 'Poll payload too large, discarding. Sender: %s to:%s', stanza.attr.from, stanza.attr.to);
return true;
end
if data.type == "new-poll" then
if check_polls(room) then return end
local poll_creator = get_occupant_details(occupant)
if not poll_creator then
module:log("error", "Cannot retrieve poll creator id and name for %s from %s", occupant.jid, room.jid)
return
end
if room.polls.count >= POLLS_LIMIT then
module:log("error", "Too many polls created in %s", room.jid)
return true;
end
if room.polls.by_id[data.pollId] ~= nil then
module:log("error", "Poll already exists: %s", data.pollId);
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll already exists'));
return true;
end
if room.jitsiMetadata and room.jitsiMetadata.permissions
and room.jitsiMetadata.permissions.pollCreationRestricted
and not is_feature_allowed('create-polls', origin.jitsi_meet_context_features) then
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Creation of polls not allowed for user'));
return true;
end
local answers = {}
local compact_answers = {}
for i, name in ipairs(data.answers) do
table.insert(answers, { name = name, voters = {} });
table.insert(compact_answers, { key = i, name = name});
end
local poll = {
id = data.pollId,
sender_id = poll_creator.occupant_id,
sender_name = poll_creator.occupant_name,
question = data.question,
answers = answers
};
room.polls.by_id[data.pollId] = poll
table.insert(room.polls.order, poll)
room.polls.count = room.polls.count + 1;
local pollData = {
event = event,
room = room,
poll = {
pollId = data.pollId,
senderId = poll_creator.occupant_id,
senderName = poll_creator.occupant_name,
question = data.question,
answers = compact_answers
}
}
module:fire_event("poll-created", pollData);
elseif data.type == "answer-poll" then
if check_polls(room) then return end
local poll = room.polls.by_id[data.pollId];
if poll == nil then
module:log("warn", "answering inexistent poll");
return;
end
local voter = get_occupant_details(occupant)
if not voter then
module:log("error", "Cannot retrieve voter id and name for %s from %s", occupant.jid, room.jid)
return
end
local answers = {};
for vote_option_idx, vote_flag in ipairs(data.answers) do
table.insert(answers, {
key = vote_option_idx,
value = vote_flag,
name = poll.answers[vote_option_idx].name,
});
poll.answers[vote_option_idx].voters[voter.occupant_id] = vote_flag and voter.occupant_name or nil;
end
local answerData = {
event = event,
room = room,
pollId = poll.id,
voterName = voter.occupant_name,
voterId = voter.occupant_id,
answers = answers
}
module:fire_event("answer-poll", answerData);
end
end);
-- Sends the current poll state to new occupants after joining a room.
module:hook("muc-occupant-joined", function(event)
local room = event.room;
if is_healthcheck_room(room.jid) then return end
if room.polls == nil or #room.polls.order == 0 then
return
end
local data = {
type = "old-polls",
polls = {},
};
for i, poll in ipairs(room.polls.order) do
data.polls[i] = {
id = poll.id,
senderId = poll.sender_id,
senderName = poll.sender_name,
question = poll.question,
answers = poll.answers
};
end
local json_msg_str, error = json.encode(data);
if not json_msg_str then
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
end
local stanza = st.message({
from = room.jid,
to = event.occupant.jid
})
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet" })
:text(json_msg_str)
:up();
room:route_stanza(stanza);
end);

View File

@@ -0,0 +1,21 @@
local st = require "util.stanza";
-- A component which we use to receive all stanzas for the created poltergeists
-- replays with error if an iq is sent
function no_action()
return true;
end
function error_reply(event)
module:send(st.error_reply(event.stanza, "cancel", "service-unavailable"));
return true;
end
module:hook("presence/host", no_action);
module:hook("message/host", no_action);
module:hook("presence/full", no_action);
module:hook("message/full", no_action);
module:hook("iq/host", error_reply);
module:hook("iq/full", error_reply);
module:hook("iq/bare", error_reply);

View File

@@ -0,0 +1,19 @@
local stanza = require "util.stanza";
local update_presence_identity = module:require "util".update_presence_identity;
-- For all received presence messages, if the jitsi_meet_context_(user|group)
-- values are set in the session, then insert them into the presence messages
-- for that session.
function on_message(event)
local stanza, session = event.stanza, event.origin;
if stanza and session then
update_presence_identity(
stanza,
session.jitsi_meet_context_user,
session.jitsi_meet_context_group
);
end
end
module:hook("pre-presence/bare", on_message);
module:hook("pre-presence/full", on_message);

View File

@@ -0,0 +1,234 @@
-- Rate limits connection based on their ip address.
-- Rate limits creating sessions (new connections),
-- rate limits sent stanzas from same ip address (presence, iq, messages)
-- Copyright (C) 2023-present 8x8, Inc.
local cache = require"util.cache";
local ceil = math.ceil;
local http_server = require "net.http.server";
local gettime = require "util.time".now
local filters = require "util.filters";
local new_throttle = require "util.throttle".create;
local timer = require "util.timer";
local ip_util = require "util.ip";
local new_ip = ip_util.new_ip;
local match_ip = ip_util.match;
local parse_cidr = ip_util.parse_cidr;
local get_ip = module:require "util".get_ip;
local config = {};
local limits_resolution = 1;
local function load_config()
-- Max allowed login rate in events per second.
config.login_rate = module:get_option_number("rate_limit_login_rate", 3);
-- The rate to which sessions from IPs exceeding the join rate will be limited, in bytes per second.
config.ip_rate = module:get_option_number("rate_limit_ip_rate", 2000);
-- The rate to which sessions exceeding the stanza(iq, presence, message) rate will be limited, in bytes per second.
config.session_rate = module:get_option_number("rate_limit_session_rate", 1000);
-- The time in seconds, after which the limit for an IP address is lifted.
config.timeout = module:get_option_number("rate_limit_timeout", 60);
-- List of regular expressions for IP addresses that are not limited by this module.
config.whitelist = module:get_option_set("rate_limit_whitelist", { "127.0.0.1", "::1" })._items;
-- The size of the cache that saves state for IP addresses
config.cache_size = module:get_option_number("rate_limit_cache_size", 10000);
-- Max allowed presence rate in events per second.
config.presence_rate = module:get_option_number("rate_limit_presence_rate", 4);
-- Max allowed iq rate in events per second.
config.iq_rate = module:get_option_number("rate_limit_iq_rate", 15);
-- Max allowed message rate in events per second.
config.message_rate = module:get_option_number("rate_limit_message_rate", 3);
-- A list of hosts for which sessions we ignore rate limiting
config.whitelist_hosts = module:get_option_set("rate_limit_whitelist_hosts", {});
local wl = "";
for ip in config.whitelist do wl = wl .. ip .. "," end
local wl_hosts = "";
for j in config.whitelist_hosts do wl_hosts = wl_hosts .. j .. "," end
module:log("info", "Loaded configuration: ");
module:log("info", "- ip_rate=%s bytes/sec, session_rate=%s bytes/sec, timeout=%s sec, cache size=%s, whitelist=%s, whitelist_hosts=%s",
config.ip_rate, config.session_rate, config.timeout, config.cache_size, wl, wl_hosts);
module:log("info", "- login_rate=%s/sec, presence_rate=%s/sec, iq_rate=%s/sec, message_rate=%s/sec",
config.login_rate, config.presence_rate, config.iq_rate, config.message_rate);
end
load_config();
-- Maps an IP address to a util.throttle which keeps the rate of login/join events from that IP.
local login_rates = cache.new(config.cache_size);
-- Keeps the IP addresses that have exceeded the allowed login/join rate (i.e. the IP addresses whose sessions need
-- to be limited). Mapped to the last instant at which the rate was exceeded.
local limited_ips = cache.new(config.cache_size);
local function is_whitelisted(ip)
local parsed_ip = new_ip(ip)
for entry in config.whitelist do
if match_ip(parsed_ip, parse_cidr(entry)) then
return true;
end
end
return false;
end
local function is_whitelisted_host(h)
return config.whitelist_hosts:contains(h);
end
-- Add an IP to the set of limied IPs
local function limit_ip(ip)
module:log("info", "Limiting %s due to login/join rate exceeded.", ip);
limited_ips:set(ip, gettime());
end
-- Installable as a session filter to limit the reading rate for a session. Based on mod_limits.
local function limit_bytes_in(bytes, session)
local sess_throttle = session.jitsi_throttle;
if sess_throttle then
-- if the limit timeout has elapsed let's stop the throttle
if not sess_throttle.start or gettime() - sess_throttle.start > config.timeout then
module:log("info", "Stop throttling session=%s, ip=%s.", session.id, session.ip);
session.jitsi_throttle = nil;
return bytes;
end
local ok, _, outstanding = sess_throttle:poll(#bytes, true);
if not ok then
session.log("debug",
"Session over rate limit (%d) with %d (by %d), pausing", sess_throttle.max, #bytes, outstanding);
outstanding = ceil(outstanding);
session.conn:pause(); -- Read no more data from the connection until there is no outstanding data
local outstanding_data = bytes:sub(-outstanding);
bytes = bytes:sub(1, #bytes-outstanding);
timer.add_task(limits_resolution, function ()
if not session.conn then return; end
if sess_throttle:peek(#outstanding_data) then
session.log("debug", "Resuming paused session");
session.conn:resume();
end
-- Handle what we can of the outstanding data
session.data(outstanding_data);
end);
end
end
return bytes;
end
-- Throttles reading from the connection of a specific session.
local function throttle_session(session, rate, timeout)
if not session.jitsi_throttle then
if (session.conn and session.conn.setlimit) then
session.jitsi_throttle_counter = session.jitsi_throttle_counter + 1;
module:log("info", "Enabling throttle (%s bytes/s) via setlimit, session=%s, ip=%s, counter=%s.",
rate, session.id, session.ip, session.jitsi_throttle_counter);
session.conn:setlimit(rate);
if timeout then
if session.jitsi_throttle_timer then
-- if there was a timer stop it as we will schedule a new one
session.jitsi_throttle_timer:stop();
session.jitsi_throttle_timer = nil;
end
session.jitsi_throttle_timer = module:add_timer(timeout, function()
if session.conn then
module:log("info", "Stop throttling session=%s, ip=%s.", session.id, session.ip);
session.conn:setlimit(0);
end
session.jitsi_throttle_timer = nil;
end);
end
else
module:log("info", "Enabling throttle (%s bytes/s) via filter, session=%s, ip=%s.", rate, session.id, session.ip);
session.jitsi_throttle = new_throttle(rate, 2);
filters.add_filter(session, "bytes/in", limit_bytes_in, 1000);
-- throttle.start used for stop throttling after the timeout
session.jitsi_throttle.start = gettime();
end
else
-- update the throttling start
session.jitsi_throttle.start = gettime();
end
end
-- checks different stanzas for rate limiting (per session)
function filter_stanza(stanza, session)
local rate = session[stanza.name.."_rate"];
if rate then
local ok, _, _ = rate:poll(1, true);
if not ok then
module:log("info", "%s rate exceeded for %s, limiting.", stanza.name, session.full_jid);
throttle_session(session, config.session_rate, config.timeout);
end
end
return stanza;
end
local function on_login(session, ip)
local login_rate = login_rates:get(ip);
if not login_rate then
module:log("debug", "Create new join rate for %s", ip);
login_rate = new_throttle(config.login_rate, 2);
login_rates:set(ip, login_rate);
end
local ok, _, _ = login_rate:poll(1, true);
if not ok then
module:log("info", "Join rate exceeded for %s, limiting.", ip);
limit_ip(ip);
end
end
local function filter_hook(session)
-- ignore outgoing sessions (s2s)
if session.outgoing then
return;
end
local ip = get_ip(session);
module:log("debug", "New session from %s", ip);
if is_whitelisted(ip) or is_whitelisted_host(session.host) then
return;
end
on_login(session, ip);
-- creates the stanzas rates
session.jitsi_throttle_counter = 0;
session.presence_rate = new_throttle(config.presence_rate, 2);
session.iq_rate = new_throttle(config.iq_rate, 2);
session.message_rate = new_throttle(config.message_rate, 2);
filters.add_filter(session, "stanzas/in", filter_stanza);
local oldt = limited_ips:get(ip);
if oldt then
local newt = gettime();
local elapsed = newt - oldt;
if elapsed < config.timeout then
if elapsed < 5 then
module:log("info", "IP address %s was limited %s seconds ago, refreshing.", ip, elapsed);
limited_ips:set(ip, newt);
end
throttle_session(session, config.ip_rate);
else
module:log("info", "Removing the limit for %s", ip);
limited_ips:set(ip, nil);
end
end
end
function module.load()
filters.add_filter_hook(filter_hook);
end
function module.unload()
filters.remove_filter_hook(filter_hook);
end
module:hook_global("config-reloaded", load_config);
-- we calculate the stats on the configured interval (60 seconds by default)
local measure_limited_ips = module:measure('limited-ips', 'amount'); -- we send stats for the total number limited ips
module:hook_global('stats-update', function ()
measure_limited_ips(limited_ips:count());
end);

View File

@@ -0,0 +1,695 @@
--- This is a port of Jicofo's Reservation System as a prosody module
-- ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
--
-- We try to retain the same behaviour and interfaces where possible, but there
-- is some difference:
-- * In the event that the DELETE call fails, Jicofo's reservation
-- system retains reservation data and allows re-creation of room if requested by
-- the same creator without making further call to the API; this module does not
-- offer this behaviour. Re-creation of a closed room will behave like a new meeting
-- and trigger a new API call to validate the reservation.
-- * Jicofo's reservation system expect int-based conflict_id. We take any sensible string.
--
-- In broad strokes, this module works by intercepting Conference IQs sent to focus component
-- and buffers it until reservation is confirmed (by calling the provided API endpoint).
-- The IQ events are routed on to focus component if reservation is valid, or error
-- response is sent back to the origin if reservation is denied. Events are routed as usual
-- if the room already exists.
--
--
-- Installation:
-- =============
--
-- Under domain config,
-- 1. add "reservations" to modules_enabled.
-- 2. Specify URL base for your API endpoint using "reservations_api_prefix" (required)
-- 3. Optional config:
-- * set "reservations_api_timeout" to change API call timeouts (defaults to 20 seconds)
-- * set "reservations_api_headers" to specify custom HTTP headers included in
-- all API calls e.g. to provide auth tokens.
-- * set "reservations_api_retry_count" to the number of times API call failures are retried (defaults to 3)
-- * set "reservations_api_retry_delay" seconds to wait between retries (defaults to 3s)
-- * set "reservations_api_should_retry_for_code" to a function that takes an HTTP response code and
-- returns true if API call should be retried. By default, retries are done for 5XX
-- responses. Timeouts are never retried, and HTTP call failures are always retried.
-- * set "reservations_enable_max_occupants" to true to enable integration with
-- mod_muc_max_occupants. Setting thia will allow optional "max_occupants" (integer)
-- payload from API to influence max occupants allowed for a given room.
-- * set "reservations_enable_lobby_support" to true to enable integration
-- with "muc_lobby_rooms". Setting this will allow optional "lobby" (boolean)
-- fields in API payload. If set to true, Lobby will be enabled for the room.
-- "persistent_lobby" module must also be enabled for this to work.
-- * set "reservations_enable_password_support" to allow optional "password" (string)
-- field in API payload. If set and not empty, then room password will be set
-- to the given string.
-- * By default, reservation checks are skipped for breakout rooms. You can subject
-- breakout rooms to the same checks by setting "reservations_skip_breakout_rooms" to false.
--
--
-- Example config:
--
-- VirtualHost "jitmeet.example.com"
-- modules_enabled = {
-- "reservations";
-- }
-- reservations_api_prefix = "http://reservation.example.com"
--
-- --- The following are all optional
-- reservations_api_headers = {
-- ["Authorization"] = "Bearer TOKEN-237958623045";
-- }
-- reservations_api_timeout = 10 -- timeout if API does not respond within 10s
-- reservations_api_retry_count = 5 -- retry up to 5 times
-- reservations_api_retry_delay = 1 -- wait 1s between retries
-- reservations_api_should_retry_for_code = function (code)
-- return code >= 500 or code == 408
-- end
--
-- reservations_enable_max_occupants = true -- support "max_occupants" field
-- reservations_enable_lobby_support = true -- support "lobby" field
-- reservations_enable_password_support = true -- support "password" field
--
local jid = require 'util.jid';
local http = require "net.http";
local json = require 'cjson.safe';
local st = require "util.stanza";
local timer = require 'util.timer';
local datetime = require 'util.datetime';
local util = module:require "util";
local get_room_from_jid = util.get_room_from_jid;
local is_healthcheck_room = util.is_healthcheck_room;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local process_host_module = util.process_host_module;
local api_prefix = module:get_option("reservations_api_prefix");
local api_headers = module:get_option("reservations_api_headers");
local api_timeout = module:get_option("reservations_api_timeout", 20);
local api_retry_count = tonumber(module:get_option("reservations_api_retry_count", 3));
local api_retry_delay = tonumber(module:get_option("reservations_api_retry_delay", 3));
local max_occupants_enabled = module:get_option("reservations_enable_max_occupants", false);
local lobby_support_enabled = module:get_option("reservations_enable_lobby_support", false);
local password_support_enabled = module:get_option("reservations_enable_password_support", false);
local skip_breakout_room = module:get_option("reservations_skip_breakout_rooms", true);
-- Option for user to control HTTP response codes that will result in a retry.
-- Defaults to returning true on any 5XX code or 0
local api_should_retry_for_code = module:get_option("reservations_api_should_retry_for_code", function (code)
return code >= 500;
end)
local muc_component_host = module:get_option_string("main_muc");
local breakout_muc_component_host = module:get_option_string('breakout_rooms_muc', 'breakout.'..module.host);
-- How often to check and evict expired reservation data
local expiry_check_period = 60;
-- Cannot proceed if "reservations_api_prefix" not configured
if not api_prefix then
module:log("error", "reservations_api_prefix not specified. Disabling %s", module:get_name());
return;
end
-- get/infer focus component hostname so we can intercept IQ bound for it
local focus_component_host = module:get_option_string("focus_component");
if not focus_component_host then
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
if not muc_domain_base then
module:log("error", "Could not infer focus domain. Disabling %s", module:get_name());
return;
end
focus_component_host = 'focus.'..muc_domain_base;
end
-- common HTTP headers added to all API calls
local http_headers = {
["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")";
};
if api_headers then -- extra headers from config
for key, value in pairs(api_headers) do
http_headers[key] = value;
end
end
--- Utils
--- Converts int timestamp to datetime string compatible with Java SimpleDateFormat
-- @param t timestamps in seconds. Supports int (as returned by os.time()) or higher
-- precision (as returned by socket.gettime())
-- @return formatted datetime string (yyyy-MM-dd'T'HH:mm:ss.SSSX)
local function to_java_date_string(t)
local t_secs, mantissa = math.modf(t);
local ms_str = (mantissa == 0) and '.000' or tostring(mantissa):sub(2,5);
local date_str = os.date("!%Y-%m-%dT%H:%M:%S", t_secs);
return date_str..ms_str..'Z';
end
--- Start non-blocking HTTP call
-- @param url URL to call
-- @param options options table as expected by net.http where we provide optional headers, body or method.
-- @param callback if provided, called with callback(response_body, response_code) when call complete.
-- @param timeout_callback if provided, called without args when request times out.
-- @param retries how many times to retry on failure; 0 means no retries.
local function async_http_request(url, options, callback, timeout_callback, retries)
local completed = false;
local timed_out = false;
local retries = retries or api_retry_count;
local function cb_(response_body, response_code)
if not timed_out then -- request completed before timeout
completed = true;
if (response_code == 0 or api_should_retry_for_code(response_code)) and retries > 0 then
module:log("warn", "API Response code %d. Will retry after %ds", response_code, api_retry_delay);
timer.add_task(api_retry_delay, function()
async_http_request(url, options, callback, timeout_callback, retries - 1)
end)
return;
end
if callback then
callback(response_body, response_code)
end
end
end
local request = http.request(url, options, cb_);
timer.add_task(api_timeout, function ()
timed_out = true;
if not completed then
http.destroy_request(request);
if timeout_callback then
timeout_callback()
end
end
end);
end
--- Returns current timestamp
local function now()
-- Don't really need higher precision of socket.gettime(). Besides, we loose
-- milliseconds precision when converting back to timestamp from date string
-- when we use datetime.parse(t), so let's be consistent.
return os.time();
end
--- Start RoomReservation implementation
-- Status enums used in RoomReservation:meta.status
local STATUS = {
PENDING = 0;
SUCCESS = 1;
FAILED = -1;
}
local RoomReservation = {};
RoomReservation.__index = RoomReservation;
function newRoomReservation(room_jid, creator_jid)
return setmetatable({
room_jid = room_jid;
-- Reservation metadata. store as table so we can set and read atomically.
-- N.B. This should always be updated using self.set_status_*
meta = {
status = STATUS.PENDING;
mail_owner = jid.bare(creator_jid);
conflict_id = nil;
start_time = now(); -- timestamp, in seconds
expires_at = nil; -- timestamp, in seconds
error_text = nil;
error_code = nil;
};
-- Array of pending events that we need to route once API call is complete
pending_events = {};
-- Set true when API call trigger has been triggered (by enqueue of first event)
api_call_triggered = false;
}, RoomReservation);
end
--- Extracts room name from room jid
function RoomReservation:get_room_name()
return jid.node(self.room_jid);
end
--- Checks if reservation data is expires and should be evicted from store
function RoomReservation:is_expired()
return self.meta.expires_at ~= nil and now() > self.meta.expires_at;
end
--- Main entry point for handing and routing events.
function RoomReservation:enqueue_or_route_event(event)
if self.meta.status == STATUS.PENDING then
table.insert(self.pending_events, event)
if self.api_call_triggered ~= true then
self:call_api_create_conference();
end
else
-- API call already complete. Immediately route without enqueueing.
-- This could happen if request comes in between the time reservation approved
-- and when Jicofo actually creates the room.
module:log("debug", "Reservation details already stored. Skipping queue for %s", self.room_jid);
self:route_event(event);
end
end
--- Updates status and initiates event routing. Called internally when API call complete.
function RoomReservation:set_status_success(start_time, duration, mail_owner, conflict_id, data)
module:log("info", "Reservation created successfully for %s", self.room_jid);
self.meta = {
status = STATUS.SUCCESS;
mail_owner = mail_owner or self.meta.mail_owner;
conflict_id = conflict_id;
start_time = start_time;
expires_at = start_time + duration;
error_text = nil;
error_code = nil;
}
if max_occupants_enabled and data.max_occupants then
self.meta.max_occupants = data.max_occupants
end
if lobby_support_enabled and data.lobby then
self.meta.lobby = data.lobby
end
if password_support_enabled and data.password then
self.meta.password = data.password
end
self:route_pending_events()
end
--- Updates status and initiates error response to pending events. Called internally when API call complete.
function RoomReservation:set_status_failed(error_code, error_text)
module:log("info", "Reservation creation failed for %s - (%s) %s", self.room_jid, error_code, error_text);
self.meta = {
status = STATUS.FAILED;
mail_owner = self.meta.mail_owner;
conflict_id = nil;
start_time = self.meta.start_time;
-- Retain reservation rejection for a short while so we have time to report failure to
-- existing clients and not trigger a re-query too soon.
-- N.B. Expiry could take longer since eviction happens periodically.
expires_at = now() + 30;
error_text = error_text;
error_code = error_code;
}
self:route_pending_events()
end
--- Triggers routing of all enqueued events
function RoomReservation:route_pending_events()
if self.meta.status == STATUS.PENDING then -- should never be called while PENDING. check just in case.
return;
end
module:log("debug", "Routing all pending events for %s", self.room_jid);
local event;
while #self.pending_events ~= 0 do
event = table.remove(self.pending_events);
self:route_event(event)
end
end
--- Event routing implementation
function RoomReservation:route_event(event)
-- this should only be called after API call complete and status no longer PENDING
assert(self.meta.status ~= STATUS.PENDING, "Attempting to route event while API call still PENDING")
local meta = self.meta;
local origin, stanza = event.origin, event.stanza;
if meta.status == STATUS.FAILED then
module:log("debug", "Route: Sending reservation error to %s", stanza.attr.from);
self:reply_with_error(event, meta.error_code, meta.error_text);
else
if meta.status == STATUS.SUCCESS then
if self:is_expired() then
module:log("debug", "Route: Sending reservation expiry to %s", stanza.attr.from);
self:reply_with_error(event, 419, "Reservation expired");
else
module:log("debug", "Route: Forwarding on event from %s", stanza.attr.from);
prosody.core_post_stanza(origin, stanza, false); -- route iq to intended target (focus)
end
else
-- this should never happen unless dev made a mistake. Block by default just in case.
module:log("error", "Reservation for %s has invalid state %s. Rejecting request.", self.room_jid, meta.status);
self:reply_with_error(event, 500, "Failed to determine reservation state");
end
end
end
--- Generates reservation-error stanza and sends to event origin.
function RoomReservation:reply_with_error(event, error_code, error_text)
local stanza = event.stanza;
local id = stanza.attr.id;
local to = stanza.attr.from;
local from = stanza.attr.to;
event.origin.send(
st.iq({ type="error", to=to, from=from, id=id })
:tag("error", { type="cancel" })
:tag("service-unavailable", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):up()
:tag("text", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):text(error_text):up()
:tag("reservation-error", { xmlns="http://jitsi.org/protocol/focus", ["error-code"]=tostring(error_code) })
);
end
--- Initiates non-blocking API call to validate reservation
function RoomReservation:call_api_create_conference()
self.api_call_triggered = true;
local url = api_prefix..'/conference';
local request_data = {
name = self:get_room_name();
start_time = to_java_date_string(self.meta.start_time);
mail_owner = self.meta.mail_owner;
}
local http_options = {
body = http.formencode(request_data); -- because Jicofo reservation encodes as form data instead JSON
method = 'POST';
headers = http_headers;
}
module:log("debug", "Sending POST /conference for %s", self.room_jid);
async_http_request(url, http_options, function (response_body, response_code)
self:on_api_create_conference_complete(response_body, response_code);
end, function ()
self:on_api_call_timeout();
end);
end
--- Parses and validates HTTP response body for conference payload
-- Ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
-- @return nil if invalid, or table with payload parsed from JSON response
function RoomReservation:parse_conference_response(response_body)
local data, error = json.decode(response_body);
if data == nil then -- invalid JSON payload
module:log("error", "Invalid JSON response from API - %s error:%s", response_body, error);
return;
end
if data.name == nil or data.name:lower() ~= self:get_room_name() then
module:log("error", "Missing or mismatching room name - %s", data.name);
return;
end
if data.id == nil then
module:log("error", "Missing id");
return;
end
if data.mail_owner == nil then
module:log("error", "Missing mail_owner");
return;
end
local duration = tonumber(data.duration);
if duration == nil then
module:log("error", "Missing or invalid duration - %s", data.duration);
return;
end
data.duration = duration;
-- if optional "max_occupants" field set, cast to number
if data.max_occupants ~= nil then
local max_occupants = tonumber(data.max_occupants)
if max_occupants == nil or max_occupants < 1 then
-- N.B. invalid max_occupants rejected even if max_occupants_enabled=false
module:log("error", "Invalid value for max_occupants - %s", data.max_occupants);
return;
end
data.max_occupants = max_occupants
end
-- if optional "lobby" field set, accept boolean true or "true"
if data.lobby ~= nil then
if (type(data.lobby) == "boolean" and data.lobby) or data.lobby == "true" then
data.lobby = true
else
data.lobby = false
end
end
-- if optional "password" field set, it has to be string
if data.password ~= nil then
if type(data.password) ~= "string" then
-- N.B. invalid "password" rejected even if reservations_enable_password_support=false
module:log("error", "Invalid type for password - string expected");
return;
end
end
local start_time = datetime.parse(data.start_time); -- N.B. we lose milliseconds portion of the date
if start_time == nil then
module:log("error", "Missing or invalid start_time - %s", data.start_time);
return;
end
data.start_time = start_time;
return data;
end
--- Parses and validates HTTP error response body for API call.
-- Expect JSON with a "message" field.
-- @return message string, or generic error message if invalid payload.
function RoomReservation:parse_error_message_from_response(response_body)
local data = json.decode(response_body);
if data ~= nil and data.message ~= nil then
module:log("debug", "Invalid error response body. Will use generic error message.");
return data.message;
else
return "Rejected by reservation server";
end
end
--- callback on API timeout
function RoomReservation:on_api_call_timeout()
self:set_status_failed(500, 'Reservation lookup timed out');
end
--- callback on API response
function RoomReservation:on_api_create_conference_complete(response_body, response_code)
if response_code == 200 or response_code == 201 then
self:handler_conference_data_returned_from_api(response_body);
elseif response_code == 409 then
self:handle_conference_already_exist(response_body);
elseif response_code == nil then -- warrants a retry, but this should be done automatically by the http call method.
self:set_status_failed(500, 'Could not contact reservation server');
else
self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
end
end
function RoomReservation:handler_conference_data_returned_from_api(response_body)
local data = self:parse_conference_response(response_body);
if not data then -- invalid response from API
module:log("error", "API returned success code but invalid payload");
self:set_status_failed(500, 'Invalid response from reservation server');
else
self:set_status_success(data.start_time, data.duration, data.mail_owner, data.id, data)
end
end
function RoomReservation:handle_conference_already_exist(response_body)
local data = json.decode(response_body);
if data == nil or data.conflict_id == nil then
-- yes, in the case of 409, API expected to return "id" as "conflict_id".
self:set_status_failed(409, 'Invalid response from reservation server');
else
local url = api_prefix..'/conference/'..data.conflict_id;
local http_options = {
method = 'GET';
headers = http_headers;
}
async_http_request(url, http_options, function(response_body, response_code)
if response_code == 200 then
self:handler_conference_data_returned_from_api(response_body);
else
self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
end
end, function ()
self:on_api_call_timeout();
end);
end
end
--- End RoomReservation
--- Store reservations lookups that are still pending or with room still active
local reservations = {}
local function get_or_create_reservations(room_jid, creator_jid)
if reservations[room_jid] == nil then
module:log("debug", "Creating new reservation data for %s", room_jid);
reservations[room_jid] = newRoomReservation(room_jid, creator_jid);
end
return reservations[room_jid];
end
local function evict_expired_reservations()
local expired = {}
-- first, gather jids of expired rooms. So we don't remove from table while iterating.
for room_jid, res in pairs(reservations) do
if res:is_expired() then
table.insert(expired, room_jid);
end
end
local room;
for _, room_jid in ipairs(expired) do
room = get_room_from_jid(room_jid);
if room then
-- Close room if still active (reservation duration exceeded)
module:log("info", "Room exceeded reservation duration. Terminating %s", room_jid);
room:destroy(nil, "Scheduled conference duration exceeded.");
-- Rely on room_destroyed to calls DELETE /conference and drops reservation[room_jid]
else
module:log("error", "Reservation references expired room that is no longer active. Dropping %s", room_jid);
-- This should not happen unless evict_expired_reservations somehow gets triggered
-- between the time room is destroyed and room_destroyed callback is called. (Possible?)
-- But just in case, we drop the reservation to avoid repeating this path on every pass.
reservations[room_jid] = nil;
end
end
end
timer.add_task(expiry_check_period, function()
evict_expired_reservations();
return expiry_check_period;
end)
--- Intercept conference IQ to Jicofo handle reservation checks before allowing normal event flow
module:log("info", "Hook to global pre-iq/host");
module:hook("pre-iq/host", function(event)
local stanza = event.stanza;
if stanza.name ~= "iq" or stanza.attr.to ~= focus_component_host or stanza.attr.type ~= 'set' then
return; -- not IQ for jicofo. Ignore this event.
end
local conference = stanza:get_child('conference', 'http://jitsi.org/protocol/focus');
if conference == nil then
return; -- not Conference IQ. Ignore.
end
local room_jid = room_jid_match_rewrite(conference.attr.room);
if get_room_from_jid(room_jid) ~= nil then
module:log("debug", "Skip reservation check for existing room %s", room_jid);
return; -- room already exists. Continue with normal flow
end
if skip_breakout_room then
local _, host = jid.split(room_jid);
if host == breakout_muc_component_host then
module:log("debug", "Skip reservation check for breakout room %s", room_jid);
return;
end
end
local res = get_or_create_reservations(room_jid, stanza.attr.from);
res:enqueue_or_route_event(event); -- hand over to reservation obj to route event
return true;
end);
--- Forget reservation details once room destroyed so query is repeated if room re-created
local function room_destroyed(event)
local res;
local room = event.room
if not is_healthcheck_room(room.jid) then
res = reservations[room.jid]
-- drop reservation data for this room
reservations[room.jid] = nil
if res then -- just in case event triggered more than once?
module:log("info", "Dropped reservation data for destroyed room %s", room.jid);
local conflict_id = res.meta.conflict_id
if conflict_id then
local url = api_prefix..'/conference/'..conflict_id;
local http_options = {
method = 'DELETE';
headers = http_headers;
}
module:log("debug", "Sending DELETE /conference/%s", conflict_id);
async_http_request(url, http_options);
end
end
end
end
local function room_created(event)
local room = event.room
if is_healthcheck_room(room.jid) then
return;
end
local res = reservations[room.jid]
if res and max_occupants_enabled and res.meta.max_occupants ~= nil then
module:log("info", "Setting max_occupants %d for room %s", res.meta.max_occupants, room.jid);
room._data.max_occupants = res.meta.max_occupants
end
if res and password_support_enabled and res.meta.password ~= nil then
module:log("info", "Setting password for room %s", room.jid);
room:set_password(res.meta.password);
end
end
local function room_pre_create(event)
local room = event.room
if is_healthcheck_room(room.jid) then
return;
end
local res = reservations[room.jid]
if res and lobby_support_enabled and res.meta.lobby then
module:log("info", "Enabling lobby for room %s", room.jid);
prosody.events.fire_event("create-persistent-lobby-room", { room = room; });
end
end
process_host_module(muc_component_host, function(host_module, host)
module:log("info", "Hook to muc-room-destroyed on %s", host);
host_module:hook("muc-room-destroyed", room_destroyed, -1);
if max_occupants_enabled or password_support_enabled then
module:log("info", "Hook to muc-room-created on %s (max_occupants or password integration enabled)", host);
host_module:hook("muc-room-created", room_created);
end
if lobby_support_enabled then
module:log("info", "Hook to muc-room-pre-create on %s (lobby integration enabled)", host);
host_module:hook("muc-room-pre-create", room_pre_create);
end
end);

View File

@@ -0,0 +1,15 @@
-- Handle room destroy requests it such a way that it can be suppressed by other
-- modules that handle room lifecycle and wish to keep the room alive.
function handle_room_destroy(event)
local room = event.room;
local reason = event.reason;
local caller = event.caller;
module:log('info', 'Destroying room %s (requested by %s)', room.jid, caller);
room:set_persistent(false);
room:destroy(nil, reason);
end
module:hook_global("maybe-destroy-room", handle_room_destroy, -1);
module:log('info', 'loaded');

View File

@@ -0,0 +1,6 @@
-- TODO: Remove this file after several stable releases when people update their configs
module:log('warn', 'mod_room_metadata is deprecated and will be removed in a future release. '
.. 'Please update your config by removing this module from the list of loaded modules.');
module:depends("jitsi_session");
module:depends("features_identity");

View File

@@ -0,0 +1,376 @@
-- This module implements a generic metadata storage system for rooms.
--
-- Component "metadata.jitmeet.example.com" "room_metadata_component"
-- muc_component = "conference.jitmeet.example.com"
-- breakout_rooms_component = "breakout.jitmeet.example.com"
local array = require 'util.array';
local filters = require 'util.filters';
local jid_node = require 'util.jid'.node;
local json = require 'util.json';
local st = require 'util.stanza';
local jid = require 'util.jid';
local util = module:require 'util';
local is_admin = util.is_admin;
local is_healthcheck_room = util.is_healthcheck_room;
local get_room_from_jid = util.get_room_from_jid;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local process_host_module = util.process_host_module;
local table_shallow_copy = util.table_shallow_copy;
local table_add = util.table_add;
local table_equals = util.table_equals;
local MUC_NS = 'http://jabber.org/protocol/muc';
local COMPONENT_IDENTITY_TYPE = 'room_metadata';
local FORM_KEY = 'muc#roominfo_jitsimetadata';
local muc_component_host = module:get_option_string('muc_component');
if muc_component_host == nil then
module:log('error', 'No muc_component specified. No muc to operate on!');
return;
end
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
if not main_virtual_host then
module:log('warn', 'No muc_mapper_domain_base option set.');
return;
end
local breakout_rooms_component_host = module:get_option_string('breakout_rooms_component');
module:log("info", "Starting room metadata for %s", muc_component_host);
local main_muc_module;
-- Utility functions
-- Returns json string with the metadata for the room.
-- @param room The room object.
-- @param metadata Optional metadata to use instead of the room's jitsiMetadata.
function getMetadataJSON(room, metadata)
local res, error = json.encode({
type = COMPONENT_IDENTITY_TYPE,
metadata = metadata or room.jitsiMetadata or {}
});
if not res then
module:log('error', 'Error encoding data room:%s', room.jid, error);
end
return res;
end
function broadcastMetadata(room, json_msg)
if not json_msg then
return;
end
for _, occupant in room:each_occupant() do
send_metadata(occupant, room, json_msg)
end
end
function send_metadata(occupant, room, json_msg)
if not json_msg or is_admin(occupant.bare_jid) then
local metadata_to_send = room.jitsiMetadata or {};
-- we want to send the main meeting participants only to jicofo
if is_admin(occupant.bare_jid) then
local participants;
local moderators = array();
if room._data.participants then
participants = array();
participants:append(room._data.participants);
end
if room._data.moderator_id then
moderators:push(room._data.moderator_id);
end
if room._data.moderators then
moderators:append(room._data.moderators);
end
metadata_to_send = table_shallow_copy(metadata_to_send);
metadata_to_send.participants = participants;
metadata_to_send.moderators = moderators;
module:log('info', 'Sending metadata to jicofo room=%s,meeting_id=%s', room.jid, room._data.meeting_id);
end
json_msg = getMetadataJSON(room, metadata_to_send);
end
local stanza = st.message({ from = module.host; to = occupant.jid; })
:tag('json-message', {
xmlns = 'http://jitsi.org/jitmeet',
room = internal_room_jid_match_rewrite(room.jid)
}):text(json_msg):up();
module:send(stanza);
end
-- Handling events
function room_created(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
room.sent_initial_metadata = {};
end
function on_message(event)
local session = event.origin;
-- Check the type of the incoming stanza to avoid loops:
if event.stanza.attr.type == 'error' then
return; -- We do not want to reply to these, so leave.
end
if not session or not session.jitsi_web_query_room then
return false;
end
local message = event.stanza:get_child(COMPONENT_IDENTITY_TYPE, 'http://jitsi.org/jitmeet');
local messageText = message:get_text();
if not message or not messageText then
return false;
end
local roomJid = message.attr.room;
local room = get_room_from_jid(room_jid_match_rewrite(roomJid));
if not room then
module:log('warn', 'No room found found for %s/%s',
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return false;
end
-- check that the participant requesting is a moderator and is an occupant in the room
local from = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant then
module:log('warn', 'No occupant %s found for %s', from, room.jid);
return false;
end
local jsonData, error = json.decode(messageText);
if jsonData == nil then -- invalid JSON
module:log("error", "Invalid JSON message: %s error:%s", messageText, error);
return false;
end
if jsonData.key == nil or jsonData.data == nil then
module:log("error", "Invalid JSON payload, key or data are missing: %s", messageText);
return false;
end
if occupant.role ~= 'moderator' then
-- will return a non nil filtered data to use, if it is nil, it is not allowed
local res = module:context(main_virtual_host):fire_event('jitsi-metadata-allow-moderation',
{ room = room; actor = occupant; key = jsonData.key ; data = jsonData.data; session = session; });
if not res then
module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
return false;
end
jsonData.data = res;
end
local old_value = room.jitsiMetadata[jsonData.key];
if not table_equals(old_value, jsonData.data) then
room.jitsiMetadata[jsonData.key] = jsonData.data;
module:log('info', 'Мetadata key "%s" updated by %s in room:%s,meeting_id:%s', jsonData.key, from, room.jid, room._data.meeting_id);
broadcastMetadata(room, getMetadataJSON(room));
-- fire and event for the change
main_muc_module:fire_event('jitsi-metadata-updated', { room = room; actor = occupant; key = jsonData.key; });
end
return true;
end
-- Module operations
-- handle messages to this component
module:hook("message/host", on_message);
-- operates on already loaded main muc module
function process_main_muc_loaded(main_muc, host_module)
main_muc_module = host_module;
module:log('debug', 'Main muc loaded');
module:log("info", "Hook to muc events on %s", muc_component_host);
host_module:hook("muc-room-created", room_created, -1);
-- The room metadata was updated internally (from another module).
host_module:hook("room-metadata-changed", function(event)
local room = event.room;
local json_msg = getMetadataJSON(room);
module:log('info', 'Metadata changed internally in room:%s,meeting_id:%s - broadcasting data:%s', room.jid, room._data.meeting_id, json_msg);
broadcastMetadata(room, json_msg);
end);
-- TODO: Once clients update to read/write metadata for startMuted policy we can drop this
-- this is to convert presence settings from old clients to metadata
host_module:hook('muc-broadcast-presence', function (event)
local actor, occupant, room, stanza, x = event.actor, event.occupant, event.room, event.stanza, event.x;
if is_healthcheck_room(room.jid) or occupant.role ~= 'moderator' then
return;
end
local startMuted = stanza:get_child('startmuted', 'http://jitsi.org/jitmeet/start-muted');
if not startMuted then
return;
end
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
local startMutedMetadata = room.jitsiMetadata.startMuted or {};
local audioNewValue = startMuted.attr.audio == 'true';
local videoNewValue = startMuted.attr.video == 'true';
local send_update = false;
if startMutedMetadata.audio ~= audioNewValue then
startMutedMetadata.audio = audioNewValue;
send_update = true;
end
if startMutedMetadata.video ~= videoNewValue then
startMutedMetadata.video = videoNewValue;
send_update = true;
end
if send_update then
room.jitsiMetadata.startMuted = startMutedMetadata;
host_module:fire_event('room-metadata-changed', { room = room; });
end
end);
-- The the connection jid for authenticated users (like jicofo) stays the same,
-- so leaving and re-joining will result not sending metatadata again.
-- Make sure we clear the sent_initial_metadata entry for the occupant on leave.
host_module:hook("muc-occupant-left", function(event)
local room, occupant = event.room, event.occupant;
if room.sent_initial_metadata then
room.sent_initial_metadata[jid.bare(event.occupant.jid)] = nil;
end
end);
end
-- process or waits to process the main muc component
process_host_module(muc_component_host, function(host_module, host)
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_main_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
-- breakout rooms support
function process_breakout_muc_loaded(breakout_muc, host_module)
module:log('debug', 'Breakout rooms muc loaded');
module:log("info", "Hook to muc events on %s", breakout_rooms_component_host);
host_module:hook("muc-room-created", room_created, -1);
end
if breakout_rooms_component_host then
process_host_module(breakout_rooms_component_host, function(host_module, host)
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_breakout_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_breakout_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
end
-- Send a message update for metadata before sending the first self presence
function filter_stanza(stanza, session)
if not stanza.attr or not stanza.attr.to or stanza.name ~= 'presence' or stanza.attr.type == 'unavailable' then
return stanza;
end
local bare_to = jid.bare(stanza.attr.to);
local muc_x = stanza:get_child('x', MUC_NS..'#user');
if not muc_x or not presence_check_status(muc_x, '110') then
return stanza;
end
local room = get_room_from_jid(room_jid_match_rewrite(jid.bare(stanza.attr.from)));
if not room or not room.sent_initial_metadata or is_healthcheck_room(room.jid) then
return stanza;
end
if room.sent_initial_metadata[bare_to] then
return stanza;
end
local occupant;
for _, o in room:each_occupant() do
if o.bare_jid == bare_to then
occupant = o;
end
end
if not occupant then
module:log('warn', 'No occupant %s found for %s', bare_to, room.jid);
return stanza;
end
room.sent_initial_metadata[bare_to] = true;
send_metadata(occupant, room);
return stanza;
end
function filter_session(session)
-- domain mapper is filtering on default priority 0
-- allowners is -1 and we need it after that, permissions is -2
filters.add_filter(session, 'stanzas/out', filter_stanza, -3);
end
-- enable filtering presences
filters.add_filter_hook(filter_session);
process_host_module(main_virtual_host, function(host_module)
module:context(host_module.host):fire_event('jitsi-add-identity', {
name = 'room_metadata'; host = module.host;
});
end);

View File

@@ -0,0 +1,164 @@
-----------------------------------------------------------
-- mod_roster_command: Manage rosters through prosodyctl
-- version 0.02
-----------------------------------------------------------
-- Copyright (C) 2011 Matthew Wild
-- Copyright (C) 2011 Adam Nielsen
--
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
-----------------------------------------------------------
if module.host ~= "*" then
module:log("error", "Do not load this module in Prosody, for correct usage see: https://modules.prosody.im/mod_roster_command.html");
return;
end
-- Workaround for lack of util.startup...
local prosody = _G.prosody;
local hosts = prosody.hosts;
prosody.bare_sessions = prosody.bare_sessions or {};
_G.bare_sessions = _G.bare_sessions or prosody.bare_sessions;
local usermanager = require "core.usermanager";
local rostermanager = require "core.rostermanager";
local storagemanager = require "core.storagemanager";
local jid = require "util.jid";
local warn = require"util.prosodyctl".show_warning;
-- Make a *one-way* subscription. User will see when contact is online,
-- contact will not see when user is online.
function subscribe(user_jid, contact_jid)
local user_username, user_host = jid.split(user_jid);
local contact_username, contact_host = jid.split(contact_jid);
if not hosts[user_host] then
warn("The host '%s' is not configured for this server.", user_host);
return;
end
if hosts[user_host].users.name == "null" then
storagemanager.initialize_host(user_host);
usermanager.initialize_host(user_host);
end
-- Update user's roster to say subscription request is pending. Bare hosts (e.g. components) don't have rosters.
if user_username ~= nil then
rostermanager.set_contact_pending_out(user_username, user_host, contact_jid);
end
if hosts[contact_host] then
if contact_host ~= user_host and hosts[contact_host].users.name == "null" then
storagemanager.initialize_host(contact_host);
usermanager.initialize_host(contact_host);
end
-- Update contact's roster to say subscription request is pending...
rostermanager.set_contact_pending_in(contact_username, contact_host, user_jid);
-- Update contact's roster to say subscription request approved...
rostermanager.subscribed(contact_username, contact_host, user_jid);
-- Update user's roster to say subscription request approved. Bare hosts (e.g. components) don't have rosters.
if user_username ~= nil then
rostermanager.process_inbound_subscription_approval(user_username, user_host, contact_jid);
end
end
end
-- Make a mutual subscription between jid1 and jid2. Each JID will see
-- when the other one is online.
function subscribe_both(jid1, jid2)
subscribe(jid1, jid2);
subscribe(jid2, jid1);
end
-- Unsubscribes user from contact (not contact from user, if subscribed).
function unsubscribe(user_jid, contact_jid)
local user_username, user_host = jid.split(user_jid);
local contact_username, contact_host = jid.split(contact_jid);
if not hosts[user_host] then
warn("The host '%s' is not configured for this server.", user_host);
return;
end
if hosts[user_host].users.name == "null" then
storagemanager.initialize_host(user_host);
usermanager.initialize_host(user_host);
end
-- Update user's roster to say subscription is cancelled...
rostermanager.unsubscribe(user_username, user_host, contact_jid);
if hosts[contact_host] then
if contact_host ~= user_host and hosts[contact_host].users.name == "null" then
storagemanager.initialize_host(contact_host);
usermanager.initialize_host(contact_host);
end
-- Update contact's roster to say subscription is cancelled...
rostermanager.unsubscribed(contact_username, contact_host, user_jid);
end
end
-- Cancel any subscription in either direction.
function unsubscribe_both(jid1, jid2)
unsubscribe(jid1, jid2);
unsubscribe(jid2, jid1);
end
-- Set the name shown and group used in the contact list
function rename(user_jid, contact_jid, contact_nick, contact_group)
local user_username, user_host = jid.split(user_jid);
if not hosts[user_host] then
warn("The host '%s' is not configured for this server.", user_host);
return;
end
if hosts[user_host].users.name == "null" then
storagemanager.initialize_host(user_host);
usermanager.initialize_host(user_host);
end
-- Load user's roster and find the contact
local roster = rostermanager.load_roster(user_username, user_host);
local item = roster[contact_jid];
if item then
if contact_nick then
item.name = contact_nick;
end
if contact_group then
item.groups = {}; -- Remove from all current groups
item.groups[contact_group] = true;
end
rostermanager.save_roster(user_username, user_host, roster);
end
end
function remove(user_jid, contact_jid)
unsubscribe_both(user_jid, contact_jid);
local user_username, user_host = jid.split(user_jid);
local roster = rostermanager.load_roster(user_username, user_host);
roster[contact_jid] = nil;
rostermanager.save_roster(user_username, user_host, roster);
end
function module.command(arg)
local command = arg[1];
if not command then
warn("Valid subcommands: (un)subscribe(_both) | rename");
return 0;
end
table.remove(arg, 1);
if command == "subscribe" then
subscribe(arg[1], arg[2]);
return 0;
elseif command == "subscribe_both" then
subscribe_both(arg[1], arg[2]);
return 0;
elseif command == "unsubscribe" then
unsubscribe(arg[1], arg[2]);
return 0;
elseif command == "unsubscribe_both" then
unsubscribe_both(arg[1], arg[2]);
return 0;
elseif command == "remove" then
remove(arg[1], arg[2]);
return 0;
elseif command == "rename" then
rename(arg[1], arg[2], arg[3], arg[4]);
return 0;
else
warn("Unknown command: %s", command);
return 1;
end
end

View File

@@ -0,0 +1,47 @@
# HG changeset patch
# User Boris Grozev <boris@jitsi.org>
# Date 1609874100 21600
# Tue Jan 05 13:15:00 2021 -0600
# Node ID f646babfc401494ff33f2126ef6c4df541ebf846
# Parent 456b9f608fcf9667cfba1bd7bf9eba2151af50d0
mod_roster_command: Fix subscription when the "user JID" is a bare domain.
Do not attempt to update the roster when the user is bare domain (e.g. a
component), since they don't have rosters and the attempt results in an error:
$ prosodyctl mod_roster_command subscribe proxy.example.com contact@example.com
xxxxxxxxxxFailed to execute command: Error: /usr/lib/prosody/core/rostermanager.lua:104: attempt to concatenate local 'username' (a nil value)
stack traceback:
/usr/lib/prosody/core/rostermanager.lua:104: in function 'load_roster'
/usr/lib/prosody/core/rostermanager.lua:305: in function 'set_contact_pending_out'
mod_roster_command.lua:44: in function 'subscribe'
diff -r 456b9f608fcf -r f646babfc401 mod_roster_command/mod_roster_command.lua
--- a/mod_roster_command/mod_roster_command.lua Tue Jan 05 13:49:50 2021 +0000
+++ b/mod_roster_command/mod_roster_command.lua Tue Jan 05 13:15:00 2021 -0600
@@ -40,8 +40,10 @@
storagemanager.initialize_host(user_host);
usermanager.initialize_host(user_host);
end
- -- Update user's roster to say subscription request is pending...
- rostermanager.set_contact_pending_out(user_username, user_host, contact_jid);
+ -- Update user's roster to say subscription request is pending. Bare hosts (e.g. components) don't have rosters.
+ if user_username ~= nil then
+ rostermanager.set_contact_pending_out(user_username, user_host, contact_jid);
+ end
if hosts[contact_host] then
if contact_host ~= user_host and hosts[contact_host].users.name == "null" then
storagemanager.initialize_host(contact_host);
@@ -51,8 +53,10 @@
rostermanager.set_contact_pending_in(contact_username, contact_host, user_jid);
-- Update contact's roster to say subscription request approved...
rostermanager.subscribed(contact_username, contact_host, user_jid);
- -- Update user's roster to say subscription request approved...
- rostermanager.process_inbound_subscription_approval(user_username, user_host, contact_jid);
+ -- Update user's roster to say subscription request approved. Bare hosts (e.g. components) don't have rosters.
+ if user_username ~= nil then
+ rostermanager.process_inbound_subscription_approval(user_username, user_host, contact_jid);
+ end
end
end

View File

@@ -0,0 +1,26 @@
-- Using as a base version https://hg.prosody.im/prosody-modules/file/c1a8ce147885/mod_s2s_whitelist/mod_s2s_whitelist.lua
local st = require "util.stanza";
local whitelist = module:get_option_inherited_set("s2s_whitelist", {});
module:hook("route/remote", function (event)
if not whitelist:contains(event.to_host) then
-- make sure we do not send error replies for errors
if event.stanza.attr.type == 'error' then
module:log('debug', 'Not whitelisted destination domain for an error: %s', event.stanza);
return true;
end
module:send(st.error_reply(event.stanza, "cancel", "not-allowed", "Communication with this domain is restricted"));
return true;
end
end, 100);
module:hook("s2s-stream-features", function (event)
if not whitelist:contains(event.origin.from_host) then
event.origin:close({
condition = "policy-violation";
text = "Communication with this domain is restricted";
});
end
end, 1000);

View File

@@ -0,0 +1,20 @@
-- Using as a base version https://hg.prosody.im/prosody-modules/file/6cf2f32dbf40/mod_s2sout_override/mod_s2sout_override.lua
--% requires: s2sout-pre-connect-event
local url = require"socket.url";
local basic_resolver = require "net.resolvers.basic";
local override_for = module:get_option(module.name, {}); -- map of host to "tcp://example.com:5269"
module:hook("s2sout-pre-connect", function(event)
local override = override_for[event.session.to_host];
if type(override) == "string" then
override = url.parse(override);
end
if type(override) == "table" and override.scheme == "tcp" and type(override.host) == "string" then
event.resolver = basic_resolver.new(override.host, tonumber(override.port) or 5269, override.scheme, {});
elseif type(override) == "table" and override.scheme == "tls" and type(override.host) == "string" then
event.resolver = basic_resolver.new(override.host, tonumber(override.port) or 5270, "tcp",
{ servername = event.session.to_host; sslctx = event.session.ssl_ctx });
end
end);

View File

@@ -0,0 +1,20 @@
-- Using version https://hg.prosody.im/prosody-modules/file/6c806a99f802/mod_secure_interfaces/mod_secure_interfaces.lua
local secure_interfaces = module:get_option_set("secure_interfaces", { "127.0.0.1", "::1" });
module:hook("stream-features", function (event)
local session = event.origin;
if session.type ~= "c2s_unauthed" then return; end
local socket = session.conn:socket();
if not socket.getsockname then
module:log("debug", "Unable to determine local address of incoming connection");
return;
end
local localip = socket:getsockname();
if secure_interfaces:contains(localip) then
-- module:log("debug", "Marking session from %s to %s as secure", session.ip or "[?]", localip);
session.secure = true;
session.conn.starttls = false;
-- else
-- module:log("debug", "Not marking session from %s to %s as secure", session.ip or "[?]", localip);
end
end, 2500);

View File

@@ -0,0 +1,138 @@
-- to be enabled under the main virtual host with all required settings
-- short_lived_token = {
-- issuer = 'myissuer';
-- accepted_audiences = { 'file-sharing' };
-- key_path = '/etc/prosody/short_lived_token.key';
-- key_id = 'my_kid';
-- ttl_seconds = 30;
-- };
-- The key in key_path can be generated via: openssl genrsa -out $PRIVATE_KEY_PATH 2048
-- And you can get the public key from it, which can be used ot verify those tokens via:
-- openssl rsa -in $PRIVATE_KEY_PATH -pubout -out $PUBLIC_KEY_PATH
local jid = require 'util.jid';
local st = require 'util.stanza';
local jwt = module:require 'luajwtjitsi';
local util = module:require 'util';
local is_vpaas = util.is_vpaas;
local process_host_module = util.process_host_module;
local table_find = util.table_find;
local create_throttle = require 'prosody.util.throttle'.create;
local SERVICE_TYPE = 'short-lived-token';
local options = module:get_option('short_lived_token');
if not (options.issuer and options.accepted_audiences
and options.key_path and options.key_id and options.ttl_seconds) then
module:log('error', 'Missing required options for short_lived_token');
return;
end
local f = io.open(options.key_path, 'r');
if f then
options.key = f:read('*all');
f:close();
end
local accepted_requests = {};
for _, host in pairs(options.accepted_audiences) do
accepted_requests[string.format('%s:%s:0', SERVICE_TYPE, host)] = host;
end
local server_region_name = module:get_option_string('region_name');
local main_muc_component_host = module:get_option_string('main_muc');
if main_muc_component_host == nil then
module:log('error', 'main_muc not configured. Cannot proceed.');
return;
end
local main_muc_service;
function generateToken(session, audience, room, occupant)
local t = os.time();
local exp = t + options.ttl_seconds;
local presence = occupant:get_presence(session.full_jid);
local _, _, id = extract_subdomain(jid.node(room.jid));
local payload = {
iss = options.issuer,
aud = audience,
nbf = t,
exp = exp,
sub = session.jitsi_web_query_prefix or module.host,
context = {
group = session.jitsi_meet_context_group or session.granted_jitsi_meet_context_group_id,
user = session.jitsi_meet_context_user or {
id = session.full_jid,
name = presence:get_child_text('nick', 'http://jabber.org/protocol/nick'),
email = presence:get_child_text("email") or nil,
nick = jid.resource(occupant.nick)
},
features = session.jitsi_meet_context_features
},
room = session.jitsi_web_query_room,
meeting_id = room._data.meetingId,
granted_from = session.granted_jitsi_meet_context_user_id,
customer_id = id or session.jitsi_meet_context_group or session.granted_jitsi_meet_context_group_id,
backend_region = server_region_name,
user_region = session.user_region
};
local alg = 'RS256';
local token, err = jwt.encode(payload, options.key, alg, { kid = options.key_id });
if not err then
return token
else
module:log('error', 'Error generating token: %s', err);
return ''
end
end
module:hook('external_service/credentials', function (event)
local requested_credentials, services, session, stanza
= event.requested_credentials, event.services, event.origin, event.stanza;
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
session.send(st.error_reply(stanza, 'cancel', 'not-allowed'));
return;
end
local occupant = room:get_occupant_by_real_jid(session.full_jid);
if not occupant then
session.send(st.error_reply(stanza, 'cancel', 'not-allowed'));
return;
end
for request in requested_credentials do
local host = accepted_requests[request];
if host then
services:push({
type = SERVICE_TYPE;
host = host;
username = 'token';
password = generateToken(session, host, room, occupant);
expires = os.time() + options.ttl_seconds;
restricted = true;
transport = 'https';
port = 443;
});
end
end
end);
process_host_module(main_muc_component_host, function(host_module, host)
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
main_muc_service = muc_module;
else
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
main_muc_service = prosody.hosts[host].modules.muc;
end
end);
end
end);

View File

@@ -0,0 +1,6 @@
-- TODO: Remove this file after several stable releases when people update their configs
module:log('warn', 'mod_speakerstats is deprecated and will be removed in a future release. '
.. 'Please update your config by removing this module from the list of loaded modules.');
module:depends('jitsi_session');
module:depends('features_identity');

View File

@@ -0,0 +1,384 @@
local util = module:require "util";
local is_admin = util.is_admin;
local get_room_from_jid = util.get_room_from_jid;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local is_jibri = util.is_jibri;
local is_healthcheck_room = util.is_healthcheck_room;
local process_host_module = util.process_host_module;
local is_transcriber_jigasi = util.is_transcriber_jigasi;
local jid_resource = require "util.jid".resource;
local st = require "util.stanza";
local socket = require "socket";
local json = require 'cjson.safe';
local jid_split = require 'util.jid'.split;
-- we use async to detect Prosody 0.10 and earlier
local have_async = pcall(require, "util.async");
if not have_async then
module:log("warn", "speaker stats will not work with Prosody version 0.10 or less.");
return;
end
local muc_component_host = module:get_option_string("muc_component");
local main_virtual_host = module:get_option_string("muc_mapper_domain_base");
if muc_component_host == nil or main_virtual_host == nil then
module:log("error", "No muc_component specified. No muc to operate on!");
return;
end
local breakout_room_component_host = "breakout." .. main_virtual_host;
module:log("info", "Starting speakerstats for %s", muc_component_host);
local main_muc_service;
-- Searches all rooms in the main muc component that holds a breakout room
-- caches it if found so we don't search it again
-- we should not cache objects in _data as this is being serialized when calling room:save()
local function get_main_room(breakout_room)
if breakout_room.main_room then
return breakout_room.main_room;
end
-- let's search all rooms to find the main room
for room in main_muc_service.each_room() do
if room._data and room._data.breakout_rooms_active and room._data.breakout_rooms[breakout_room.jid] then
breakout_room.main_room = room;
return room;
end
end
end
-- receives messages from client currently connected to the room
-- clients indicates their own dominant speaker events
function on_message(event)
-- Check the type of the incoming stanza to avoid loops:
if event.stanza.attr.type == "error" then
return; -- We do not want to reply to these, so leave.
end
local speakerStats
= event.stanza:get_child('speakerstats', 'http://jitsi.org/jitmeet');
if speakerStats then
local roomAddress = speakerStats.attr.room;
local silence = speakerStats.attr.silence == 'true';
local room = get_room_from_jid(room_jid_match_rewrite(roomAddress));
if not room then
module:log("warn", "No room found %s", roomAddress);
return false;
end
if not room.speakerStats then
module:log("warn", "No speakerStats found for %s", roomAddress);
return false;
end
local roomSpeakerStats = room.speakerStats;
local from = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant then
module:log("warn", "No occupant %s found for %s", from, roomAddress);
return false;
end
local newDominantSpeaker = roomSpeakerStats[occupant.jid];
local oldDominantSpeakerId = roomSpeakerStats['dominantSpeakerId'];
if oldDominantSpeakerId and occupant.jid ~= oldDominantSpeakerId then
local oldDominantSpeaker = roomSpeakerStats[oldDominantSpeakerId];
if oldDominantSpeaker then
oldDominantSpeaker:setDominantSpeaker(false, false);
end
end
if newDominantSpeaker then
newDominantSpeaker:setDominantSpeaker(true, silence);
end
room.speakerStats['dominantSpeakerId'] = occupant.jid;
end
local newFaceLandmarks = event.stanza:get_child('faceLandmarks', 'http://jitsi.org/jitmeet');
if newFaceLandmarks then
local roomAddress = newFaceLandmarks.attr.room;
local room = get_room_from_jid(room_jid_match_rewrite(roomAddress));
if not room then
module:log("warn", "No room found %s", roomAddress);
return false;
end
if not room.speakerStats then
module:log("warn", "No speakerStats found for %s", roomAddress);
return false;
end
local from = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant or not room.speakerStats[occupant.jid] then
module:log("warn", "No occupant %s found for %s", from, roomAddress);
return false;
end
local faceLandmarks = room.speakerStats[occupant.jid].faceLandmarks;
table.insert(faceLandmarks,
{
faceExpression = newFaceLandmarks.attr.faceExpression,
timestamp = tonumber(newFaceLandmarks.attr.timestamp),
duration = tonumber(newFaceLandmarks.attr.duration),
})
end
return true
end
--- Start SpeakerStats implementation
local SpeakerStats = {};
SpeakerStats.__index = SpeakerStats;
function new_SpeakerStats(nick, context_user)
return setmetatable({
totalDominantSpeakerTime = 0;
_dominantSpeakerStart = 0;
_isSilent = false;
_isDominantSpeaker = false;
nick = nick;
context_user = context_user;
displayName = nil;
faceLandmarks = {};
}, SpeakerStats);
end
-- Changes the dominantSpeaker data for current occupant
-- saves start time if it is new dominat speaker
-- or calculates and accumulates time of speaking
function SpeakerStats:setDominantSpeaker(isNowDominantSpeaker, silence)
-- module:log("debug", "set isDominant %s for %s", tostring(isNowDominantSpeaker), self.nick);
local now = socket.gettime()*1000;
if not self:isDominantSpeaker() and isNowDominantSpeaker and not silence then
self._dominantSpeakerStart = now;
elseif self:isDominantSpeaker() then
if not isNowDominantSpeaker then
if not self._isSilent then
local timeElapsed = math.floor(now - self._dominantSpeakerStart);
self.totalDominantSpeakerTime = self.totalDominantSpeakerTime + timeElapsed;
self._dominantSpeakerStart = 0;
end
elseif self._isSilent and not silence then
self._dominantSpeakerStart = now;
elseif not self._isSilent and silence then
local timeElapsed = math.floor(now - self._dominantSpeakerStart);
self.totalDominantSpeakerTime = self.totalDominantSpeakerTime + timeElapsed;
self._dominantSpeakerStart = 0;
end
end
self._isDominantSpeaker = isNowDominantSpeaker;
self._isSilent = silence;
end
-- Returns true if the tracked user is currently a dominant speaker.
function SpeakerStats:isDominantSpeaker()
return self._isDominantSpeaker;
end
-- Returns true if the tracked user is currently silent.
function SpeakerStats:isSilent()
return self._isSilent;
end
--- End SpeakerStats
-- create speakerStats for the room
function room_created(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return ;
end
room.speakerStats = {};
room.speakerStats.sessionId = room._data.meetingId;
end
-- create speakerStats for the breakout
function breakout_room_created(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return ;
end
local main_room = get_main_room(room);
room.speakerStats = {};
room.speakerStats.isBreakout = true
room.speakerStats.breakoutRoomId = jid_split(room.jid)
room.speakerStats.sessionId = main_room._data.meetingId;
end
-- Create SpeakerStats object for the joined user
function occupant_joined(event)
local occupant, room, stanza = event.occupant, event.room, event.stanza;
if is_healthcheck_room(room.jid)
or is_admin(occupant.bare_jid)
or is_transcriber_jigasi(stanza)
or is_jibri(occupant) then
return;
end
local nick = jid_resource(occupant.nick);
if room.speakerStats then
-- lets send the current speaker stats to that user, so he can update
-- its local stats
if next(room.speakerStats) ~= nil then
local users_json = {};
for jid, values in pairs(room.speakerStats) do
-- skip reporting those without a nick('dominantSpeakerId')
-- and skip focus if sneaked into the table
if values and type(values) == 'table' and values.nick ~= nil and values.nick ~= 'focus' then
local totalDominantSpeakerTime = values.totalDominantSpeakerTime;
local faceLandmarks = values.faceLandmarks;
if totalDominantSpeakerTime > 0 or room:get_occupant_jid(jid) == nil or values:isDominantSpeaker()
or next(faceLandmarks) ~= nil then
-- before sending we need to calculate current dominant speaker state
if values:isDominantSpeaker() and not values:isSilent() then
local timeElapsed = math.floor(socket.gettime()*1000 - values._dominantSpeakerStart);
totalDominantSpeakerTime = totalDominantSpeakerTime + timeElapsed;
end
users_json[values.nick] = {
displayName = values.displayName,
totalDominantSpeakerTime = totalDominantSpeakerTime,
faceLandmarks = faceLandmarks
};
end
end
end
if next(users_json) ~= nil then
local body_json = {};
body_json.type = 'speakerstats';
body_json.users = users_json;
local json_msg_str, error = json.encode(body_json);
if json_msg_str then
local stanza = st.message({
from = module.host;
to = occupant.jid; })
:tag("json-message", {xmlns='http://jitsi.org/jitmeet'})
:text(json_msg_str):up();
room:route_stanza(stanza);
else
module:log('error', 'Error encoding room:%s error:%s', room.jid, error);
end
end
end
local context_user = event.origin and event.origin.jitsi_meet_context_user or nil;
room.speakerStats[occupant.jid] = new_SpeakerStats(nick, context_user);
end
end
-- Occupant left set its dominant speaker to false and update the store the
-- display name
function occupant_leaving(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
if not room.speakerStats then
return;
end
local occupant = event.occupant;
local speakerStatsForOccupant = room.speakerStats[occupant.jid];
if speakerStatsForOccupant then
speakerStatsForOccupant:setDominantSpeaker(false, false);
-- set display name
local displayName = occupant:get_presence():get_child_text(
'nick', 'http://jabber.org/protocol/nick');
speakerStatsForOccupant.displayName = displayName;
end
end
-- Conference ended, send speaker stats
function room_destroyed(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
module:fire_event("send-speaker-stats", { room = room; roomSpeakerStats = room.speakerStats; });
end
module:hook("message/host", on_message);
function process_main_muc_loaded(main_muc, host_module)
-- the conference muc component
module:log("info", "Hook to muc events on %s", host_module.host);
main_muc_service = main_muc;
module:log("info", "Main muc service %s", main_muc_service)
host_module:hook("muc-room-created", room_created, -1);
host_module:hook("muc-occupant-joined", occupant_joined, -1);
host_module:hook("muc-occupant-pre-leave", occupant_leaving, -1);
host_module:hook("muc-room-destroyed", room_destroyed, -1);
end
function process_breakout_muc_loaded(breakout_muc, host_module)
-- the Breakout muc component
module:log("info", "Hook to muc events on %s", host_module.host);
host_module:hook("muc-room-created", breakout_room_created, -1);
host_module:hook("muc-occupant-joined", occupant_joined, -1);
host_module:hook("muc-occupant-pre-leave", occupant_leaving, -1);
host_module:hook("muc-room-destroyed", room_destroyed, -1);
end
-- process or waits to process the conference muc component
process_host_module(muc_component_host, function(host_module, host)
module:log('info', 'Conference component loaded %s', host);
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_main_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
-- process or waits to process the breakout rooms muc component
process_host_module(breakout_room_component_host, function(host_module, host)
module:log('info', 'Breakout component loaded %s', host);
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_breakout_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_breakout_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
process_host_module(main_virtual_host, function(host_module)
module:context(host_module.host):fire_event('jitsi-add-identity', {
name = 'speakerstats'; host = module.host;
});
end);

View File

@@ -0,0 +1,125 @@
-- Module which can be used as an http endpoint to send system private chat messages to meeting participants. The provided token
--- in the request is verified whether it has the right to do so. This module should be loaded under the virtual host.
-- Copyright (C) 2024-present 8x8, Inc.
-- curl https://{host}/send-system-chat-message -d '{"message": "testmessage", "connectionJIDs": ["{connection_jid}"], "room": "{room_jid}"}' -H "content-type: application/json" -H "authorization: Bearer {token}"
local util = module:require "util";
local token_util = module:require "token/util".new(module);
local async_handler_wrapper = util.async_handler_wrapper;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local starts_with = util.starts_with;
local get_room_from_jid = util.get_room_from_jid;
local st = require "util.stanza";
local json = require "cjson.safe";
local asapKeyServer = module:get_option_string("prosody_password_public_key_repo_url", "");
if asapKeyServer then
-- init token util with our asap keyserver
token_util:set_asap_key_server(asapKeyServer)
end
function verify_token(token)
if token == nil then
module:log("warn", "no token provided");
return false;
end
local session = {};
session.auth_token = token;
local verified, reason, msg = token_util:process_and_verify_token(session);
if not verified then
module:log("warn", "not a valid token %s %s", tostring(reason), tostring(msg));
return false;
end
return true;
end
function handle_send_system_message (event)
local request = event.request;
module:log("debug", "Request for sending a system message received: reqid %s", request.headers["request_id"])
-- verify payload
if request.headers.content_type ~= "application/json"
or (not request.body or #request.body == 0) then
module:log("error", "Wrong content type: %s or missing payload", request.headers.content_type);
return { status_code = 400; }
end
local payload = json.decode(request.body);
if not payload then
module:log("error", "Request body is missing");
return { status_code = 400; }
end
local displayName = payload["displayName"];
local message = payload["message"];
local connectionJIDs = payload["connectionJIDs"];
local payload_room = payload["room"];
if not message or not connectionJIDs or not payload_room then
module:log("error", "One of [message, connectionJIDs, room] was not provided");
return { status_code = 400; }
end
local room_jid = room_jid_match_rewrite(payload_room);
local room = get_room_from_jid(room_jid);
if not room then
module:log("error", "Room %s not found", room_jid);
return { status_code = 404; }
end
-- verify access
local token = request.headers["authorization"]
if not token then
module:log("error", "Authorization header was not provided for conference %s", room_jid)
return { status_code = 401 };
end
if starts_with(token, 'Bearer ') then
token = token:sub(8, #token)
else
module:log("error", "Authorization header is invalid")
return { status_code = 401 };
end
if not verify_token(token, room_jid) then
return { status_code = 401 };
end
local data = {
displayName = displayName,
type = "system_chat_message",
message = message,
};
for _, to in ipairs(connectionJIDs) do
local stanza = st.message({
from = room.jid,
to = to
})
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' })
:text(json.encode(data))
:up();
room:route_stanza(stanza);
end
return { status_code = 200 };
end
module:log("info", "Adding http handler for /send-system-chat-message on %s", module.host);
module:depends("http");
module:provides("http", {
default_path = "/";
route = {
["POST send-system-chat-message"] = function(event)
return async_handler_wrapper(event, handle_send_system_message)
end;
};
});

View File

@@ -0,0 +1,137 @@
-- Token authentication
-- Copyright (C) 2021-present 8x8, Inc.
local log = module._log;
local host = module.host;
local st = require "util.stanza";
local jid_split = require 'util.jid'.split;
local jid_bare = require 'util.jid'.bare;
local util = module:require 'util';
local is_admin = util.is_admin;
local DEBUG = false;
local measure_success = module:measure('success', 'counter');
local measure_fail = module:measure('fail', 'counter');
local parentHostName = string.gmatch(tostring(host), "%w+.(%w.+)")();
if parentHostName == nil then
module:log("error", "Failed to start - unable to get parent hostname");
return;
end
local parentCtx = module:context(parentHostName);
if parentCtx == nil then
module:log("error",
"Failed to start - unable to get parent context for host: %s",
tostring(parentHostName));
return;
end
local token_util = module:require "token/util".new(parentCtx);
-- no token configuration
if token_util == nil then
return;
end
module:log("debug",
"%s - starting MUC token verifier app_id: %s app_secret: %s allow empty: %s",
tostring(host), tostring(token_util.appId), tostring(token_util.appSecret),
tostring(token_util.allowEmptyToken));
-- option to disable room modification (sending muc config form) for guest that do not provide token
local require_token_for_moderation;
-- option to allow domains to skip token verification
local allowlist;
local function load_config()
require_token_for_moderation = module:get_option_boolean("token_verification_require_token_for_moderation");
allowlist = module:get_option_set('token_verification_allowlist', {});
end
load_config();
-- verify user and whether he is allowed to join a room based on the token information
local function verify_user(session, stanza)
if DEBUG then
module:log("debug", "Session token: %s, session room: %s",
tostring(session.auth_token), tostring(session.jitsi_meet_room));
end
-- token not required for admin users
local user_jid = stanza.attr.from;
if is_admin(user_jid) then
if DEBUG then module:log("debug", "Token not required from admin user: %s", user_jid); end
return true;
end
-- token not required for users matching allow list
local user_bare_jid = jid_bare(user_jid);
local _, user_domain = jid_split(user_jid);
-- allowlist for participants
if allowlist:contains(user_domain) or allowlist:contains(user_bare_jid) then
if DEBUG then module:log("debug", "Token not required from user in allow list: %s", user_jid); end
return true;
end
if DEBUG then module:log("debug", "Will verify token for user: %s, room: %s ", user_jid, stanza.attr.to); end
if not token_util:verify_room(session, stanza.attr.to) then
module:log("error", "Token %s not allowed to join: %s",
tostring(session.auth_token), tostring(stanza.attr.to));
session.send(
st.error_reply(
stanza, "cancel", "not-allowed", "Room and token mismatched"));
return false; -- we need to just return non nil
end
if DEBUG then module:log("debug", "allowed: %s to enter/create room: %s", user_jid, stanza.attr.to); end
return true;
end
module:hook("muc-room-pre-create", function(event)
local origin, stanza = event.origin, event.stanza;
if DEBUG then module:log("debug", "pre create: %s %s", tostring(origin), tostring(stanza)); end
if not verify_user(origin, stanza) then
measure_fail(1);
return true; -- Returning any value other than nil will halt processing of the event
end
measure_success(1);
end, 99);
module:hook("muc-occupant-pre-join", function(event)
local origin, room, stanza = event.origin, event.room, event.stanza;
if DEBUG then module:log("debug", "pre join: %s %s", tostring(room), tostring(stanza)); end
if not verify_user(origin, stanza) then
measure_fail(1);
return true; -- Returning any value other than nil will halt processing of the event
end
measure_success(1);
end, 99);
for event_name, method in pairs {
-- Normal room interactions
["iq-set/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ;
-- Host room
["iq-set/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ;
} do
module:hook(event_name, function (event)
local session, stanza = event.origin, event.stanza;
-- if we do not require token we pass it through(default behaviour)
-- or the request is coming from admin (focus)
if not require_token_for_moderation or is_admin(stanza.attr.from) then
return;
end
-- jitsi_meet_room is set after the token had been verified
if not session.auth_token or not session.jitsi_meet_room then
session.send(
st.error_reply(
stanza, "cancel", "not-allowed", "Room modification disabled for guests"));
return true;
end
end, -1); -- the default prosody hook is on -2
end
module:hook_global('config-reloaded', load_config);

View File

@@ -0,0 +1,80 @@
-- XEP-0215 implementation for time-limited turn credentials
-- Copyright (C) 2012-2014 Philipp Hancke
-- This file is MIT/X11 licensed.
--turncredentials_secret = "keepthissecret";
--turncredentials = {
-- { type = "stun", host = "8.8.8.8" },
-- { type = "turn", host = "8.8.8.8", port = "3478" },
-- { type = "turn", host = "8.8.8.8", port = "80", transport = "tcp" }
--}
-- for stun servers, host is required, port defaults to 3478
-- for turn servers, host is required, port defaults to tcp,
-- transport defaults to udp
-- hosts can be a list of server names / ips for random
-- choice loadbalancing
local st = require "util.stanza";
local hmac_sha1 = require "util.hashes".hmac_sha1;
local base64 = require "util.encodings".base64;
local os_time = os.time;
local secret = module:get_option_string("turncredentials_secret");
local ttl = module:get_option_number("turncredentials_ttl", 86400);
local hosts = module:get_option("turncredentials") or {};
if not (secret) then
module:log("error", "turncredentials not configured");
return;
end
module:add_feature("urn:xmpp:extdisco:1");
function random(arr)
local index = math.random(1, #arr);
return arr[index];
end
module:hook_global("config-reloaded", function()
module:log("debug", "config-reloaded")
secret = module:get_option_string("turncredentials_secret");
ttl = module:get_option_number("turncredentials_ttl", 86400);
hosts = module:get_option("turncredentials") or {};
end);
module:hook("iq-get/host/urn:xmpp:extdisco:1:services", function(event)
local origin, stanza = event.origin, event.stanza;
if origin.type ~= "c2s" then
return;
end
local now = os_time() + ttl;
local userpart = tostring(now);
local nonce = base64.encode(hmac_sha1(secret, tostring(userpart), false));
local reply = st.reply(stanza):tag("services", {xmlns = "urn:xmpp:extdisco:1"})
for idx, item in pairs(hosts) do
if item.type == "stun" or item.type == "stuns" then
-- stun items need host and port (defaults to 3478)
reply:tag("service",
{ type = item.type, host = item.host, port = tostring(item.port) or "3478" }
):up();
elseif item.type == "turn" or item.type == "turns" then
local turn = {}
-- turn items need host, port (defaults to 3478),
-- transport (defaults to udp)
-- username, password, ttl
turn.type = item.type;
turn.port = tostring(item.port);
turn.transport = item.transport;
turn.username = userpart;
turn.password = nonce;
turn.ttl = tostring(ttl);
if item.hosts then
turn.host = random(item.hosts)
else
turn.host = item.host
end
reply:tag("service", turn):up();
end
end
origin.send(reply);
return true;
end);

View File

@@ -0,0 +1,31 @@
-- http endpoint to expose turn credentials for other services
-- Copyright (C) 2023-present 8x8, Inc.
local ext_services = module:depends("external_services");
local get_services = ext_services.get_services;
local async_handler_wrapper = module:require "util".async_handler_wrapper;
local json = require 'cjson.safe';
--- Handles request for retrieving turn credentials
-- @param event the http event, holds the request query
-- @return GET response, containing a json with participants details
function handle_get_turn_credentials (event)
local GET_response = {
headers = {
content_type = "application/json";
};
body = json.encode(get_services());
};
return GET_response;
end;
function module.load()
module:depends("http");
module:provides("http", {
default_path = "/";
route = {
["GET turn-credentials"] = function (event) return async_handler_wrapper(event,handle_get_turn_credentials) end;
};
});
end

View File

@@ -0,0 +1,490 @@
--- activate under main vhost
--- In /etc/hosts add:
--- vm1-ip-address visitors1.domain.com
--- vm1-ip-address conference.visitors1.domain.com
--- vm2-ip-address visitors2.domain.com
--- vm2-ip-address conference.visitors2.domain.com
--- Enable in global modules: 's2s_bidi' and 'certs_all'
--- Make sure 's2s' is not in modules_disabled
--- Open port 5269 on the provider side and on the firewall on the machine (iptables -I INPUT 4 -p tcp -m tcp --dport 5269 -j ACCEPT)
--- NOTE: Make sure all communication between prosodies is using the real jids ([foo]room1@muc.example.com)
local st = require 'util.stanza';
local jid = require 'util.jid';
local new_id = require 'util.id'.medium;
local util = module:require 'util';
local filter_identity_from_presence = util.filter_identity_from_presence;
local is_admin = util.is_admin;
local presence_check_status = util.presence_check_status;
local process_host_module = util.process_host_module;
local is_transcriber_jigasi = util.is_transcriber_jigasi;
local json = require 'cjson.safe';
-- Debug flag
local DEBUG = false;
local MUC_NS = 'http://jabber.org/protocol/muc';
-- required parameter for custom muc component prefix, defaults to 'conference'
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
local main_muc_component_config = module:get_option_string('main_muc');
if main_muc_component_config == nil then
module:log('error', 'visitors rooms not enabled missing main_muc config');
return ;
end
-- A list of domains which to be ignored for visitors. For occupants using those domain we do not propagate them
-- to visitor nodes and we do not update them with presence changes
local ignore_list = module:get_option_set('visitors_ignore_list', {});
-- Advertise the component for discovery via disco#items
module:add_identity('component', 'visitors', 'visitors.'..module.host);
local sent_iq_cache = require 'util.cache'.new(200);
-- visitors_nodes = {
-- roomjid1 = {
-- nodes = {
-- ['conference.visitors1.jid'] = 2, // number of main participants, on 0 we clean it
-- ['conference.visitors2.jid'] = 3
-- }
-- },
-- roomjid2 = {}
--}
local visitors_nodes = {};
-- sends connect or update iq
-- @parameter type - Type of iq to send 'connect' or 'update'
local function send_visitors_iq(conference_service, room, type)
-- send iq informing the vnode that the connect is done and it will allow visitors to join
local iq_id = new_id();
sent_iq_cache:set(iq_id, socket.gettime());
local visitors_iq = st.iq({
type = 'set',
to = conference_service,
from = module.host,
id = iq_id })
:tag('visitors', { xmlns = 'jitsi:visitors',
room = jid.join(jid.node(room.jid), conference_service) })
:tag(type, { xmlns = 'jitsi:visitors',
password = type ~= 'disconnect' and room:get_password() or '',
lobby = room._data.lobbyroom and 'true' or 'false',
meetingId = room._data.meetingId,
createdTimestamp = room.created_timestamp and tostring(room.created_timestamp) or nil
});
if type == 'update' then
visitors_iq:tag('moderators', { xmlns = 'jitsi:visitors' });
for _, o in room:each_occupant() do
if not is_admin(o.bare_jid) and o.role == 'moderator' then
visitors_iq:tag('item', { epId = jid.resource(o.nick) }):up();
end
end
visitors_iq:up();
-- files that are shared in the room
if room.jitsi_shared_files then
visitors_iq:tag('files', { xmlns = 'jitsi:visitors' });
for k, v in pairs(room.jitsi_shared_files) do
visitors_iq:tag('file', {
id = k
}):text(json.encode(v)):up();
end
visitors_iq:up();
end
end
visitors_iq:up();
module:send(visitors_iq);
end
-- Filter out identity information (nick name, email, etc) from a presence stanza,
-- if the hideDisplayNameForGuests option for the room is set.
-- This is applied to presence of main room participants before it is sent out to vnodes.
local function filter_stanza_nick_if_needed(stanza, room)
if not stanza or stanza.name ~= 'presence' or stanza.attr.type == 'error' or stanza.attr.type == 'unavailable' then
return stanza;
end
-- if hideDisplayNameForGuests we want to drop any display name from the presence stanza
if room and (room._data.hideDisplayNameForGuests or room._data.hideDisplayNameForAll) then
return filter_identity_from_presence(stanza);
end
return stanza;
end
-- an event received from visitors component, which receives iqs from jicofo
local function connect_vnode(event)
local room, vnode = event.room, event.vnode;
local conference_service = muc_domain_prefix..'.'..vnode..'.meet.jitsi';
if visitors_nodes[room.jid] and
visitors_nodes[room.jid].nodes and
visitors_nodes[room.jid].nodes[conference_service] then
-- nothing to do
return;
end
if visitors_nodes[room.jid] == nil then
visitors_nodes[room.jid] = {};
end
if visitors_nodes[room.jid].nodes == nil then
visitors_nodes[room.jid].nodes = {};
end
local sent_main_participants = 0;
-- send update initially so we can report the moderators that will join
send_visitors_iq(conference_service, room, 'update');
for _, o in room:each_occupant() do
if not is_admin(o.bare_jid) then
local fmuc_pr = filter_stanza_nick_if_needed(st.clone(o:get_presence()), room);
local user, _, res = jid.split(o.nick);
fmuc_pr.attr.to = jid.join(user, conference_service , res);
fmuc_pr.attr.from = o.jid;
-- add <x>
fmuc_pr:tag('x', { xmlns = MUC_NS });
-- if there is a password on the main room let's add the password for the vnode join
-- as we will set the password to the vnode room and we will need it
local pass = room:get_password();
if pass and pass ~= '' then
fmuc_pr:tag('password'):text(pass);
end
fmuc_pr:up();
module:send(fmuc_pr);
sent_main_participants = sent_main_participants + 1;
end
end
visitors_nodes[room.jid].nodes[conference_service] = sent_main_participants;
send_visitors_iq(conference_service, room, 'connect');
end
module:hook('jitsi-connect-vnode', connect_vnode);
-- listens for responses to the iq sent for connecting vnode
local function stanza_handler(event)
local origin, stanza = event.origin, event.stanza;
if stanza.name ~= 'iq' then
return;
end
-- we receive error from vnode for our disconnect message as the room was already destroyed (all visitors left)
if (stanza.attr.type == 'result' or stanza.attr.type == 'error') and sent_iq_cache:get(stanza.attr.id) then
sent_iq_cache:set(stanza.attr.id, nil);
return true;
end
end
module:hook('iq/host', stanza_handler, 10);
-- an event received from visitors component, which receives iqs from jicofo
local function disconnect_vnode(event)
local room, vnode = event.room, event.vnode;
if visitors_nodes[event.room.jid] == nil then
-- maybe the room was already destroyed and vnodes cleared
return;
end
local conference_service = muc_domain_prefix..'.'..vnode..'.meet.jitsi';
visitors_nodes[room.jid].nodes[conference_service] = nil;
send_visitors_iq(conference_service, room, 'disconnect');
end
module:hook('jitsi-disconnect-vnode', disconnect_vnode);
-- takes care when the visitor nodes destroys the room to count the leaving participants from there, and if its really destroyed
-- we clean up, so if we establish again the connection to the same visitor node to send the main participants
module:hook('presence/full', function(event)
local stanza = event.stanza;
local room_name, from_host = jid.split(stanza.attr.from);
if stanza.attr.type == 'unavailable' and from_host ~= main_muc_component_config then
local room_jid = jid.join(room_name, main_muc_component_config); -- converts from visitor to main room jid
local x = stanza:get_child('x', 'http://jabber.org/protocol/muc#user');
if not presence_check_status(x, '110') then
return;
end
if visitors_nodes[room_jid] and visitors_nodes[room_jid].nodes
and visitors_nodes[room_jid].nodes[from_host] then
visitors_nodes[room_jid].nodes[from_host] = visitors_nodes[room_jid].nodes[from_host] - 1;
-- we clean only on disconnect coming from jicofo
end
end
end, 900);
process_host_module(main_muc_component_config, function(host_module, host)
-- detects presence change in a main participant and propagate it to the used visitor nodes
host_module:hook('muc-occupant-pre-change', function (event)
local room, stanzaEv, occupant = event.room, event.stanza, event.dest_occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- filter focus and configured domains (used for jibri and transcribers)
if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil
or (ignore_list:contains(jid.host(occupant.bare_jid)) and not is_transcriber_jigasi(stanza)) then
return;
end
local vnodes = visitors_nodes[room.jid].nodes;
local user, _, res = jid.split(occupant.nick);
-- a change in the presence of a main participant we need to update all active visitor nodes
for k in pairs(vnodes) do
local fmuc_pr = st.clone(stanza);
fmuc_pr.attr.to = jid.join(user, k, res);
fmuc_pr.attr.from = occupant.jid;
module:send(fmuc_pr);
end
end);
-- when a main participant leaves inform the visitor nodes
host_module:hook('muc-occupant-left', function (event)
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- ignore configured domains (jibri and transcribers)
if is_admin(occupant.bare_jid) or visitors_nodes[room.jid] == nil or visitors_nodes[room.jid].nodes == nil
or (ignore_list:contains(jid.host(occupant.bare_jid)) and not is_transcriber_jigasi(stanza)) then
return;
end
--this is probably participant kick scenario, create an unavailable presence and send to vnodes.
if not stanza then
stanza = st.presence {from = occupant.nick; type = "unavailable";};
end
-- we want to update visitor node that a main participant left or kicked.
if stanza then
local vnodes = visitors_nodes[room.jid].nodes;
local user, _, res = jid.split(occupant.nick);
for k in pairs(vnodes) do
local fmuc_pr = st.clone(stanza);
fmuc_pr.attr.to = jid.join(user, k, res);
fmuc_pr.attr.from = occupant.jid;
module:send(fmuc_pr);
end
end
end);
-- cleanup cache
host_module:hook('muc-room-destroyed',function(event)
local room = event.room;
-- room is destroyed let's disconnect all vnodes
if visitors_nodes[room.jid] then
local vnodes = visitors_nodes[room.jid].nodes;
for conference_service in pairs(vnodes) do
send_visitors_iq(conference_service, room, 'disconnect');
end
visitors_nodes[room.jid] = nil;
end
end);
-- detects new participants joining main room and sending them to the visitor nodes
host_module:hook('muc-occupant-joined', function (event)
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- filter focus, ignore configured domains (jibri and transcribers)
if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil
or (ignore_list:contains(jid.host(occupant.bare_jid)) and not is_transcriber_jigasi(stanza)) then
return;
end
local vnodes = visitors_nodes[room.jid].nodes;
local user, _, res = jid.split(occupant.nick);
-- a main participant we need to update all active visitor nodes
for k in pairs(vnodes) do
if occupant.role == 'moderator' then
-- first send that the participant is a moderator
send_visitors_iq(k, room, 'update');
end
local fmuc_pr = st.clone(stanza);
fmuc_pr.attr.to = jid.join(user, k, res);
fmuc_pr.attr.from = occupant.jid;
module:send(fmuc_pr);
end
end);
-- forwards messages from main participants to vnodes
host_module:hook('muc-occupant-groupchat', function(event)
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- filter sending messages from transcribers/jibris to visitors
if not visitors_nodes[room.jid] then
return;
end
local vnodes = visitors_nodes[room.jid].nodes;
local user = jid.node(occupant.nick);
-- a main participant we need to update all active visitor nodes
for k in pairs(vnodes) do
local fmuc_msg = st.clone(stanza);
fmuc_msg.attr.to = jid.join(user, k);
fmuc_msg.attr.from = occupant.jid;
module:send(fmuc_msg);
end
end);
-- receiving messages from visitor nodes and forward them to local main participants
-- and forward them to the rest of visitor nodes
host_module:hook('muc-occupant-groupchat', function(event)
local occupant, room, stanzaEv = event.occupant, event.room, event.stanza;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
local to = stanza.attr.to;
local from = stanza.attr.from;
local from_vnode = jid.host(from);
if occupant or not (visitors_nodes[to]
and visitors_nodes[to].nodes
and visitors_nodes[to].nodes[from_vnode]) then
return;
end
if host_module:fire_event('jitsi-visitor-groupchat-pre-route', event) then
-- message filtered
return;
end
-- a message from visitor occupant of known visitor node
stanza.attr.from = to;
for _, o in room:each_occupant() do
-- send it to the nick to be able to route it to the room (ljm multiple rooms) from unknown occupant
room:route_to_occupant(o, stanza);
end
-- let's add the message to the history of the room
host_module:fire_event("muc-add-history", { room = room; stanza = stanza; from = from; visitor = true; });
-- now we need to send to rest of visitor nodes
local vnodes = visitors_nodes[room.jid].nodes;
for k in pairs(vnodes) do
if k ~= from_vnode then
local st_copy = st.clone(stanza);
st_copy.attr.to = jid.join(jid.node(room.jid), k);
module:send(st_copy);
end
end
return true;
end, 55); -- prosody check for unknown participant chat is prio 50, we want to override it
-- Handle private messages from visitor nodes to main participants
-- This routes forwarded private messages through the proper MUC system
host_module:hook('message/full', function(event)
local stanza = event.stanza;
-- Only handle chat messages (private messages)
if stanza.attr.type ~= 'chat' then
return; -- Let other handlers process non-chat messages
end
local to = stanza.attr.to;
-- Early return if this is not targeted at our MUC component
if jid.host(to) ~= main_muc_component_config then
return; -- Not for our MUC component, let other handlers process
end
local from = stanza.attr.from;
local from_host = jid.host(from);
local to_node = jid.node(to);
local to_resource = jid.resource(to);
-- Check if this is a private message from a known visitor node
local target_room_jid = jid.bare(to);
-- Early return if we don't have any visitor nodes for this room
if not (visitors_nodes[target_room_jid] and visitors_nodes[target_room_jid].nodes) then
return; -- No visitor nodes for this room, let default MUC handle it
end
-- Early return if the from_host is not a known visitor node
if not visitors_nodes[target_room_jid].nodes[from_host] then
-- This could be a main->visitor message, let it go through s2s
return; -- Not from a known visitor node, let default MUC handle it
end
-- At this point we know it's a visitor message, handle it
local room = prosody.hosts[main_muc_component_config].modules.muc.get_room_from_jid(target_room_jid);
if room then
-- Find the occupant
local occupant = room:get_occupant_by_nick(to);
if occupant then
-- Add addresses element (XEP-0033) to store original visitor JID for reply functionality
stanza:tag('addresses', { xmlns = 'http://jabber.org/protocol/address' })
:tag('address', { type = 'ofrom', jid = stanza.attr.from }):up()
:up();
-- Change from to be the main domain equivalent for proper client recognition
-- Use bare JID without resource
stanza.attr.from = jid.join(to_node, main_muc_component_config);
room:route_to_occupant(occupant, stanza);
return true;
else
module:log('warn', 'VISITOR PRIVATE MESSAGE: Occupant not found for %s', to);
end
else
module:log('warn', 'VISITOR PRIVATE MESSAGE: Room not found for %s', to);
end
return false;
end, 10); -- Normal priority since we're in the right place now
-- Main->visitor private messages work via s2s routing automatically
-- No special handling needed!
host_module:hook('muc-config-submitted/muc#roomconfig_roomsecret', function(event)
if event.status_codes['104'] then
local room = event.room;
if visitors_nodes[room.jid] then
-- we need to update all vnodes
local vnodes = visitors_nodes[room.jid].nodes;
for conference_service in pairs(vnodes) do
send_visitors_iq(conference_service, room, 'update');
end
end
end
end, -100); -- we want to run last in order to check is the status code 104
host_module:hook('muc-set-affiliation', function (event)
if event.actor and not is_admin(event.actor) and event.affiliation == 'owner' then
local room = event.room;
if not visitors_nodes[room.jid] then
return;
end
-- we need to update all vnodes
local vnodes = visitors_nodes[room.jid].nodes;
for conference_service in pairs(vnodes) do
send_visitors_iq(conference_service, room, 'update');
end
end
end, -2);
end);
local function update_vnodes_for_room(event)
local room = event.room;
if visitors_nodes[room.jid] then
-- we need to update all vnodes
local vnodes = visitors_nodes[room.jid].nodes;
for conference_service in pairs(vnodes) do
send_visitors_iq(conference_service, room, 'update');
end
end
end
module:hook('jitsi-lobby-enabled', update_vnodes_for_room);
module:hook('jitsi-lobby-disabled', update_vnodes_for_room);
module:hook('jitsi-filesharing-updated', update_vnodes_for_room);

View File

@@ -0,0 +1,753 @@
module:log('info', 'Starting visitors_component at %s', module.host);
local array = require "util.array";
local http = require 'net.http';
local jid = require 'util.jid';
local st = require 'util.stanza';
local util = module:require 'util';
local is_admin = util.is_admin;
local is_healthcheck_room = util.is_healthcheck_room;
local is_sip_jigasi = util.is_sip_jigasi;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local get_room_from_jid = util.get_room_from_jid;
local get_focus_occupant = util.get_focus_occupant;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local table_find = util.table_find;
local is_vpaas = util.is_vpaas;
local is_sip_jibri_join = util.is_sip_jibri_join;
local process_host_module = util.process_host_module;
local respond_iq_result = util.respond_iq_result;
local split_string = util.split_string;
local new_id = require 'util.id'.medium;
local json = require 'cjson.safe';
local inspect = require 'inspect';
-- Debug flag
local DEBUG = false;
-- will be initialized once the main virtual host module is initialized
local token_util;
local MUC_NS = 'http://jabber.org/protocol/muc';
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
local muc_domain_base = module:get_option_string('muc_mapper_domain_base');
if not muc_domain_base then
module:log('warn', 'No muc_domain_base option set.');
return;
end
-- A list of domains which to be ignored for visitors. The config is set under the main virtual host
local ignore_list = module:context(muc_domain_base):get_option_set('visitors_ignore_list', {});
local auto_allow_promotion = module:get_option_boolean('auto_allow_visitor_promotion', false);
-- whether to always advertise that visitors feature is enabled for rooms
-- can be set to off and being controlled by another module, turning it on and off for rooms
local always_visitors_enabled = module:get_option_boolean('always_visitors_enabled', true);
local visitors_queue_service = module:get_option_string('visitors_queue_service');
local http_headers = {
["User-Agent"] = "Prosody (" .. prosody.version .. "; " .. prosody.platform .. ")",
["Content-Type"] = "application/json",
["Accept"] = "application/json"
};
-- This is a map to keep data for room and the jids that were allowed to join after visitor mode is enabled
-- automatically allowed or allowed by a moderator
local visitors_promotion_map = {};
-- A map with key room jid. The content is a map with key jid from which the request is received
-- and the value is a table that has the json message that needs to be sent to any future moderator that joins
-- and the vnode from which the request is received and where the response will be sent
local visitors_promotion_requests = {};
local cache = require 'util.cache';
local sent_iq_cache = cache.new(200);
-- Function to get visitors room metadata
local function get_visitors_room_metadata(room)
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
if not room.jitsiMetadata.visitors then
room.jitsiMetadata.visitors = {};
end
return room.jitsiMetadata.visitors;
end
-- Sends a json-message to the destination jid
-- @param to_jid the destination jid
-- @param json_message the message content to send
function send_json_message(to_jid, json_message)
local stanza = st.message({ from = module.host; to = to_jid; })
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_message):up();
module:send(stanza);
end
local function request_promotion_received(room, from_jid, from_vnode, nick, time, user_id, group_id, force_promote_requested)
if DEBUG then
module:log('debug', 'Received promotion request from %s for room %s, nick: %s, time: %s, user_id: %s, group_id: %s, force_promote_requested: %s',
from_jid, room.jid, nick, time, user_id, group_id, force_promote_requested);
end
-- if visitors is enabled for the room
if visitors_promotion_map[room.jid] then
local force_promote = auto_allow_promotion or get_visitors_room_metadata(room).autoPromote;
if not force_promote and force_promote_requested == 'true' then
-- Let's do the force_promote checks if requested
-- if it is vpaas meeting we trust the moderator computation from visitor node (value of force_promote_requested)
-- if it is not vpaas we need to check further settings only if they exist
if is_vpaas(room) or (not room._data.moderator_id and not room._data.moderators)
-- _data.moderator_id can be used from external modules to set single moderator for a meeting
-- or a whole group of moderators
or (room._data.moderator_id
and room._data.moderator_id == user_id or room._data.moderator_id == group_id)
-- all moderators are allowed to auto promote, the fact that user_id and force_promote_requested are set
-- means that the user has token and is moderator on visitor node side
or room._data.allModerators
-- can be used by external modules to set multiple moderator ids (table of values)
or table_find(room._data.moderators, user_id)
then
force_promote = true;
end
end
-- only for raise hand, ignore lowering the hand
if time and time > 0 and force_promote then
-- we are in auto-allow mode, let's reply with accept
-- we store where the request is coming from so we can send back the response
local username = new_id():lower();
visitors_promotion_map[room.jid][username] = {
from = from_vnode;
jid = from_jid;
};
local req_from = visitors_promotion_map[room.jid][username].from;
local req_jid = visitors_promotion_map[room.jid][username].jid;
local focus_occupant = get_focus_occupant(room);
local focus_jid = focus_occupant and focus_occupant.bare_jid or nil;
local iq_id = new_id();
sent_iq_cache:set(iq_id, socket.gettime());
local node = jid.node(room.jid);
module:send(st.iq({
type='set', to = req_from, from = module.host, id = iq_id })
:tag('visitors', {
xmlns='jitsi:visitors',
room = jid.join(node, muc_domain_prefix..'.'..req_from),
focusjid = focus_jid })
:tag('promotion-response', {
xmlns='jitsi:visitors',
jid = req_jid,
username = username ,
allow = 'true' }):up());
return true;
else
-- send promotion request to all moderators
local body_json = {};
body_json.type = 'visitors';
body_json.room = internal_room_jid_match_rewrite(room.jid);
body_json.action = 'promotion-request';
body_json.nick = nick;
body_json.from = from_jid;
if time and time > 0 then
-- raise hand
body_json.on = true;
else
-- lower hand, we want to inform interested parties that
-- the visitor is no longer interested in joining the main call
body_json.on = false;
end
local msg_to_send, error = json.encode(body_json);
if not msg_to_send then
module:log('error', 'Error encoding msg room:%s error:%s', room.jid, error)
return true;
end
if visitors_promotion_requests[room.jid] then
visitors_promotion_requests[room.jid][from_jid] = {
msg = msg_to_send;
from = from_vnode;
};
else
module:log('warn', 'Received promotion request for room %s with visitors not enabled. %s',
room.jid, msg_to_send);
end
-- let's send a notification to every moderator
for _, occupant in room:each_occupant() do
if occupant.role == 'moderator' and not is_admin(occupant.bare_jid) then
send_json_message(occupant.jid, msg_to_send);
end
end
return true;
end
end
module:log('warn', 'Received promotion request from %s for room %s without active visitors', from, room.jid);
end
local function connect_vnode_received(room, vnode)
module:context(muc_domain_base):fire_event('jitsi-connect-vnode', { room = room; vnode = vnode; });
if not visitors_promotion_map[room.jid] then
-- visitors is enabled
visitors_promotion_map[room.jid] = {};
visitors_promotion_requests[room.jid] = {};
room._connected_vnodes = cache.new(16); -- we up to 16 vnodes for this prosody
end
room._connected_vnodes:set(vnode..'.meet.jitsi', {});
end
local function disconnect_vnode_received(room, vnode)
module:context(muc_domain_base):fire_event('jitsi-disconnect-vnode', { room = room; vnode = vnode; });
room._connected_vnodes:set(vnode..'.meet.jitsi', nil);
if room._connected_vnodes:count() == 0 then
visitors_promotion_map[room.jid] = nil;
visitors_promotion_requests[room.jid] = nil;
room._connected_vnodes = nil;
end
end
-- returns the accumulated data for visitors nodes, count all visitors requesting transcriptions
-- and accumulated languages requested
-- @returns count, languages
function get_visitors_languages(room)
if not room._connected_vnodes then
return;
end
local count = 0;
local languages = array();
-- iterate over visitor nodes we are connected to and accumulate data if we have it
for k, v in room._connected_vnodes:items() do
if v.count then
count = count + v.count;
end
if v.langs then
for k in pairs(v.langs) do
local val = v.langs[k]
if not languages[val] then
languages:push(val);
end
end
end
end
return count, languages:sort():concat(',');
end
-- listens for iq request for promotion and forward it to moderators in the meeting for approval
-- or auto-allow it if such the config is set enabling it
local function stanza_handler(event)
local origin, stanza = event.origin, event.stanza;
if stanza.name ~= 'iq' then
return;
end
if DEBUG then
module:log('debug', 'Received stanza %s from %s', stanza, origin.full_jid);
end
if stanza.attr.type == 'result' and sent_iq_cache:get(stanza.attr.id) then
sent_iq_cache:set(stanza.attr.id, nil);
return true;
end
if stanza.attr.type ~= 'set' and stanza.attr.type ~= 'get' then
return; -- We do not want to reply to these, so leave.
end
local visitors_iq = event.stanza:get_child('visitors', 'jitsi:visitors');
if not visitors_iq then
return;
end
-- set stanzas are coming from s2s connection
if stanza.attr.type == 'set' and origin.type ~= 's2sin' then
module:log('warn', 'not from s2s session, ignore! %s', stanza);
return true;
end
local room_jid = visitors_iq.attr.room;
local room = get_room_from_jid(room_jid_match_rewrite(room_jid));
if not room then
-- this maybe as we receive the iq from jicofo after the room is already destroyed
module:log('debug', 'No room found %s', room_jid);
return true;
end
local from_vnode;
if room._connected_vnodes then
from_vnode = room._connected_vnodes:get(stanza.attr.from);
end
local processed;
-- promotion request is coming from visitors and is a set and is over the s2s connection
local request_promotion = visitors_iq:get_child('promotion-request');
if request_promotion then
if not from_vnode then
module:log('warn', 'Received forged request_promotion message: %s %s',stanza, inspect(room._connected_vnodes));
return true; -- stop processing
end
local display_name = visitors_iq:get_child_text('nick', 'http://jabber.org/protocol/nick');
processed = request_promotion_received(
room,
request_promotion.attr.jid,
stanza.attr.from,
display_name,
tonumber(request_promotion.attr.time),
request_promotion.attr.userId,
request_promotion.attr.groupId,
request_promotion.attr.forcePromote
);
end
-- connect and disconnect are only received from jicofo
if is_admin(jid.bare(stanza.attr.from)) then
for item in visitors_iq:childtags('connect-vnode') do
connect_vnode_received(room, item.attr.vnode);
processed = true;
end
for item in visitors_iq:childtags('disconnect-vnode') do
disconnect_vnode_received(room, item.attr.vnode);
processed = true;
end
end
-- request to update metadata service for jigasi languages
local transcription_languages = visitors_iq:get_child('transcription-languages');
if transcription_languages
and (transcription_languages.attr.langs or transcription_languages.attr.count) then
if not from_vnode then
module:log('warn', 'Received forged transcription_languages message: %s %s',stanza, inspect(room._connected_vnodes));
return true; -- stop processing
end
local metadata = get_visitors_room_metadata(room);
-- we keep the split by languages array to optimize accumulating languages
from_vnode.langs = split_string(transcription_languages.attr.langs, ',');
from_vnode.count = transcription_languages.attr.count;
local count, languages = get_visitors_languages(room);
if metadata.transcribingLanguages ~= languages then
metadata.transcribingLanguages = languages;
processed = true;
end
if metadata.transcribingCount ~= count then
metadata.transcribingCount = count;
processed = true;
end
if processed then
module:context(muc_domain_prefix..'.'..muc_domain_base)
:fire_event('room-metadata-changed', { room = room; });
end
end
if not processed then
module:log('warn', 'Unknown iq received for %s: %s', module.host, stanza);
end
respond_iq_result(origin, stanza);
return processed;
end
local function process_promotion_response(room, id, approved)
if not approved then
module:log('debug', 'promotion not approved %s, %s', room.jid, id);
return;
end
if DEBUG then
module:log('debug', 'Processing promotion response for room %s, id %s, approved %s',
room.jid, id, approved);
end
-- lets reply to participant that requested promotion
local username = new_id():lower();
visitors_promotion_map[room.jid][username] = {
from = visitors_promotion_requests[room.jid][id].from;
jid = id;
};
local req_from = visitors_promotion_map[room.jid][username].from;
local req_jid = visitors_promotion_map[room.jid][username].jid;
local focus_occupant = get_focus_occupant(room);
local focus_jid = focus_occupant and focus_occupant.bare_jid or nil;
local iq_id = new_id();
sent_iq_cache:set(iq_id, socket.gettime());
local node = jid.node(room.jid);
module:send(st.iq({
type='set', to = req_from, from = module.host, id = iq_id })
:tag('visitors', {
xmlns='jitsi:visitors',
room = jid.join(node, muc_domain_prefix..'.'..req_from),
focusjid = focus_jid })
:tag('promotion-response', {
xmlns='jitsi:visitors',
jid = req_jid,
username = username,
allow = approved }):up());
end
-- if room metadata does not have visitors.live set to `true` and there are no occupants in the meeting
-- it will skip calling goLive endpoint
local function go_live(room)
if DEBUG then
module:log('debug', 'Checking if room %s is live', room.jid);
end
if room._jitsi_go_live_sent then
if DEBUG then
module:log('debug', 'Room %s already sent go live request, skipping', room.jid);
end
return;
end
-- if missing we assume room is live, only skip if it is marked explicitly as false
if room.jitsiMetadata and room.jitsiMetadata.visitors
and room.jitsiMetadata.visitors.live ~= nil and room.jitsiMetadata.visitors.live == false then
if DEBUG then
module:log('debug', 'Room %s is not live, skipping go live request', room.jid);
end
return;
end
local has_occupant = false;
for _, occupant in room:each_occupant() do
if not is_admin(occupant.bare_jid) then
has_occupant = true;
break;
end
end
-- when there is an occupant then go live
if not has_occupant then
if DEBUG then
module:log('debug', 'Room %s has no occupants, skipping go live request', room.jid);
end
return;
end
-- let's inform the queue service
local function cb(content_, code_, response_, request_)
local room = room;
if code_ ~= 200 then
module:log('warn', 'External call to visitors_queue_service/golive failed. Code %s, Content %s',
code_, content_)
end
end
local headers = http_headers or {};
headers['Authorization'] = token_util:generateAsapToken();
local ev = {
conference = internal_room_jid_match_rewrite(room.jid)
};
room._jitsi_go_live_sent = true;
http.request(visitors_queue_service..'/golive', {
headers = headers,
method = 'POST',
body = json.encode(ev);
}, cb);
end
module:hook('iq/host', stanza_handler, 10);
process_host_module(muc_domain_base, function(host_module, host)
token_util = module:require "token/util".new(host_module);
end);
process_host_module(muc_domain_prefix..'.'..muc_domain_base, function(host_module, host)
-- if visitor mode is started, then you are not allowed to join without request/response exchange of iqs -> deny access
-- check list of allowed jids for the room
host_module:hook('muc-occupant-pre-join', function (event)
local room, stanza, occupant, session = event.room, event.stanza, event.occupant, event.origin;
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
if DEBUG then
module:log('debug', 'Skipping visitor checks for healthcheck room %s or admin %s',
room.jid, occupant.bare_jid);
end
return;
end
-- visitors were already in the room one way or another they have access
-- skip password challenge
local join = stanza:get_child('x', MUC_NS);
if join and room:get_password() and
visitors_promotion_map[room.jid] and visitors_promotion_map[room.jid][jid.node(stanza.attr.from)] then
join:tag('password', { xmlns = MUC_NS }):text(room:get_password());
end
local is_live = get_visitors_room_metadata(room).live;
-- we skip any checks when auto-allow is enabled and room is live
if (auto_allow_promotion or get_visitors_room_metadata(room).autoPromote and (is_live or is_live == nil))
or ignore_list:contains(jid.host(stanza.attr.from)) -- jibri or other domains to ignore
or is_sip_jigasi(stanza)
or is_sip_jibri_join(stanza)
or table_find(room._data.moderators, session.jitsi_meet_context_user and session.jitsi_meet_context_user.id)
or (room._data.moderator_id and room._data.moderator_id == (session.jitsi_meet_context_user and session.jitsi_meet_context_user.id))
or table_find(room._data.participants, session.jitsi_meet_context_user and session.jitsi_meet_context_user.id) then
if DEBUG then
module:log('debug', 'Auto-allowing visitor %s in room %s', stanza.attr.from, room.jid);
end
return;
end
if visitors_promotion_map[room.jid] then
local in_ignore_list = ignore_list:contains(jid.host(stanza.attr.from));
-- now let's check for jid
if visitors_promotion_map[room.jid][jid.node(stanza.attr.from)] -- promotion was approved
or in_ignore_list then -- jibri or other domains to ignore
-- allow join
if not in_ignore_list then
-- let's update metadata
local metadata = get_visitors_room_metadata(room);
if not metadata.promoted then
metadata.promoted = {};
end
metadata.promoted[jid.resource(occupant.nick)] = true;
module:context(muc_domain_prefix..'.'..muc_domain_base)
:fire_event('room-metadata-changed', { room = room; });
end
return;
end
module:log('error', 'Visitor needs to be allowed by a moderator %s', stanza.attr.from);
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Visitor needs to be allowed by a moderator')
:tag('promotion-not-allowed', { xmlns = 'jitsi:visitors' }));
return true;
elseif is_vpaas(room) then
-- special case for vpaas where if someone with a visitor token tries to join a room, where
-- there are no visitors yet, we deny access
if session.jitsi_meet_context_user and session.jitsi_meet_context_user.role == 'visitor' then
session.log('warn', 'Deny user join as visitor in the main meeting, not approved');
session.send(st.error_reply(
stanza, 'cancel', 'not-allowed', 'Visitor tried to join the main room without approval')
:tag('no-main-participants', { xmlns = 'jitsi:visitors' }));
return true;
end
elseif room._data.participants then
-- This is non jaas room which has a list of participants allowed to participate in the main room
-- but this occupant is not one of them and the room is either not live or has no participants joined
session.log('warn',
'Deny user join in the main not live meeting, not in the list of main participants');
session.send(st.error_reply(
stanza, 'cancel', 'not-allowed',
'Tried to join the main (not live or without main participants) room')
:tag('not-live-room', { xmlns = 'jitsi:visitors' }));
return true;
end
end, 7); -- after muc_meeting_id, the logic for not joining before jicofo
host_module:hook('muc-room-destroyed', function (event)
visitors_promotion_map[event.room.jid] = nil;
visitors_promotion_requests[event.room.jid] = nil;
end);
host_module:hook('muc-occupant-joined', function (event)
local room, occupant = event.room, event.occupant;
if DEBUG then
module:log('debug', 'Occupant %s joined room %s', occupant.jid, room.jid);
end
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) or occupant.role ~= 'moderator' -- luacheck: ignore
or not visitors_promotion_requests[event.room.jid] then
if DEBUG then
module:log('debug', 'Skipping visitor checks for healthcheck room %s or admin %s or not moderator %s',
room.jid, occupant.bare_jid, occupant.role);
end
return;
end
for _,value in pairs(visitors_promotion_requests[event.room.jid]) do
send_json_message(occupant.jid, value.msg);
end
end);
host_module:hook('muc-set-affiliation', function (event)
-- the actor can be nil if is coming from allowners or similar module we want to skip it here
-- as we will handle it in occupant_joined
local actor, affiliation, jid, room = event.actor, event.affiliation, event.jid, event.room;
if is_admin(jid) or is_healthcheck_room(room.jid) or not actor or not affiliation == 'owner' -- luacheck: ignore
or not visitors_promotion_requests[event.room.jid] then
return;
end
-- event.jid is the bare jid of participant
for _, occupant in room:each_occupant() do
if occupant.bare_jid == event.jid then
for _,value in pairs(visitors_promotion_requests[event.room.jid]) do
send_json_message(occupant.jid, value.msg);
end
end
end
end);
host_module:hook('jitsi-endpoint-message-received', function(event)
local data, error, occupant, room, stanza
= event.message, event.error, event.occupant, event.room, event.stanza;
if not data or data.type ~= 'visitors'
or (data.action ~= "promotion-response" and data.action ~= "demote-request") then
if error then
module:log('error', 'Error decoding error:%s', error);
end
return;
end
if occupant.role ~= 'moderator' then
module:log('error', 'Occupant %s sending response message but not moderator in room %s',
occupant.jid, room.jid);
return false;
end
if data.action == "demote-request" then
if occupant.nick ~= room.jid..'/'..data.actor then
module:log('error', 'Bad actor in demote request %s', stanza);
event.origin.send(st.error_reply(stanza, "cancel", "bad-request"));
return true;
end
-- when demoting we want to send message to the demoted participant and to moderators
local target_jid = room.jid..'/'..data.id;
stanza.attr.type = 'chat'; -- it is safe as we are not using this stanza instance anymore
stanza.attr.from = module.host;
for _, room_occupant in room:each_occupant() do
-- do not send it to jicofo or back to the sender
if room_occupant.jid ~= occupant.jid and not is_admin(room_occupant.bare_jid) then
if room_occupant.role == 'moderator'
or room_occupant.nick == target_jid then
stanza.attr.to = room_occupant.jid;
room:route_stanza(stanza);
end
end
end
else
if data.id then
process_promotion_response(room, data.id, data.approved and 'true' or 'false');
else
-- we are in the case with admit all, we need to read data.ids
for _,value in pairs(data.ids) do
process_promotion_response(room, value, data.approved and 'true' or 'false');
end
end
end
return true; -- halt processing, but return true that we handled it
end);
if visitors_queue_service then
host_module:hook('muc-room-created', function (event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
go_live(room);
end, -2); -- metadata hook on -1
host_module:hook('jitsi-metadata-updated', function (event)
if event.key == 'visitors' then
go_live(event.room);
end
end);
-- when metadata changed internally from another module
host_module:hook('room-metadata-changed', function (event)
go_live(event.room);
end);
host_module:hook('muc-occupant-joined', function (event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
go_live(room);
end);
end
if always_visitors_enabled then
local visitorsEnabledField = {
name = "muc#roominfo_visitorsEnabled";
type = "boolean";
label = "Whether visitors are enabled.";
value = 1;
};
-- Append "visitors enabled" to the MUC config form.
host_module:context(host):hook("muc-disco#info", function(event)
table.insert(event.form, visitorsEnabledField);
end);
host_module:context(host):hook("muc-config-form", function(event)
table.insert(event.form, visitorsEnabledField);
end);
end
end);
prosody.events.add_handler('pre-jitsi-authentication', function(session)
if not session.customusername or not session.jitsi_web_query_room then
return nil;
end
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
return nil;
end
if visitors_promotion_map[room.jid] and visitors_promotion_map[room.jid][session.customusername] then
-- user was previously allowed to join, let him use the requested jid
return session.customusername;
end
end);
-- when occupant is leaving breakout to join the main room and visitors are enabled
-- make sure we will allow that participant to join as it is already part of the main room
function handle_occupant_leaving_breakout(event)
local main_room, occupant, stanza = event.main_room, event.occupant, event.stanza;
local presence_status = stanza:get_child_text('status');
if presence_status ~= 'switch_room' or not visitors_promotion_map[main_room.jid] then
return;
end
local node = jid.node(occupant.bare_jid);
visitors_promotion_map[main_room.jid][node] = {
from = 'none';
jid = occupant.bare_jid;
};
end
module:hook_global('jitsi-breakout-occupant-leaving', handle_occupant_leaving_breakout);

View File

@@ -0,0 +1,22 @@
--- muc.lib.lua 2016-10-26 18:26:53.432377291 +0000
+++ muc.lib.lua 2016-10-26 18:41:40.754426072 +0000
@@ -1582,16 +1582,16 @@
if event.allowed ~= nil then
return event.allowed, event.error, event.condition;
end
+ local occupant_affiliation = self:get_affiliation(occupant.bare_jid);
-- Can't do anything to other owners or admins
- local occupant_affiliation = self:get_affiliation(occupant.bare_jid);
- if occupant_affiliation == "owner" or occupant_affiliation == "admin" then
+ local actor_affiliation = self:get_affiliation(actor);
+ if (occupant_affiliation == "owner" and actor_affiliation ~= "owner") or (occupant_affiliation == "admin" and actor_affiliation ~= "admin" and actor_affiliation ~= "owner") then
return nil, "cancel", "not-allowed";
end
-- If you are trying to give or take moderator role you need to be an owner or admin
if occupant.role == "moderator" or role == "moderator" then
- local actor_affiliation = self:get_affiliation(actor);
if actor_affiliation ~= "owner" and actor_affiliation ~= "admin" then
return nil, "cancel", "not-allowed";
end

View File

@@ -0,0 +1,397 @@
local inspect = require("inspect")
local jid = require("util.jid")
local stanza = require("util.stanza")
local timer = require("util.timer")
local update_presence_identity = module:require("util").update_presence_identity
local uuid = require("util.uuid")
local component = module:get_option_string(
"poltergeist_component",
module.host
)
local expiration_timeout = module:get_option_string(
"poltergeist_leave_timeout",
30 -- defaults to 30 seconds
)
local MUC_NS = "http://jabber.org/protocol/muc"
--------------------------------------------------------------------------------
-- Utility functions for commonly used poltergeist codes.
--------------------------------------------------------------------------------
-- Creates a nick for a poltergeist.
-- @param username is the unique username of the poltergeist
-- @return a nick to use for xmpp
local function create_nick(username)
return string.sub(username, 0,8)
end
-- Returns the last presence of the occupant.
-- @param room the room instance where to check for occupant
-- @param nick the nick of the occupant
-- @return presence stanza of the occupant
function get_presence(room, nick)
local occupant_jid = room:get_occupant_jid(component.."/"..nick)
if occupant_jid then
return room:get_occupant_by_nick(occupant_jid):get_presence();
end
return nil;
end
-- Checks for existence of a poltergeist occupant in a room.
-- @param room the room instance where to check for the occupant
-- @param nick the nick of the occupant
-- @return true if occupant is found, false otherwise
function occupies(room, nick)
-- Find out if we have a poltergeist occupant in the room for this JID
return not not room:get_occupant_jid(component.."/"..nick);
end
--------------------------------------------------------------------------------
-- Username storage for poltergeist.
--
-- Every poltergeist will have a username stored in a table underneath
-- the room name that they are currently active in. The username can
-- be retrieved given a room and a user_id. The username is removed from
-- a room by providing the room and the nick.
--
-- A table with a single entry looks like:
-- {
-- ["[hug]hostilewerewolvesthinkslightly"] = {
-- ["655363:52148a3e-b5fb-4cfc-8fbd-f55e793cf657"] = "ed7757d6-d88d-4e6a-8e24-aca2adc31348",
-- ed7757d6 = "655363:52148a3e-b5fb-4cfc-8fbd-f55e793cf657"
-- }
-- }
--------------------------------------------------------------------------------
-- state is the table where poltergeist usernames and call resources are stored
-- for a given xmpp muc.
local state = module:shared("state")
-- Adds a poltergeist to the store.
-- @param room is the room the poltergeist is being added to
-- @param user_id is the user_id of the user the poltergeist represents
-- @param username is the unique id of the poltergeist itself
local function store_username(room, user_id, username)
local room_name = jid.node(room.jid)
if not state[room_name] then
state[room_name] = {}
end
state[room_name][user_id] = username
state[room_name][create_nick(username)] = user_id
end
-- Retrieves a poltergeist username from the store if one exists.
-- @param room is the room to check for the poltergeist in the store
-- @param user_id is the user id of the user the poltergeist represents
local function get_username(room, user_id)
local room_name = jid.node(room.jid)
if not state[room_name] then
return nil
end
return state[room_name][user_id]
end
local function get_username_from_nick(room_name, nick)
if not state[room_name] then
return nil
end
local user_id = state[room_name][nick]
return state[room_name][user_id]
end
-- Removes the username from the store.
-- @param room is the room the poltergeist is being removed from
-- @param nick is the nick of the muc occupant
local function remove_username(room, nick)
local room_name = jid.node(room.jid)
if not state[room_name] then
return
end
local user_id = state[room_name][nick]
state[room_name][user_id] = nil
state[room_name][nick] = nil
end
-- Removes all poltergeists in the store for the provided room.
-- @param room is the room all poltergiest will be removed from
local function remove_room(room)
local room_name = jid.node(room.jid)
if state[room_name] then
state[room_name] = nil
end
end
-- Adds a resource that is associated with a a call in a room. There
-- is only one resource for each type.
-- @param room is the room the call and poltergeist is in.
-- @param call_id is the unique id for the call.
-- @param resource_type is type of resource being added.
-- @param resource_id is the id of the resource being added.
local function add_call_resource(room, call_id, resource_type, resource_id)
local room_name = jid.node(room.jid)
if not state[room_name] then
state[room_name] = {}
end
if not state[room_name][call_id] then
state[room_name][call_id] = {}
end
state[room_name][call_id][resource_type] = resource_id
end
--------------------------------------------------------------------------------
-- State for toggling the tagging of presence stanzas with ignored tag.
--
-- A poltergeist with it's full room/nick set to ignore will have a jitsi ignore
-- tag applied to all presence stanza's broadcasted. The following functions
-- assist in managing this state.
--------------------------------------------------------------------------------
local presence_ignored = {}
-- Sets the nick to ignored state.
-- @param room_nick full room/nick jid
local function set_ignored(room_nick)
presence_ignored[room_nick] = true
end
-- Resets the nick out of ignored state.
-- @param room_nick full room/nick jid
local function reset_ignored(room_nick)
presence_ignored[room_nick] = nil
end
-- Determines whether or not the leave presence should be tagged with ignored.
-- @param room_nick full room/nick jid
local function should_ignore(room_nick)
if presence_ignored[room_nick] == nil then
return false
end
return presence_ignored[room_nick]
end
--------------------------------------------------------------------------------
-- Poltergeist control functions for adding, updating and removing poltergeist.
--------------------------------------------------------------------------------
-- Updates the status tags and call flow tags of an existing poltergeist
-- presence.
-- @param presence_stanza is the actual presence stanza for a poltergeist.
-- @param status is the new status to be updated in the stanza.
-- @param call_details is a table of call flow signal information.
function update_presence_tags(presence_stanza, status, call_details)
local call_cancel = false
local call_id = nil
-- Extract optional call flow signal information.
if call_details then
call_id = call_details["id"]
if call_details["cancel"] then
call_cancel = call_details["cancel"]
end
end
presence_stanza:maptags(function (tag)
if tag.name == "status" then
if call_cancel then
-- If call cancel is set then the status should not be changed.
return tag
end
return stanza.stanza("status"):text(status)
elseif tag.name == "call_id" then
if call_id then
return stanza.stanza("call_id"):text(call_id)
else
-- If no call id is provided the re-use the existing id.
return tag
end
elseif tag.name == "call_cancel" then
if call_cancel then
return stanza.stanza("call_cancel"):text("true")
else
return stanza.stanza("call_cancel"):text("false")
end
end
return tag
end)
return presence_stanza
end
-- Updates the presence status of a poltergeist.
-- @param room is the room the poltergeist has occupied
-- @param nick is the xmpp nick of the poltergeist occupant
-- @param status is the status string to set in the presence
-- @param call_details is a table of call flow control details
local function update(room, nick, status, call_details)
local original_presence = get_presence(room, nick)
if not original_presence then
module:log("info", "update issued for a non-existing poltergeist")
return
end
-- update occupant presence with appropriate to and from
-- so we can send it again
update_presence = stanza.clone(original_presence)
update_presence.attr.to = room.jid.."/"..nick
update_presence.attr.from = component.."/"..nick
update_presence = update_presence_tags(update_presence, status, call_details)
module:log("info", "updating poltergeist: %s/%s - %s", room, nick, status)
room:handle_normal_presence(
prosody.hosts[component],
update_presence
)
end
-- Removes the poltergeist from the room.
-- @param room is the room the poltergeist has occupied
-- @param nick is the xmpp nick of the poltergeist occupant
-- @param ignore toggles if the leave subsequent leave presence should be tagged
local function remove(room, nick, ignore)
local original_presence = get_presence(room, nick);
if not original_presence then
module:log("info", "attempted to remove a poltergeist with no presence")
return
end
local leave_presence = stanza.clone(original_presence)
leave_presence.attr.to = room.jid.."/"..nick
leave_presence.attr.from = component.."/"..nick
leave_presence.attr.type = "unavailable"
if (ignore) then
set_ignored(room.jid.."/"..nick)
end
remove_username(room, nick)
module:log("info", "removing poltergeist: %s/%s", room, nick)
room:handle_normal_presence(
prosody.hosts[component],
leave_presence
)
end
-- Adds a poltergeist to a muc/room.
-- @param room is the room the poltergeist will occupy
-- @param is the id of the user the poltergeist represents
-- @param display_name is the display name to use for the poltergeist
-- @param avatar is the avatar link used for the poltergeist display
-- @param context is the session context of the user making the request
-- @param status is the presence status string to use
-- @param resources is a table of resource types and resource ids to correlate.
local function add_to_muc(room, user_id, display_name, avatar, context, status, resources)
local username = uuid.generate()
local presence_stanza = original_presence(
room,
username,
display_name,
avatar,
context,
status
)
module:log("info", "adding poltergeist: %s/%s", room, create_nick(username))
store_username(room, user_id, username)
for k, v in pairs(resources) do
add_call_resource(room, username, k, v)
end
room:handle_first_presence(
prosody.hosts[component],
presence_stanza
)
local remove_delay = 5
local expiration = expiration_timeout - remove_delay;
local nick = create_nick(username)
timer.add_task(
expiration,
function ()
update(room, nick, "expired")
timer.add_task(
remove_delay,
function ()
if occupies(room, nick) then
remove(room, nick, false)
end
end
)
end
)
end
-- Generates an original presence for a new poltergeist
-- @param room is the room the poltergeist will occupy
-- @param username is the unique name for the poltergeist
-- @param display_name is the display name to use for the poltergeist
-- @param avatar is the avatar link used for the poltergeist display
-- @param context is the session context of the user making the request
-- @param status is the presence status string to use
-- @return a presence stanza that can be used to add the poltergeist to the muc
function original_presence(room, username, display_name, avatar, context, status)
local nick = create_nick(username)
local p = stanza.presence({
to = room.jid.."/"..nick,
from = component.."/"..nick,
}):tag("x", { xmlns = MUC_NS }):up();
p:tag("bot", { type = "poltergeist" }):up();
p:tag("call_cancel"):text(nil):up();
p:tag("call_id"):text(username):up();
if status then
p:tag("status"):text(status):up();
else
p:tag("status"):text(nil):up();
end
if display_name then
p:tag(
"nick",
{ xmlns = "http://jabber.org/protocol/nick" }):text(display_name):up();
end
if avatar then
p:tag("avatar-url"):text(avatar):up();
end
-- If the room has a password set, let the poltergeist enter using it
local room_password = room:get_password();
if room_password then
local join = p:get_child("x", MUC_NS);
join:tag("password", { xmlns = MUC_NS }):text(room_password);
end
update_presence_identity(
p,
context.user,
context.group,
context.creator_user,
context.creator_group
)
return p
end
return {
get_username = get_username,
get_username_from_nick = get_username_from_nick,
occupies = occupies,
remove_room = remove_room,
reset_ignored = reset_ignored,
should_ignore = should_ignore,
create_nick = create_nick,
add_to_muc = add_to_muc,
update = update,
remove = remove
}

View File

@@ -0,0 +1,14 @@
diff -r 423f240d1173 core/stanza_router.lua
--- a/core/stanza_router.lua Tue Feb 21 10:06:54 2023 +0000
+++ b/core/stanza_router.lua Wed May 24 11:56:02 2023 -0500
@@ -207,7 +207,9 @@
else
local host_session = hosts[from_host];
if not host_session then
- log("error", "No hosts[from_host] (please report): %s", stanza);
+ -- moved it to debug as it fills visitor's prosody logs and this is a situation where we try to send
+ -- presence back to the main server and we don't need anyway as it came from there
+ log("debug", "No hosts[from_host] (please report): %s", stanza);
else
local xmlns = stanza.attr.xmlns;
stanza.attr.xmlns = nil;

View File

@@ -0,0 +1,539 @@
-- Token authentication
-- Copyright (C) 2021-present 8x8, Inc.
local basexx = require "basexx";
local have_async, async = pcall(require, "util.async");
local hex = require "util.hex";
local jwt = module:require "luajwtjitsi";
local jid = require "util.jid";
local json_safe = require "cjson.safe";
local path = require "util.paths";
local sha256 = require "util.hashes".sha256;
local main_util = module:require "util";
local ends_with = main_util.ends_with;
local http_get_with_retry = main_util.http_get_with_retry;
local extract_subdomain = main_util.extract_subdomain;
local starts_with = main_util.starts_with;
local table_shallow_copy = main_util.table_shallow_copy;
local cjson_safe = require 'cjson.safe'
local timer = require "util.timer";
local async = require "util.async";
local inspect = require 'inspect';
local nr_retries = 3;
local ssl = require "ssl";
-- TODO: Figure out a less arbitrary default cache size.
local cacheSize = module:get_option_number("jwt_pubkey_cache_size", 128);
-- the cache for generated asap jwt tokens
local jwtKeyCache = require 'util.cache'.new(cacheSize);
local ASAPTTL_THRESHOLD = module:get_option_number('asap_ttl_threshold', 600);
local ASAPTTL = module:get_option_number('asap_ttl', 3600);
local ASAPIssuer = module:get_option_string('asap_issuer', 'jitsi');
local ASAPAudience = module:get_option_string('asap_audience', 'jitsi');
local ASAPKeyId = module:get_option_string('asap_key_id', 'jitsi');
local ASAPKeyPath = module:get_option_string('asap_key_path', '/etc/prosody/certs/asap.key');
local ASAPKey;
local f = io.open(ASAPKeyPath, 'r');
if f then
ASAPKey = f:read('*all');
f:close();
end
local Util = {}
Util.__index = Util
--- Constructs util class for token verifications.
-- Constructor that uses the passed module to extract all the
-- needed configurations.
-- If configuration is missing returns nil
-- @param module the module in which options to check for configs.
-- @return the new instance or nil
function Util.new(module)
local self = setmetatable({}, Util)
self.appId = module:get_option_string("app_id");
self.appSecret = module:get_option_string("app_secret");
self.asapKeyServer = module:get_option_string("asap_key_server");
-- A URL that will return json file with a mapping between kids and public keys
-- If the response Cache-Control header we will respect it and refresh it
self.cacheKeysUrl = module:get_option_string("cache_keys_url");
self.signatureAlgorithm = module:get_option_string("signature_algorithm");
self.allowEmptyToken = module:get_option_boolean("allow_empty_token");
self.cache = require"util.cache".new(cacheSize);
--[[
Multidomain can be supported in some deployments. In these deployments
there is a virtual conference muc, which address contains the subdomain
to use. Those deployments are accessible
by URL https://domain/subdomain.
Then the address of the room will be:
roomName@conference.subdomain.domain. This is like a virtual address
where there is only one muc configured by default with address:
conference.domain and the actual presentation of the room in that muc
component is [subdomain]roomName@conference.domain.
These setups relay on configuration 'muc_domain_base' which holds
the main domain and we use it to subtract subdomains from the
virtual addresses.
The following configurations are for multidomain setups and domain name
verification:
--]]
-- optional parameter for custom muc component prefix,
-- defaults to "conference"
self.muc_domain_prefix = module:get_option_string(
"muc_mapper_domain_prefix", "conference");
-- domain base, which is the main domain used in the deployment,
-- the main VirtualHost for the deployment
self.muc_domain_base = module:get_option_string("muc_mapper_domain_base");
-- The "real" MUC domain that we are proxying to
if self.muc_domain_base then
self.muc_domain = module:get_option_string(
"muc_mapper_domain",
self.muc_domain_prefix.."."..self.muc_domain_base);
end
-- whether domain name verification is enabled, by default it is enabled
-- when disabled checking domain name and tenant if available will be skipped, we will check only room name.
self.enableDomainVerification = module:get_option_boolean('enable_domain_verification', true);
if self.allowEmptyToken == true then
module:log("warn", "WARNING - empty tokens allowed");
end
if self.appId == nil then
module:log("error", "'app_id' must not be empty");
return nil;
end
if self.appSecret == nil and self.asapKeyServer == nil then
module:log("error", "'app_secret' or 'asap_key_server' must be specified");
return nil;
end
-- Set defaults for signature algorithm
if self.signatureAlgorithm == nil then
if self.asapKeyServer ~= nil then
self.signatureAlgorithm = "RS256"
elseif self.appSecret ~= nil then
self.signatureAlgorithm = "HS256"
end
end
--array of accepted issuers: by default only includes our appId
self.acceptedIssuers = module:get_option_array('asap_accepted_issuers',{self.appId})
--array of accepted audiences: by default only includes our appId
self.acceptedAudiences = module:get_option_array('asap_accepted_audiences',{'*'})
self.requireRoomClaim = module:get_option_boolean('asap_require_room_claim', true);
if self.asapKeyServer and not have_async then
module:log("error", "requires a version of Prosody with util.async");
return nil;
end
if self.cacheKeysUrl then
self.cachedKeys = {};
local update_keys_cache;
update_keys_cache = async.runner(function (name)
local content, code, cache_for;
content, code, cache_for = http_get_with_retry(self.cacheKeysUrl, nr_retries);
if content ~= nil then
local keys_to_delete = table_shallow_copy(self.cachedKeys);
-- Let's convert any certificate to public key
for k, v in pairs(cjson_safe.decode(content)) do
if starts_with(v, '-----BEGIN CERTIFICATE-----') then
self.cachedKeys[k] = ssl.loadcertificate(v):pubkey();
-- do not clean this key if it already exists
keys_to_delete[k] = nil;
end
end
-- let's schedule the clean in an hour and a half, current tokens will be valid for an hour
timer.add_task(90*60, function ()
for k, _ in pairs(keys_to_delete) do
self.cachedKeys[k] = nil;
end
end);
if cache_for then
cache_for = tonumber(cache_for);
-- let's schedule new update 60 seconds before the cache expiring
if cache_for > 60 then
cache_for = cache_for - 60;
end
timer.add_task(cache_for, function ()
update_keys_cache:run("update_keys_cache");
end);
else
-- no cache header let's consider updating in 6hours
timer.add_task(6*60*60, function ()
update_keys_cache:run("update_keys_cache");
end);
end
else
module:log('warn', 'Failed to retrieve cached public keys code:%s', code);
-- failed let's retry in 30 seconds
timer.add_task(30, function ()
update_keys_cache:run("update_keys_cache");
end);
end
end);
update_keys_cache:run("update_keys_cache");
end
return self
end
function Util:set_asap_key_server(asapKeyServer)
self.asapKeyServer = asapKeyServer;
end
function Util:set_asap_accepted_issuers(acceptedIssuers)
self.acceptedIssuers = acceptedIssuers;
end
function Util:set_asap_accepted_audiences(acceptedAudiences)
self.acceptedAudiences = acceptedAudiences;
end
function Util:set_asap_require_room_claim(checkRoom)
self.requireRoomClaim = checkRoom;
end
function Util:clear_asap_cache()
self.cache = require"util.cache".new(cacheSize);
end
--- Returns the public key by keyID
-- @param keyId the key ID to request
-- @return the public key (the content of requested resource) or nil
function Util:get_public_key(keyId)
local content = self.cache:get(keyId);
local code;
if content == nil then
-- If the key is not found in the cache.
-- module:log("debug", "Cache miss for key: %s", keyId);
local keyurl = path.join(self.asapKeyServer, hex.to(sha256(keyId))..'.pem');
-- module:log("debug", "Fetching public key from: %s", keyurl);
content, code = http_get_with_retry(keyurl, nr_retries);
if content ~= nil then
self.cache:set(keyId, content);
else
if code == nil then
-- this is timeout after nr_retries retries
module:log('warn', 'Timeout retrieving %s from %s', keyId, keyurl);
end
end
return content;
else
-- If the key is in the cache, use it.
-- module:log("debug", "Cache hit for key: %s", keyId);
return content;
end
end
--- Verifies token and process needed values to be stored in the session.
-- Token is obtained from session.auth_token.
-- Stores in session the following values:
-- session.jitsi_meet_room - the room name value from the token
-- session.jitsi_meet_domain - the domain name value from the token
-- session.jitsi_meet_context_user - the user details from the token
-- session.jitsi_meet_context_room - the room details from the token
-- session.jitsi_meet_context_group - the group value from the token
-- session.jitsi_meet_context_features - the features value from the token
-- @param session the current session
-- @return false and error
function Util:process_and_verify_token(session)
if session.auth_token == nil then
if self.allowEmptyToken then
return true;
else
return false, "not-allowed", "token required";
end
end
local key;
if session.public_key then
-- We're using an public key stored in the session
-- module:log("debug","Public key was found on the session");
key = session.public_key;
elseif self.asapKeyServer and session.auth_token ~= nil then
-- We're fetching an public key from an ASAP server
local dotFirst = session.auth_token:find("%.");
if not dotFirst then return false, "not-allowed", "Invalid token" end
local header, err = json_safe.decode(basexx.from_url64(session.auth_token:sub(1,dotFirst-1)));
if err then
return false, "not-allowed", "bad token format";
end
local kid = header["kid"];
if kid == nil then
return false, "not-allowed", "'kid' claim is missing";
end
local alg = header["alg"];
if alg == nil then
return false, "not-allowed", "'alg' claim is missing";
end
if alg.sub(alg,1,2) ~= "RS" then
return false, "not-allowed", "'kid' claim only support with RS family";
end
if self.cachedKeys and self.cachedKeys[kid] then
key = self.cachedKeys[kid];
else
key = self:get_public_key(kid);
end
if key == nil then
return false, "not-allowed", "could not obtain public key";
end
elseif self.appSecret ~= nil then
-- We're using a symmetric secret
key = self.appSecret
end
if key == nil then
return false, "not-allowed", "signature verification key is missing";
end
-- now verify the whole token
local claims, msg = jwt.verify(
session.auth_token,
self.signatureAlgorithm,
key,
self.acceptedIssuers,
self.acceptedAudiences
)
if claims ~= nil then
if self.requireRoomClaim then
local roomClaim = claims["room"];
if roomClaim == nil then
return false, "'room' claim is missing";
end
end
-- Binds room name to the session which is later checked on MUC join
session.jitsi_meet_room = claims["room"];
-- Binds domain name to the session
session.jitsi_meet_domain = claims["sub"];
-- Binds the user details to the session if available
if claims["context"] ~= nil then
session.jitsi_meet_str_tenant = claims["context"]["tenant"];
if claims["context"]["user"] ~= nil then
session.jitsi_meet_context_user = claims["context"]["user"];
end
if claims["context"]["group"] ~= nil then
-- Binds any group details to the session
session.jitsi_meet_context_group = claims["context"]["group"];
end
if claims["context"]["features"] ~= nil then
-- Binds any features details to the session
session.jitsi_meet_context_features = claims["context"]["features"];
end
if claims["context"]["room"] ~= nil then
session.jitsi_meet_context_room = claims["context"]["room"]
end
elseif claims["user_id"] then
session.jitsi_meet_context_user = {};
session.jitsi_meet_context_user.id = claims["user_id"];
end
-- fire event that token has been verified and pass the session and the decoded token
prosody.events.fire_event('jitsi-authentication-token-verified', {
session = session;
claims = claims;
});
if session.contextRequired and claims["context"] == nil then
return false, "not-allowed", 'jwt missing required context claim';
end
return true;
else
return false, "not-allowed", msg;
end
end
--- Verifies room name and domain if necessary.
-- Checks configs and if necessary checks the room name extracted from
-- room_address against the one saved in the session when token was verified.
-- Also verifies domain name from token against the domain in the room_address,
-- if enableDomainVerification is enabled.
-- @param session the current session
-- @param room_address the whole room address as received
-- @return returns true in case room was verified or there is no need to verify
-- it and returns false in case verification was processed
-- and was not successful
function Util:verify_room(session, room_address)
if self.allowEmptyToken and session.auth_token == nil then
--module:log("debug", "Skipped room token verification - empty tokens are allowed");
return true;
end
-- extract room name using all chars, except the not allowed ones
local room,_,_ = jid.split(room_address);
if room == nil then
log("error",
"Unable to get name of the MUC room ? to: %s", room_address);
return true;
end
local auth_room = session.jitsi_meet_room;
if auth_room then
if type(auth_room) == 'string' then
auth_room = string.lower(auth_room);
else
module:log('warn', 'session.jitsi_meet_room not string: %s', inspect(auth_room));
end
end
if not self.enableDomainVerification then
-- if auth_room is missing, this means user is anonymous (no token for
-- its domain) we let it through, jicofo is verifying creation domain
if auth_room and (room ~= auth_room and not ends_with(room, ']'..auth_room)) and auth_room ~= '*' then
return false;
end
return true;
end
local room_address_to_verify = jid.bare(room_address);
local room_node = jid.node(room_address);
-- parses bare room address, for multidomain expected format is:
-- [subdomain]roomName@conference.domain
local target_subdomain, target_room = extract_subdomain(room_node);
-- if we have '*' as room name in token, this means all rooms are allowed
-- so we will use the actual name of the room when constructing strings
-- to verify subdomains and domains to simplify checks
local room_to_check;
if auth_room == '*' then
-- authorized for accessing any room assign to room_to_check the actual
-- room name
if target_room ~= nil then
-- we are in multidomain mode and we were able to extract room name
room_to_check = target_room;
else
-- no target_room, room_address_to_verify does not contain subdomain
-- so we get just the node which is the room name
room_to_check = room_node;
end
else
-- no wildcard, so check room against authorized room from the token
if session.jitsi_meet_context_room and (session.jitsi_meet_context_room["regex"] == true or session.jitsi_meet_context_room["regex"] == "true") then
if target_room ~= nil then
-- room with subdomain
room_to_check = target_room:match(auth_room);
else
room_to_check = room_node:match(auth_room);
end
else
-- not a regex
room_to_check = auth_room;
end
-- module:log("debug", "room to check: %s", room_to_check)
if not room_to_check then
if not self.requireRoomClaim then
-- if we do not require to have the room claim, and it is missing
-- there is no point of continue and verifying the roomName and the tenant
return true;
end
return false;
end
end
if session.jitsi_meet_str_tenant
and string.lower(session.jitsi_meet_str_tenant) ~= session.jitsi_web_query_prefix then
module:log('warn', 'Tenant differs for user:%s group:%s url_tenant:%s token_tenant:%s',
session.jitsi_meet_context_user and session.jitsi_meet_context_user.id or '',
session.jitsi_meet_context_group,
session.jitsi_web_query_prefix, session.jitsi_meet_str_tenant);
session.jitsi_meet_tenant_mismatch = true;
end
local auth_domain = string.lower(session.jitsi_meet_domain);
local subdomain_to_check;
if target_subdomain then
if auth_domain == '*' then
-- check for wildcard in JWT claim, allow access if found
subdomain_to_check = target_subdomain;
else
-- no wildcard in JWT claim, so check subdomain against sub in token
subdomain_to_check = auth_domain;
end
-- from this point we depend on muc_domain_base,
-- deny access if option is missing
if not self.muc_domain_base then
module:log("warn", "No 'muc_domain_base' option set, denying access!");
return false;
end
return room_address_to_verify == jid.join(
"["..subdomain_to_check.."]"..room_to_check, self.muc_domain);
else
if auth_domain == '*' then
-- check for wildcard in JWT claim, allow access if found
subdomain_to_check = self.muc_domain;
else
-- no wildcard in JWT claim, so check subdomain against sub in token
subdomain_to_check = self.muc_domain_prefix.."."..auth_domain;
end
-- we do not have a domain part (multidomain is not enabled)
-- verify with info from the token
return room_address_to_verify == jid.join(room_to_check, subdomain_to_check);
end
end
function Util:generateAsapToken(audience)
if not ASAPKey then
module:log('warn', 'No ASAP Key read, asap key generation is disabled');
return ''
end
audience = audience or ASAPAudience
local t = os.time()
local err
local exp_key = 'asap_exp.'..audience
local token_key = 'asap_token.'..audience
local exp = jwtKeyCache:get(exp_key)
local token = jwtKeyCache:get(token_key)
--if we find a token and it isn't too far from expiry, then use it
if token ~= nil and exp ~= nil then
exp = tonumber(exp)
if (exp - t) > ASAPTTL_THRESHOLD then
return token
end
end
--expiry is the current time plus TTL
exp = t + ASAPTTL
local payload = {
iss = ASAPIssuer,
aud = audience,
nbf = t,
exp = exp,
}
-- encode
local alg = 'RS256'
token, err = jwt.encode(payload, ASAPKey, alg, { kid = ASAPKeyId })
if not err then
token = 'Bearer '..token
jwtKeyCache:set(exp_key, exp)
jwtKeyCache:set(token_key, token)
return token
else
return ''
end
end
return Util;

View File

@@ -0,0 +1,766 @@
local http_server = require "net.http.server";
local jid = require "util.jid";
local st = require 'util.stanza';
local timer = require "util.timer";
local http = require "net.http";
local cache = require "util.cache";
local array = require "util.array";
local is_set = require 'util.set'.is_set;
local usermanager = require 'core.usermanager';
local config_global_admin_jids = module:context('*'):get_option_set('admins', {}) / jid.prep;
local config_admin_jids = module:get_option_inherited_set('admins', {}) / jid.prep;
local http_timeout = 30;
local have_async, async = pcall(require, "util.async");
local http_headers = {
["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")"
};
local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference");
-- defaults to module.host, the module that uses the utility
local muc_domain_base = module:get_option_string("muc_mapper_domain_base", module.host);
-- The "real" MUC domain that we are proxying to
local muc_domain = module:get_option_string("muc_mapper_domain", muc_domain_prefix.."."..muc_domain_base);
local escaped_muc_domain_base = muc_domain_base:gsub("%p", "%%%1");
local escaped_muc_domain_prefix = muc_domain_prefix:gsub("%p", "%%%1");
-- The pattern used to extract the target subdomain
-- (e.g. extract 'foo' from 'conference.foo.example.com')
local target_subdomain_pattern = "^"..escaped_muc_domain_prefix..".([^%.]+)%."..escaped_muc_domain_base;
-- table to store all incoming iqs without roomname in it, like discoinfo to the muc component
local roomless_iqs = {};
local OUTBOUND_SIP_JIBRI_PREFIXES = { 'outbound-sip-jibri@', 'sipjibriouta@', 'sipjibrioutb@' };
local INBOUND_SIP_JIBRI_PREFIXES = { 'inbound-sip-jibri@', 'sipjibriina@', 'sipjibriina@' };
local RECORDER_PREFIXES = module:get_option_inherited_set('recorder_prefixes', { 'recorder@recorder.', 'jibria@recorder.', 'jibrib@recorder.' });
local TRANSCRIBER_PREFIXES = module:get_option_inherited_set('transcriber_prefixes', { 'transcriber@recorder.', 'transcribera@recorder.', 'transcriberb@recorder.' });
local split_subdomain_cache = cache.new(1000);
local extract_subdomain_cache = cache.new(1000);
local internal_room_jid_cache = cache.new(1000);
local moderated_subdomains = module:get_option_set("allowners_moderated_subdomains", {})
local moderated_rooms = module:get_option_set("allowners_moderated_rooms", {})
-- Utility function to split room JID to include room name and subdomain
-- (e.g. from room1@conference.foo.example.com/res returns (room1, example.com, res, foo))
local function room_jid_split_subdomain(room_jid)
local ret = split_subdomain_cache:get(room_jid);
if ret then
return ret.node, ret.host, ret.resource, ret.subdomain;
end
local node, host, resource = jid.split(room_jid);
local target_subdomain = host and host:match(target_subdomain_pattern);
local cache_value = {node=node, host=host, resource=resource, subdomain=target_subdomain};
split_subdomain_cache:set(room_jid, cache_value);
return node, host, resource, target_subdomain;
end
--- Utility function to check and convert a room JID from
--- virtual room1@conference.foo.example.com to real [foo]room1@conference.example.com
-- @param room_jid the room jid to match and rewrite if needed
-- @param stanza the stanza
-- @return returns room jid [foo]room1@conference.example.com when it has subdomain
-- otherwise room1@conference.example.com(the room_jid value untouched)
local function room_jid_match_rewrite(room_jid, stanza)
local node, _, resource, target_subdomain = room_jid_split_subdomain(room_jid);
if not target_subdomain then
-- module:log("debug", "No need to rewrite out 'to' %s", room_jid);
return room_jid;
end
-- Ok, rewrite room_jid address to new format
local new_node, new_host, new_resource;
if node then
new_node, new_host, new_resource = "["..target_subdomain.."]"..node, muc_domain, resource;
else
-- module:log("debug", "No room name provided so rewriting only host 'to' %s", room_jid);
new_host, new_resource = muc_domain, resource;
if (stanza and stanza.attr and stanza.attr.id) then
roomless_iqs[stanza.attr.id] = stanza.attr.to;
end
end
return jid.join(new_node, new_host, new_resource);
end
-- Utility function to check and convert a room JID from real [foo]room1@muc.example.com to virtual room1@muc.foo.example.com
local function internal_room_jid_match_rewrite(room_jid, stanza)
-- first check for roomless_iqs
if (stanza and stanza.attr and stanza.attr.id and roomless_iqs[stanza.attr.id]) then
local result = roomless_iqs[stanza.attr.id];
roomless_iqs[stanza.attr.id] = nil;
return result;
end
local ret = internal_room_jid_cache:get(room_jid);
if ret then
return ret;
end
local node, host, resource = jid.split(room_jid);
if host ~= muc_domain or not node then
-- module:log("debug", "No need to rewrite %s (not from the MUC host)", room_jid);
internal_room_jid_cache:set(room_jid, room_jid);
return room_jid;
end
local target_subdomain, target_node = extract_subdomain(node);
if not (target_node and target_subdomain) then
-- module:log("debug", "Not rewriting... unexpected node format: %s", node);
internal_room_jid_cache:set(room_jid, room_jid);
return room_jid;
end
-- Ok, rewrite room_jid address to pretty format
ret = jid.join(target_node, muc_domain_prefix..".".. target_subdomain.."."..muc_domain_base, resource);
internal_room_jid_cache:set(room_jid, ret);
return ret;
end
--- Finds and returns room by its jid
-- @param room_jid the room jid to search in the muc component
-- @return returns room if found or nil
function get_room_from_jid(room_jid)
local _, host = jid.split(room_jid);
local component = hosts[host];
if component then
local muc = component.modules.muc
if muc then
return muc.get_room_from_jid(room_jid);
else
return
end
end
end
-- Returns the room if available, work and in multidomain mode
-- @param room_name the name of the room
-- @param group name of the group (optional)
-- @return returns room if found or nil
function get_room_by_name_and_subdomain(room_name, subdomain)
local room_address;
-- if there is a subdomain we are in multidomain mode and that subdomain is not our main host
if subdomain and subdomain ~= "" and subdomain ~= muc_domain_base then
room_address = jid.join("["..subdomain.."]"..room_name, muc_domain);
else
room_address = jid.join(room_name, muc_domain);
end
return get_room_from_jid(room_address);
end
function async_handler_wrapper(event, handler)
if not have_async then
module:log("error", "requires a version of Prosody with util.async");
return nil;
end
local runner = async.runner;
-- Grab a local response so that we can send the http response when
-- the handler is done.
local response = event.response;
local async_func = runner(
function (event)
local result = handler(event)
-- If there is a status code in the result from the
-- wrapped handler then add it to the response.
if tonumber(result.status_code) ~= nil then
response.status_code = result.status_code
end
-- If there are headers in the result from the
-- wrapped handler then add them to the response.
if result.headers ~= nil then
response.headers = result.headers
end
-- Send the response to the waiting http client with
-- or without the body from the wrapped handler.
if result.body ~= nil then
response:send(result.body)
else
response:send();
end
end
)
async_func:run(event)
-- return true to keep the client http connection open.
return true;
end
--- Updates presence stanza, by adding identity node
-- @param stanza the presence stanza
-- @param user the user to which presence we are updating identity
-- @param group the group of the user to which presence we are updating identity
-- @param creator_user the user who created the user which presence we
-- are updating (this is the poltergeist case, where a user creates
-- a poltergeist), optional.
-- @param creator_group the group of the user who created the user which
-- presence we are updating (this is the poltergeist case, where a user creates
-- a poltergeist), optional.
function update_presence_identity(stanza, user, group, creator_user, creator_group)
-- First remove any 'identity' element if it already
-- exists, so it cannot be spoofed by a client
stanza:maptags(
function(tag)
for k, v in pairs(tag) do
if k == "name" and v == "identity" then
return nil
end
end
return tag
end
);
if not user then
return;
end
stanza:tag("identity"):tag("user");
for k, v in pairs(user) do
v = tostring(v)
stanza:tag(k):text(v):up();
end
stanza:up();
-- Add the group information if it is present
if group then
stanza:tag("group"):text(group):up();
end
-- Add the creator user information if it is present
if creator_user then
stanza:tag("creator_user");
for k, v in pairs(creator_user) do
stanza:tag(k):text(v):up();
end
stanza:up();
-- Add the creator group information if it is present
if creator_group then
stanza:tag("creator_group"):text(creator_group):up();
end
end
stanza:up(); -- Close identity tag
end
-- Utility function to check whether feature is present and enabled. Allow
-- a feature if there are features present in the session(coming from
-- the token) and the value of the feature is true.
-- if features are missing from the token we check whether it is moderator
function is_feature_allowed(ft, features, is_moderator)
if features then
return features[ft] == "true" or features[ft] == true;
else
return is_moderator;
end
end
--- Extracts the subdomain and room name from internal jid node [foo]room1
-- @return subdomain(optional, if extracted or nil), the room name, the customer_id in case of vpaas
function extract_subdomain(room_node)
local ret = extract_subdomain_cache:get(room_node);
if ret then
return ret.subdomain, ret.room, ret.customer_id;
end
local subdomain, room_name = room_node:match("^%[([^%]]+)%](.+)$");
if not subdomain then
room_name = room_node;
end
local _, customer_id = subdomain and subdomain:match("^(vpaas%-magic%-cookie%-)(.*)$") or nil, nil;
local cache_value = { subdomain=subdomain, room=room_name, customer_id=customer_id };
extract_subdomain_cache:set(room_node, cache_value);
return subdomain, room_name, customer_id;
end
function starts_with(str, start)
if not str then
return false;
end
return str:sub(1, #start) == start
end
function starts_with_one_of(str, prefixes)
if not str or not prefixes then
return false;
end
if is_set(prefixes) then
-- set is a table with keys and value of true
for k, _ in prefixes:items() do
if starts_with(str, k) then
return k;
end
end
else
for _, v in pairs(prefixes) do
if starts_with(str, v) then
return v;
end
end
end
return false
end
function ends_with(str, ending)
if not str then
return false;
end
return ending == "" or str:sub(-#ending) == ending
end
-- healthcheck rooms in jicofo starts with a string '__jicofo-health-check'
function is_healthcheck_room(room_jid)
return starts_with(room_jid, "__jicofo-health-check");
end
--- Utility function to make an http get request and
--- retry @param retry number of times
-- @param url endpoint to be called
-- @param retry nr of retries, if retry is
-- @param auth_token value to be passed as auth Bearer
-- nil there will be no retries
-- @returns result of the http call or nil if
-- the external call failed after the last retry
function http_get_with_retry(url, retry, auth_token)
local content, code, cache_for;
local timeout_occurred;
local wait, done = async.waiter();
local request_headers = http_headers or {}
if auth_token ~= nil then
request_headers['Authorization'] = 'Bearer ' .. auth_token
end
local function cb(content_, code_, response_, request_)
if timeout_occurred == nil then
code = code_;
if code == 200 or code == 204 then
-- module:log("debug", "External call was successful, content %s", content_);
content = content_;
-- if there is cache-control header, let's return the max-age value
if response_ and response_.headers and response_.headers['cache-control'] then
local vals = {};
for k, v in response_.headers['cache-control']:gmatch('(%w+)=(%w+)') do
vals[k] = v;
end
-- max-age=123 will be parsed by the regex ^ to age=123
cache_for = vals.age;
end
else
module:log("warn", "Error on GET request: Code %s, Content %s",
code_, content_);
end
done();
else
module:log("warn", "External call reply delivered after timeout from: %s", url);
end
end
local function call_http()
return http.request(url, {
headers = request_headers,
method = "GET"
}, cb);
end
local request = call_http();
local function cancel()
-- TODO: This check is racey. Not likely to be a problem, but we should
-- still stick a mutex on content / code at some point.
if code == nil then
timeout_occurred = true;
module:log("warn", "Timeout %s seconds making the external call to: %s", http_timeout, url);
-- no longer present in prosody 0.11, so check before calling
if http.destroy_request ~= nil then
http.destroy_request(request);
end
if retry == nil then
module:log("debug", "External call failed and retry policy is not set");
done();
elseif retry ~= nil and retry < 1 then
module:log("debug", "External call failed after retry")
done();
else
module:log("debug", "External call failed, retry nr %s", retry)
retry = retry - 1;
request = call_http()
return http_timeout;
end
end
end
timer.add_task(http_timeout, cancel);
wait();
return content, code, cache_for;
end
-- Checks whether there is status in the <x node
-- @param muc_x the <x element from presence
-- @param status checks for this status
-- @returns true if the status is found, false otherwise or if no muc_x is provided.
function presence_check_status(muc_x, status)
if not muc_x then
return false;
end
for statusNode in muc_x:childtags('status') do
if statusNode.attr.code == status then
return true;
end
end
return false;
end
-- Retrieves the focus from the room and cache it in the room object
-- @param room The room name for which to find the occupant
local function get_focus_occupant(room)
return room:get_occupant_by_nick(room.jid..'/focus');
end
-- Checks whether the jid is moderated, the room name is in moderated_rooms
-- or if the subdomain is in the moderated_subdomains
-- @return returns on of the:
-- -> false
-- -> true, room_name, subdomain
-- -> true, room_name, nil (if no subdomain is used for the room)
function is_moderated(room_jid)
if moderated_subdomains:empty() and moderated_rooms:empty() then
return false;
end
local room_node = jid.node(room_jid);
-- parses bare room address, for multidomain expected format is:
-- [subdomain]roomName@conference.domain
local target_subdomain, target_room_name = extract_subdomain(room_node);
if target_subdomain then
if moderated_subdomains:contains(target_subdomain) then
return true, target_room_name, target_subdomain;
end
elseif moderated_rooms:contains(room_node) then
return true, room_node, nil;
end
return false;
end
-- check if the room tenant starts with vpaas-magic-cookie-
-- @param room the room to check
function is_vpaas(room)
if not room then
return false;
end
-- stored check in room object if it exist
if room.is_vpaas ~= nil then
return room.is_vpaas;
end
room.is_vpaas = false;
local node, host = jid.split(room.jid);
if host ~= muc_domain or not node then
return false;
end
local tenant, conference_name = node:match('^%[([^%]]+)%](.+)$');
if not (tenant and conference_name) then
return false;
end
if not starts_with(tenant, 'vpaas-magic-cookie-') then
return false;
end
room.is_vpaas = true;
return true;
end
-- Returns the initiator extension if the stanza is coming from a sip jigasi
function is_sip_jigasi(stanza)
if not stanza then
return false;
end
return stanza:get_child('initiator', 'http://jitsi.org/protocol/jigasi');
end
-- This requires presence stanza being passed
function is_transcriber_jigasi(stanza)
if not stanza then
return false;
end
local features = stanza:get_child('features');
if not features then
return false;
end
for i = 1, #features do
local feature = features[i];
if feature.attr and feature.attr.var and feature.attr.var == 'http://jitsi.org/protocol/transcriber' then
return true;
end
end
return false;
end
function is_transcriber(jid)
return starts_with_one_of(jid, TRANSCRIBER_PREFIXES);
end
function get_sip_jibri_email_prefix(email)
if not email then
return nil;
elseif starts_with_one_of(email, INBOUND_SIP_JIBRI_PREFIXES) then
return starts_with_one_of(email, INBOUND_SIP_JIBRI_PREFIXES);
elseif starts_with_one_of(email, OUTBOUND_SIP_JIBRI_PREFIXES) then
return starts_with_one_of(email, OUTBOUND_SIP_JIBRI_PREFIXES);
else
return nil;
end
end
function is_sip_jibri_join(stanza)
if not stanza then
return false;
end
local features = stanza:get_child('features');
local email = stanza:get_child_text('email');
if not features or not email then
return false;
end
for i = 1, #features do
local feature = features[i];
if feature.attr and feature.attr.var and feature.attr.var == "http://jitsi.org/protocol/jibri" then
if get_sip_jibri_email_prefix(email) then
module:log("debug", "Occupant with email %s is a sip jibri ", email);
return true;
end
end
end
return false
end
function is_jibri(occupant)
return starts_with_one_of(type(occupant) == "string" and occupant or occupant.jid, RECORDER_PREFIXES)
end
-- process a host module directly if loaded or hooks to wait for its load
function process_host_module(name, callback)
local function process_host(host)
if host == name then
callback(module:context(host), host);
end
end
if prosody.hosts[name] == nil then
module:log('info', 'No host/component found, will wait for it: %s', name)
-- when a host or component is added
prosody.events.add_handler('host-activated', process_host, -100); -- make sure everything is loaded
else
process_host(name);
end
end
function table_shallow_copy(t)
local t2 = {}
for k, v in pairs(t) do
t2[k] = v
end
return t2
end
local function table_find(tab, val)
if not tab or val == nil then
return nil
end
for i, v in ipairs(tab) do
if v == val then
return i
end
end
return nil
end
-- Adds second table values to the first table
local function table_add(t1, t2)
for _,v in ipairs(t2) do
table.insert(t1, v);
end
end
-- Returns as a first result the removed items and as a second the added items
local function table_compare(old_table, new_table)
local removed = {}
local added = {}
local modified = {}
-- Find removed items (in old but not in new)
for id, value in pairs(old_table) do
if new_table[id] == nil then
table.insert(removed, id)
elseif new_table[id] ~= value then
table.insert(modified, id)
end
end
-- Find added items (in new but not in old)
for id, _ in pairs(new_table) do
if old_table[id] == nil then
table.insert(added, id)
end
end
return removed, added, modified
end
local function table_equals(t1, t2)
if t1 == nil then
return t2 == nil;
end
if t2 == nil then
return t1 == nil;
end
local removed, added, modified = table_compare(t1, t2);
return next(removed) == nil and next(added) == nil and next(modified) == nil
end
-- Splits a string using delimiter
function split_string(str, delimiter)
str = str .. delimiter;
local result = array();
for w in str:gmatch("(.-)" .. delimiter) do
result:push(w);
end
return result;
end
-- send iq result that the iq was received and will be processed
function respond_iq_result(origin, stanza)
-- respond with successful receiving the iq
origin.send(st.iq({
type = 'result';
from = stanza.attr.to;
to = stanza.attr.from;
id = stanza.attr.id
}));
end
-- Note: http_server.get_request_from_conn() was added in Prosody 0.12.3,
-- this code provides backwards compatibility with older versions
local get_request_from_conn = http_server.get_request_from_conn or function (conn)
local response = conn and conn._http_open_response;
return response and response.request or nil;
end;
-- Discover real remote IP of a session
function get_ip(session)
local request = get_request_from_conn(session.conn);
return request and request.ip or session.ip;
end
-- Checks whether the provided jid is in the list of admins
-- we are not using the new permissions and roles api as we have few global modules which need to be
-- refactored into host modules, as that api needs to be executed in host context
local function is_admin(_jid)
local bare_jid = jid.bare(_jid);
if config_global_admin_jids:contains(bare_jid) or config_admin_jids:contains(bare_jid) then
return true;
end
return false;
end
-- Filter out identity information (nick name, email, etc) from a presence stanza.
local function filter_identity_from_presence(orig_stanza)
local stanza = st.clone(orig_stanza);
stanza:remove_children('nick', 'http://jabber.org/protocol/nick');
stanza:remove_children('email');
stanza:remove_children('stats-id');
local identity = stanza:get_child('identity');
if identity then
local user = identity:get_child('user');
local name = identity:get_child('name');
if user then
user:remove_children('email');
user:remove_children('name');
end
if name then
name:remove_children('name'); -- Remove name with no namespace
end
end
return stanza;
end
return {
OUTBOUND_SIP_JIBRI_PREFIXES = OUTBOUND_SIP_JIBRI_PREFIXES;
INBOUND_SIP_JIBRI_PREFIXES = INBOUND_SIP_JIBRI_PREFIXES;
RECORDER_PREFIXES = RECORDER_PREFIXES;
extract_subdomain = extract_subdomain;
filter_identity_from_presence = filter_identity_from_presence;
is_admin = is_admin;
is_feature_allowed = is_feature_allowed;
is_jibri = is_jibri;
is_healthcheck_room = is_healthcheck_room;
is_moderated = is_moderated;
is_sip_jibri_join = is_sip_jibri_join;
is_sip_jigasi = is_sip_jigasi;
is_transcriber = is_transcriber;
is_transcriber_jigasi = is_transcriber_jigasi;
is_vpaas = is_vpaas;
get_focus_occupant = get_focus_occupant;
get_ip = get_ip;
get_room_from_jid = get_room_from_jid;
get_room_by_name_and_subdomain = get_room_by_name_and_subdomain;
get_sip_jibri_email_prefix = get_sip_jibri_email_prefix;
async_handler_wrapper = async_handler_wrapper;
presence_check_status = presence_check_status;
process_host_module = process_host_module;
respond_iq_result = respond_iq_result;
room_jid_match_rewrite = room_jid_match_rewrite;
room_jid_split_subdomain = room_jid_split_subdomain;
internal_room_jid_match_rewrite = internal_room_jid_match_rewrite;
update_presence_identity = update_presence_identity;
http_get_with_retry = http_get_with_retry;
ends_with = ends_with;
split_string = split_string;
starts_with = starts_with;
starts_with_one_of = starts_with_one_of;
table_add = table_add;
table_compare = table_compare;
table_shallow_copy = table_shallow_copy;
table_find = table_find;
table_equals = table_equals;
};

View File

@@ -0,0 +1,88 @@
#!/bin/bash
set -e
EMAIL=$1
DOMAIN=$2
if [ -z "${DOMAIN}" ] || [ -z "${EMAIL}" ]; then
echo "You need to provide email and domain as parameters."
exit 1
fi
JITSI_INSTALLATION="DEBIAN"
JAAS_ENDPOINT="https://account-provisioning.cloudflare.jitsi.net/operations"
CHALLENGE_FILE="/usr/share/jitsi-meet/.well-known/jitsi-challenge.txt"
SUPPORT_MSG="Reach out to JaaS support or retry with /usr/share/jitsi-meet/scripts/register-jaas-account.sh"
create_error=0
create_data=$(curl -s -f -X 'POST' "${JAAS_ENDPOINT}" -H 'Content-Type: application/json' -H 'accept: */*' \
-d "{ \"domain\": \"${DOMAIN}\", \"email\": \"${EMAIL}\", \"jitsiInstallation\": \"${JITSI_INSTALLATION}\" }") || create_error=$?
if [ ${create_error} -ne 0 ]; then
echo "Account creation failed. Status: ${create_error}, response: ${create_data}"
exit 2
fi
# make sure .well-known exists
mkdir -p "$(dirname "$CHALLENGE_FILE")"
# Creating the challenge file
echo "${create_data}" | jq -r .challenge > ${CHALLENGE_FILE}
op_id=$(echo "${create_data}" | jq -r .operationId)
ready_error=0
ready_data=$(curl -s -f -X 'PUT' "${JAAS_ENDPOINT}/${op_id}/ready") || ready_error=$?
if [ ${ready_error} -ne 0 ]; then
echo "Validating domain failed. Status: ${ready_error}"
echo "Response: "
echo "${ready_data}" | jq -r
echo "${SUPPORT_MSG}"
echo
exit 3
fi
SLEEP_TIME=0
WAIT_BEFORE_CHECK=10
TIMEOUT=60
echo -n "Creating..."
(while true; do
provisioned_data=$(curl -s -f "${JAAS_ENDPOINT}/${op_id}")
status=$(echo "${provisioned_data}" | jq -r .status)
if [ "${status}" == "PROVISIONED" ]; then
echo ""
echo "=================="
echo ""
echo "JaaS account was created. To finish setup follow the email that was sent."
echo ""
echo "=================="
exit 0;
elif [ "${status}" == "FAILED" ]; then
echo ""
echo "=================="
echo ""
echo "JaaS account creation failed:${provisioned_data}"
echo ""
echo "=================="
exit 4
elif [ "${status}" == "VERIFIED" ] && [ "${verified}" != "true" ]; then
echo -n "Account was successfully verified..."
verified="true"
fi
if [ ${SLEEP_TIME} -ge ${TIMEOUT} ]; then
echo ""
echo "=================="
echo ""
echo "Timeout creating account. ${SUPPORT_MSG}"
echo ""
echo "=================="
exit 5
fi
echo -n "waiting..."
sleep ${WAIT_BEFORE_CHECK}
SLEEP_TIME=$((SLEEP_TIME+WAIT_BEFORE_CHECK))
done)
rm ${CHALLENGE_FILE} || true

Some files were not shown because too many files have changed in this diff Show More