snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 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,133 @@
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
["polls.v1.meet.jitsi"] = "tcp://127.0.0.1:52691";
["conference.v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
["v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
["polls.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";
["polls.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";
["polls.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";
["polls.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";
["polls.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";
["polls.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";
["polls.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",
'polls.v1.meet.jitsi', 'polls.v2.meet.jitsi', 'polls.v3.meet.jitsi', 'polls.v4.meet.jitsi',
'polls.v5.meet.jitsi', 'polls.v6.meet.jitsi', 'polls.v7.meet.jitsi', 'polls.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,63 @@
#!/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-)
# Install SystemD template unit (once, outside loop)
cp prosody-v@.service /usr/lib/systemd/system/prosody-v@.service
systemctl daemon-reload
# Configure prosody instances
for (( i=1 ; i<=${NUMBER_OF_INSTANCES} ; i++ ));
do
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
# Enable and start the systemd instance
systemctl enable --now prosody-v@${i}.service
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
# Restart all prosody visitor instances
systemctl restart prosody-v@*
systemctl restart jicofo

View File

@@ -0,0 +1,46 @@
[Unit]
### see man systemd.unit
Description=Prosody v%i (visitor v%i) 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-v%i/prosody.cfg.lua -F
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-abnormal
### see man systemd.exec
User=prosody
Group=prosody
UMask=0027
RuntimeDirectory=prosody-v%i
ConfigurationDirectory=prosody-v%i
StateDirectory=prosody-v%i
StateDirectoryMode=0750
LogsDirectory=prosody-v%i
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,145 @@
---------- 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
'polls.jitmeet.example.com'; -- polls component
};
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
['polls.jitmeet.example.com'] = "tcp://127.0.0.1:5269"; -- polls 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';
'features_identity';
}
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
Component 'polls.vX.meet.jitsi' 'polls_component'

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

@@ -0,0 +1,268 @@
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
/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 the "file-upload" feature
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 Let’s 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,116 @@
#### 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.
- allowUnauthenticatedAccess - Option that works only with tenants. When it is false only jwt authenticated participants(visitors) are allowed in the meeting and their token(or sub claim for jaas) should match the one in the room. Default value when missing is true.
- 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.
- lobby_disabled - Whether lobby was disabled for the room by the backend.
- 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.
- 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.
- 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.lua and used 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.
# session fields added by jitsi
- jitsi_breakout_main_jid - Once an occupant joins main room we mark this to check it when joining breakout rooms.
- jitsi_meet_context_user - The context from the jwt token, added after token verify.
- jitsi_meet_context_group - The group from the jwt context, added after token verify.
- jitsi_meet_context_features - The features from the context, added after token verify.
- jitsi_meet_context_room - The room settings from the jwt context, added after token verify.
- jitsi_meet_room - The room name in jwt token, added after token verify.
- jitsi_meet_verified_room - The jid of the room the session was verified for on join. Set by mod_token_verification.lua and used to re-check claims that are refreshed later, when a resuming connection presents a different token.
- jitsi_meet_str_tenant - The tenant in the context. Added after token verify.
- jitsi_meet_domain - The domain in the jwt ('sub' claim). Added after token verify. Can be the domain if not tenant is used or the tenant itself in lowercase.
- customusername - from a query parameter to be used with combination with "pre-jitsi-authentication" event to pre-set a known jid to a session.
- jitsi_web_query_room - room name from the query.
- jitsi_web_query_prefix - the tenant from the query specified as a param named 'prefix'.
- auth_token - The token, set before verify and cleared if verification fails.
- jitsi_meet_tenant_mismatch - The tenant field from the token and the query param for tenant do not match.
- previd - Used for stream resumption.
- user_region - the region header from the http request received.
- user_agent_header - the user agent header from the http request received.
- jitsi_throttle - used by rate limit module.
- jitsi_throttle_counter - used by rate limit module.
- force_permissions_update - Indicate that on next self-presence update the permissions should be resent to the client. Used by mod_jitsi_permissions.lua to manage permissions updates for the session.
- granted_jitsi_meet_context_user_id - when affiliation was changed (grant moderation) this holds the id of the actor.
- granted_jitsi_meet_context_group_id - when affiliation was changed (grant moderation) this holds the group of the actor.
#### 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,271 @@
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
-- Expose internals for unit testing (harmless in production).
M._internals = {
verify_claim = verify_claim,
split_token = split_token,
parse_token = parse_token,
strip_signature = strip_signature,
}
return M

View File

@@ -0,0 +1,697 @@
-- mod_audio_translation_component.lua
--
-- Prosody component that collects per-receiver live-translation subscriptions
-- and exposes the aggregated <senderId, set<language>> map to jicofo (and only
-- jicofo) through RoomMetadata.
--
-- ── Subscription updates (client → component) ────────────────────────────────
-- A receiver sends a <message> to this component containing an <audio-translation
-- xmlns='http://jitsi.org/jitmeet'> child whose text is a JSON object that maps a
-- sender's endpoint id to a target language:
--
-- { "a1b2c3d4": "en", "b2c3d4e5": "" }
--
-- The payload is a DELTA merged into the receiver's existing subscription map:
-- · "<lang>" adds/replaces the subscription for that sender (one language per
-- sender — a new language replaces the previous one).
-- · "" removes the subscription for that sender (no-op if absent).
-- Senders/languages not mentioned in the delta are left untouched.
--
-- The whole stanza is applied atomically: if any entry fails validation the
-- entire delta is rejected with an error reply and no state changes.
--
-- Processing (on_message):
-- 1. The sender must be an occupant of the room identified by the session's
-- jitsi_web_query_room field (set from the WebSocket ?room= URL param).
-- 2. If the feature is disabled in RoomMetadata (audioTranslation.enabled ==
-- false) the message is a silent no-op. A missing flag means enabled.
-- 3. The receiver must have the 'live-translation-subscribe' permission, which
-- everybody has unless the token explicitly sets it to "false".
-- 4. Each entry is validated: senderId is 8 hex digits and a current occupant,
-- language is alphabetical and at most 20 characters (or "" to remove).
-- 5. A receiver may hold at most audio_translation_max_subscriptions distinct
-- subscriptions (unlimited by default; set the option to enforce a cap).
--
-- ── Aggregate map (component → jicofo) ───────────────────────────────────────
-- Whenever the set of subscriptions changes, a debounced task recomputes the
-- aggregate { [senderId] = { lang, ... } } (the union of all receivers' requests)
-- and stores it on room._data.audioTranslationRequests, then fires
-- 'room-metadata-changed' so mod_room_metadata_component broadcasts it. That
-- module injects audioTranslationRequests into the metadata only for admin
-- (jicofo) occupants, so regular clients never see it.
--
-- ── Directed listeners push (component → each sender) ─────────────────────────────
-- Alongside the jicofo aggregate, each sender is told which receivers currently translate it.
-- An inverted map { [senderId] = set<receiverId> } is maintained in lockstep with the forward
-- subscription map (updated incrementally on every delta and on occupant-leave), and a directed
-- <translation-listeners xmlns='http://jitsi.org/jitmeet'> message (sorted JSON array) is sent to
-- each sender whose listener set changed, so a client badges the receivers translating the local
-- user. Only senders flagged dirty since the last publish are reconsidered, so the cost is
-- proportional to what changed rather than to room size. Gated exactly like the aggregate; an empty
-- array clears a sender whose last listener left. Handled in publish_listeners(), kept separate so
-- the jicofo-facing publish() is unchanged.
--
-- ── Enable-flag write gating ─────────────────────────────────────────────────
-- The audioTranslation metadata key (which carries the room-level enable flag)
-- is written through mod_room_metadata_component. This module hooks
-- 'jitsi-metadata-allow-moderation' to gate that write behind the
-- 'live-translation' permission (moderators by default) and to sanitise the
-- stored value to { enabled = <bool> }.
--
-- Component "audiotranslation.jitmeet.example.com" "audio_translation_component"
-- muc_component = "conference.jitmeet.example.com"
-- breakout_rooms_component = "breakout.jitmeet.example.com"
local json = require 'util.json';
local st = require 'util.stanza';
local jid_resource = require 'util.jid'.resource;
local timer = require 'util.timer';
local util = module:require 'util';
local is_healthcheck_room = util.is_healthcheck_room;
local is_feature_allowed = util.is_feature_allowed;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
local process_host_module = util.process_host_module;
local AUDIO_TRANSLATION_NS = 'http://jitsi.org/jitmeet';
local ELEMENT_NAME = 'audio-translation';
-- RoomMetadata key carrying the room-level enable flag ({ enabled = bool }).
-- Client/preset writable through mod_room_metadata_component; read-only here.
local ENABLED_METADATA_KEY = 'audioTranslation';
-- Aggregate map exposed to jicofo only. Stored on room._data (never in
-- jitsiMetadata) so it is never broadcast to regular clients.
local REQUESTS_METADATA_KEY = 'audioTranslationRequests';
-- Per-(sender, language) subscriber counts. Client-visible (kept in jitsiMetadata): counts only, never
-- identities. Clients combine these with the bridge's synthetic-source sending state to show how many
-- participants are still hearing a given speaker.
local LISTENER_COUNTS_METADATA_KEY = 'audioTranslationListenerCounts';
local ENABLE_PERMISSION = 'live-translation';
local SUBSCRIBE_PERMISSION = 'live-translation-subscribe';
local LANG_MAX_LENGTH = 20;
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
-- Breakout rooms live on a separate MUC component; hook it too (optional).
local breakout_rooms_component_host = module:get_option_string('breakout_rooms_component');
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
-- Maximum distinct subscriptions a single receiver may hold. Unlimited unless the option is set.
local max_subscriptions = module:get_option_number('audio_translation_max_subscriptions');
-- Coalesce bursts of subscription changes into a single metadata update.
local debounce_interval = module:get_option_number('audio_translation_debounce_interval', 0.5);
module:log('info', 'Starting audio_translation for %s (max_subscriptions=%s, debounce=%ss)',
muc_component_host, max_subscriptions and tostring(max_subscriptions) or 'unlimited', tostring(debounce_interval));
local main_muc_module;
-- Returns (creating if needed) the per-room translation state:
-- { receivers = { [receiverId] = { [senderId] = lang } }, -- forward map (source of truth)
-- last_published = <json|nil>, -- jicofo aggregate dedup
-- listeners_by_sender = { [senderId] = { [receiverId] = true } }, -- inverted map, kept in lockstep
-- dirty_senders = { [senderId] = true }, -- senders changed since last publish
-- last_sent_listeners = { [senderId] = <json> } } -- directed-push dedup
-- receivers drives per-receiver atomic updates and the jicofo aggregate; listeners_by_sender is
-- maintained alongside it so the directed per-sender push costs work proportional to what changed.
local function get_state(room)
if not room._audio_translation then
room._audio_translation = {
receivers = {};
listeners_by_sender = {};
dirty_senders = {};
last_sent_listeners = {};
};
end
return room._audio_translation;
end
-- Flags a sender for reconsideration on the next listeners publish.
local function mark_sender_dirty(state, sender_id)
state.dirty_senders[sender_id] = true;
end
-- Records that receiver_id now translates sender_id in the inverted map, flagging the sender dirty.
local function add_listener(state, sender_id, receiver_id)
local set = state.listeners_by_sender[sender_id];
if not set then
set = {};
state.listeners_by_sender[sender_id] = set;
end
set[receiver_id] = true;
mark_sender_dirty(state, sender_id);
end
-- Records that receiver_id no longer translates sender_id, dropping the set once it empties and
-- flagging the sender dirty so the change is pushed (an empty set clears the sender).
local function remove_listener(state, sender_id, receiver_id)
local set = state.listeners_by_sender[sender_id];
if set then
set[receiver_id] = nil;
if next(set) == nil then
state.listeners_by_sender[sender_id] = nil;
end
end
mark_sender_dirty(state, sender_id);
end
-- Flags every currently-tracked sender dirty. Used when room-wide gating (the enable flag) flips,
-- which changes every sender's pushed list at once with no per-sender delta to key off.
local function mark_all_senders_dirty(state)
for sender_id in pairs(state.listeners_by_sender) do
state.dirty_senders[sender_id] = true;
end
for sender_id in pairs(state.last_sent_listeners) do
state.dirty_senders[sender_id] = true;
end
end
-- The endpoint id of an occupant is the resource part of its in-room nick.
local function endpoint_id(occupant)
return jid_resource(occupant.nick);
end
-- True unless the feature is explicitly disabled (audioTranslation.enabled == false).
-- A missing audioTranslation key, or a key without an explicit enabled=false,
-- means enabled.
local function is_enabled(room)
local at = room.jitsiMetadata and room.jitsiMetadata[ENABLED_METADATA_KEY];
if type(at) == 'table' and at.enabled == false then
return false;
end
return true;
end
-- Everyone may subscribe unless the token explicitly sets the permission to false.
local function subscribe_allowed(session)
local features = session and session.jitsi_meet_context_features;
if features then
local v = features[SUBSCRIBE_PERMISSION];
if v == false or v == 'false' then
return false;
end
end
return true;
end
local function is_valid_sender_id(id)
return type(id) == 'string' and id:match('^%x%x%x%x%x%x%x%x$') ~= nil;
end
local function is_valid_language(lang)
return type(lang) == 'string' and #lang <= LANG_MAX_LENGTH and lang:match('^%a+$') ~= nil;
end
-- Applies a delta to the receiver's subscription map atomically.
-- Returns true on success, or false plus an (error-type, condition) pair.
local function apply_delta(room, receiver_id, delta)
local state = get_state(room);
local current = state.receivers[receiver_id] or {};
-- Work on a copy so a mid-delta failure leaves the stored map untouched.
local next_map = {};
for sender_id, lang in pairs(current) do
next_map[sender_id] = lang;
end
for sender_id, lang in pairs(delta) do
if not is_valid_sender_id(sender_id) then
return false, 'modify', 'bad-request';
end
if type(lang) ~= 'string' then
return false, 'modify', 'bad-request';
end
if lang == '' then
-- Removal; benign no-op when the sender is not currently subscribed.
next_map[sender_id] = nil;
else
if not is_valid_language(lang) then
return false, 'modify', 'bad-request';
end
if not room:get_occupant_by_nick(room.jid..'/'..sender_id) then
return false, 'cancel', 'item-not-found';
end
next_map[sender_id] = lang;
end
end
local count = 0;
for _ in pairs(next_map) do
count = count + 1;
end
if max_subscriptions and count > max_subscriptions then
return false, 'cancel', 'policy-violation';
end
state.receivers[receiver_id] = next_map;
-- Update the inverted map in lockstep, now that the delta has fully validated (so a rejected
-- delta never mutates it). Only membership changes move listeners; a language-only change
-- (sender present before and after) is not a listener change and leaves the sender untouched.
for sender_id in pairs(current) do
if not next_map[sender_id] then
remove_listener(state, sender_id, receiver_id);
end
end
for sender_id in pairs(next_map) do
if not current[sender_id] then
add_listener(state, sender_id, receiver_id);
end
end
return true;
end
-- Builds the aggregate { [senderId] = { lang, ... } } (union over all receivers),
-- or nil when there are no subscriptions.
local function compute_aggregate(room)
local sets = {};
local any = false;
for _, subs in pairs(get_state(room).receivers) do
for sender_id, lang in pairs(subs) do
local set = sets[sender_id];
if not set then
set = {};
sets[sender_id] = set;
end
set[lang] = true;
any = true;
end
end
if not any then
return nil;
end
local out = {};
for sender_id, set in pairs(sets) do
local langs = {};
for lang in pairs(set) do
langs[#langs + 1] = lang;
end
table.sort(langs);
out[sender_id] = langs;
end
return out;
end
-- Builds { [senderId] = { [lang] = <subscriber count> } }, or nil when there are no subscriptions.
local function compute_listener_counts(room)
local out = {};
local any = false;
for _, subs in pairs(get_state(room).receivers) do
for sender_id, lang in pairs(subs) do
local counts = out[sender_id];
if not counts then
counts = {};
out[sender_id] = counts;
end
counts[lang] = (counts[lang] or 0) + 1;
any = true;
end
end
return any and out or nil;
end
-- Recomputes the aggregate and, when it changed, publishes it to jicofo via
-- RoomMetadata. Stored on room._data so mod_room_metadata_component only forwards
-- it to admin (jicofo) occupants.
-- While the feature is disabled for the room the published map is cleared (nil) so
-- jicofo stops translation; the receivers' subscriptions are kept in memory and
-- republished if the room is re-enabled.
local function publish(room)
if not room._audio_translation or not main_muc_module then
return;
end
-- Neutral, default-open extension point: an external module may veto publishing
-- the translation request map by returning false from this event. No handler, or
-- any non-false return, means allowed. This keeps any gating/entitlement logic out
-- of this module (open by default). Follows the same false=deny / nil=no-opinion
-- convention as 'jitsi-metadata-allow-moderation'.
local allow_publish = main_muc_module:fire_event('jitsi-audio-translation-allow-publish', { room = room; }) ~= false;
-- The request set the receivers have asked for, independent of gating. Computed
-- unconditionally so we can tell "nothing requested" apart from "requests suppressed".
local requested = compute_aggregate(room);
local gated = allow_publish and is_enabled(room);
local aggregate = gated and requested or nil;
local listener_counts = gated and compute_listener_counts(room) or nil;
-- Deduped on both: counts can change while the language set does not (a second receiver picking a
-- language another receiver already requested). Stays nil when there is nothing to publish, so a gated
-- or empty room still broadcasts nothing.
local encoded = (aggregate or listener_counts)
and ((aggregate and json.encode(aggregate) or '')
..'|'..(listener_counts and json.encode(listener_counts) or ''))
or nil;
if encoded == room._audio_translation.last_published then
return;
end
room._audio_translation.last_published = encoded;
-- Log when we transition to suppressing a non-empty request set (feature disabled for
-- the room, or an external handler vetoed publishing). Deduped by last_published above,
-- so this fires on the transition, not on every update. Normally the client hides the
-- control, so a suppressed request set is worth being able to discover.
if aggregate == nil and requested ~= nil then
module:log('warn', 'Not publishing audio-translation requests in room %s: %s', room.jid,
allow_publish and 'disabled for the room' or 'publishing vetoed by a handler');
end
room._data.audioTranslationRequests = aggregate;
room.jitsiMetadata = room.jitsiMetadata or {};
room.jitsiMetadata[LISTENER_COUNTS_METADATA_KEY] = listener_counts;
main_muc_module:fire_event('room-metadata-changed', { room = room; });
end
-- Pushes each dirty sender the sorted receiver ids currently translating it, as a directed
-- <translation-listeners> message, then clears the dirty set. Reads the incrementally-maintained
-- inverted map (listeners_by_sender), so the cost is proportional to what changed, not room size.
-- Kept separate from publish() so the jicofo-facing aggregate path stays untouched. Gated like the
-- aggregate: while disabled/vetoed every dirty sender resolves to an empty list. Change-detected per
-- sender against last_sent_listeners; an empty array is sent (then the tracker entry dropped) to
-- clear a sender whose last listener left.
local function publish_listeners(room)
if not room._audio_translation or not main_muc_module then
return;
end
local state = room._audio_translation;
local dirty = state.dirty_senders;
if not dirty or next(dirty) == nil then
return;
end
state.dirty_senders = {};
local allow_publish = main_muc_module:fire_event('jitsi-audio-translation-allow-publish', { room = room; }) ~= false;
local enabled = allow_publish and is_enabled(room);
for sender_id in pairs(dirty) do
local list = {};
local set = enabled and state.listeners_by_sender[sender_id];
if set then
for receiver_id in pairs(set) do
list[#list + 1] = receiver_id;
end
-- Sorted so set-iteration order never produces a spurious change.
table.sort(list);
end
-- util.json encodes an empty table as "{}"; force "[]" so clients always parse an array.
local encoded_listeners = (#list > 0) and json.encode(list) or '[]';
if encoded_listeners ~= state.last_sent_listeners[sender_id] then
local occupant = room:get_occupant_by_nick(room.jid..'/'..sender_id);
if occupant then
module:send(st.message({ from = module.host; to = occupant.jid; })
:tag('translation-listeners', { xmlns = AUDIO_TRANSLATION_NS; })
:text(encoded_listeners):up());
end
-- Only a delivered push is recorded; for a missing occupant the entry is dropped so a
-- rejoin with the same endpoint id gets a fresh push instead of being change-suppressed.
if occupant and #list > 0 then
state.last_sent_listeners[sender_id] = encoded_listeners;
else
state.last_sent_listeners[sender_id] = nil;
end
end
end
end
-- Schedules a debounced publish so a burst of updates produces a single metadata
-- broadcast.
local function schedule_publish(room)
local state = get_state(room);
if state.publish_scheduled then
return;
end
state.publish_scheduled = true;
timer.add_task(debounce_interval, function()
if room._audio_translation then
room._audio_translation.publish_scheduled = nil;
end
publish(room);
publish_listeners(room);
end);
end
-- Prunes a leaving occupant from its room's state (as receiver and sender). Shared by both MUC hooks.
local function prune_occupant(room, occupant)
if not room._audio_translation or is_healthcheck_room(room.jid) then
return;
end
local state = room._audio_translation;
local id = endpoint_id(occupant);
local changed = false;
local own = state.receivers[id];
if own then
state.receivers[id] = nil;
for sender_id in pairs(own) do
remove_listener(state, sender_id, id);
end
changed = true;
end
for _, subs in pairs(state.receivers) do
if subs[id] then
subs[id] = nil;
changed = true;
end
end
if state.listeners_by_sender[id] or state.last_sent_listeners[id] then
state.listeners_by_sender[id] = nil;
state.last_sent_listeners[id] = nil;
state.dirty_senders[id] = nil;
changed = true;
end
if changed then
schedule_publish(room);
end
end
function on_message(event)
local origin, stanza = event.origin, event.stanza;
if stanza.attr.type == 'error' then
return; -- Do not reply to error stanzas (avoids loops).
end
if not origin or not origin.jitsi_web_query_room then
return false;
end
local at = stanza:get_child(ELEMENT_NAME, AUDIO_TRANSLATION_NS);
if not at then
return true; -- Not for us.
end
local body = at:get_text();
if not body or body == '' then
return true;
end
local main_room = get_room_by_name_and_subdomain(
origin.jitsi_web_query_room, origin.jitsi_web_query_prefix or '');
if not main_room then
module:log('warn', 'No room found for %s/%s',
origin.jitsi_web_query_prefix, origin.jitsi_web_query_room);
origin.send(st.error_reply(stanza, 'cancel', 'item-not-found'));
return true;
end
local from = stanza.attr.from;
-- The receiver may be in a breakout (different MUC); ?room= is always the main room. Resolve the
-- room it is actually in (get_occupant_by_real_jid fans out into breakouts) and track it there.
local occupant, room = get_occupant_by_real_jid(main_room, from);
if not occupant then
module:log('warn', '%s is not an occupant of %s', from, main_room.jid);
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
end
-- Disabled for the room → no-op. Log it: the client normally hides the control when
-- translation is disabled, so a subscription arriving here is unexpected and worth
-- being able to discover.
if not is_enabled(room) then
module:log('warn', 'Ignoring audio-translation subscription from %s: disabled for room %s', from, room.jid);
return true;
end
if not subscribe_allowed(origin) then
module:log('warn', '%s is not allowed to subscribe to translations in %s', from, room.jid);
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
end
local delta = json.decode(body);
if type(delta) ~= 'table' then
module:log('warn', 'Invalid audio_translation payload from %s: %s', from, body);
origin.send(st.error_reply(stanza, 'modify', 'bad-request'));
return true;
end
local ok, err_type, err_condition = apply_delta(room, endpoint_id(occupant), delta);
if not ok then
origin.send(st.error_reply(stanza, err_type, err_condition));
return true;
end
schedule_publish(room);
return true;
end
module:hook('message/host', on_message);
function process_main_muc_loaded(main_muc, host_module)
main_muc_module = host_module;
module:log('info', 'Hooked audio_translation to muc events on %s', muc_component_host);
-- Prune a leaving occupant's subscriptions (shared with the breakout MUC hook below).
host_module:hook('muc-occupant-left', function(event)
prune_occupant(event.room, event.occupant);
end);
-- React when the room-level enable flag is toggled: republish so the aggregate
-- map is cleared when disabled (translation stops) or restored when re-enabled.
host_module:hook('jitsi-metadata-updated', function(event)
local room = event.room;
if event.key == ENABLED_METADATA_KEY and room._audio_translation
and not is_healthcheck_room(room.jid) then
-- Room-wide gating changes every sender's pushed list at once, with no per-sender delta,
-- so flag them all for the listeners pass (publish() recomputes the aggregate itself).
mark_all_senders_dirty(room._audio_translation);
schedule_publish(room);
end
end);
end
-- Hook occupant-left on the breakout MUC so breakout subscriptions are pruned on leave. Publishing
-- still flows through main_muc_module, whose room-metadata-changed handler broadcasts the given room.
function process_breakout_muc_loaded(breakout_muc, host_module)
module:log('info', 'Hooked audio_translation to breakout muc events on %s', breakout_rooms_component_host);
host_module:hook('muc-occupant-left', function(event)
prune_occupant(event.room, event.occupant);
end);
end
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);
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 breakout 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
-- Advertise the component as a disco identity on the main virtual host so
-- clients can discover it (handled by mod_features_identity), and gate writes to
-- the audioTranslation enable flag behind the 'live-translation' permission
-- (moderators by default) while sanitising the stored value.
process_host_module(main_virtual_host, function(host_module)
module:context(host_module.host):fire_event('jitsi-add-identity', {
name = ELEMENT_NAME; host = module.host;
});
host_module:hook('jitsi-metadata-allow-moderation', function(event)
if event.key ~= ENABLED_METADATA_KEY then
return; -- No opinion on other keys.
end
local session = event.session;
local is_moderator = event.actor and event.actor.role == 'moderator';
local features = session and session.jitsi_meet_context_features;
if not is_feature_allowed(ENABLE_PERMISSION, features, is_moderator) then
return false;
end
local data = event.data;
local enabled = type(data) == 'table' and (data.enabled == true or data.enabled == 'true');
return { enabled = enabled };
end);
end);

View File

@@ -0,0 +1,97 @@
-- 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;
module:depends("jitsi_session");
-- 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)
local resuming = false;
if (session.previd ~= nil) then
for _, session1 in pairs(sessions) do
if (session1.resumption_token == session.previd) then
self.username = session1.username;
resuming = true;
break;
end
end
else
self.username = message;
end
if not resuming then
session.auth_token = nil;
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);
module:hook("pre-resource-unbind", function (e)
local error, session = e.error, e.session;
prosody.events.fire_event('jitsi-pre-session-unbind', {
jid = session.full_jid,
session = session,
error = error
});
end, 11);

View File

@@ -0,0 +1,69 @@
-- 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');
-- TODO: shared_secret_prev is probably broken. get_sasl_handler() returns shared_secret
-- from its plain callback, so Prosody rejects any password other than the current secret.
-- provider.test_password() handles shared_secret_prev correctly but is not called in the
-- PLAIN SASL flow. To fix, get_sasl_handler() should use a plain_test profile instead.
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,272 @@
-- 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');
local measure_resume_room_mismatch = module:measure('resume_room_mismatch', 'counter');
-- The JWT-derived authorization state that c2s-session-updated refreshes from
-- the connection that resumes a hibernating session.
local TOKEN_CLAIM_FIELDS = {
'auth_token';
'jitsi_meet_context_user';
'jitsi_meet_context_group';
'jitsi_meet_context_features';
'jitsi_meet_context_room';
'jitsi_meet_room';
'jitsi_meet_str_tenant';
'jitsi_meet_domain';
'jitsi_meet_tenant_mismatch';
'jitsi_meet_auth_issuer';
};
-- define auth provider
local provider = {};
local host = module.host;
module:hook("pre-resource-unbind", function (e)
local error, session = e.error, e.session;
prosody.events.fire_event('jitsi-pre-session-unbind', {
jid = session.full_jid,
session = session,
error = error
});
end, 11);
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 first_stage_auth(session)
-- 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;
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 = res, error = error, reason = 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 { res = false, error = "not-allowed", reason = "user is banned" };
end
return { verify_result = res, custom_username = prosody.events.fire_event("pre-jitsi-authentication", session) };
end
function second_stage_auth(session)
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;
end
end
function provider.get_sasl_handler(session)
local function get_username_from_token(self, message)
local s1_result = first_stage_auth(session);
if s1_result.res == false then
return s1_result.res, s1_result.error, s1_result.reason;
end
if s1_result.custom_username then
self.username = s1_result.custom_username;
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 s2_result = second_stage_auth(session);
if s2_result and s2_result.res ~= nil then
return s2_result.res, s2_result.error, s2_result.reason;
end
measure_success(1);
session._jitsi_auth_done = true;
return s1_result.verify_result;
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);
-- Fired by Prosody's sessionmanager.update_session() during smacks (XEP-0198) session
-- resumption. The naming can be counter-intuitive:
-- event.session (session) – the OLD hibernating session being kept alive.
-- It holds all MUC state, auth state, and custom
-- fields accumulated during the call.
-- event.from_session (from_session) – the NEW incoming TCP session being absorbed and
-- then retired. It has JWT fields freshly extracted
-- from the reconnect URL by mod_jitsi_session, but
-- no MUC state (it never joined a room).
-- The copies below refresh the old session's JWT claims with the latest values from the
-- new connection (useful when the client reconnects with a rotated token). Fields that
-- represent runtime MUC state (e.g. jitsi_breakout_main_jid, set by
-- mod_muc_breakout_rooms when the session joins the main room) must NOT be copied here —
-- from_session will have nil for those, which would silently overwrite the valid value
-- on the old session.
module:hook_global('c2s-session-updated', function (event)
local session, from_session = event.session, event.from_session;
if not from_session.auth_token then
return;
end
-- The room claim is scoped to a conference on muc-room-pre-create and
-- muc-occupant-pre-join, which do not run again for a session that is past
-- its join, so the claims coming from the resuming connection are scoped
-- here instead: the ones verified on join are snapshotted before anything
-- below mutates the session, and are kept when the refreshed ones do not
-- cover the conference the session is in. The check belongs to whichever
-- module owns room verification (mod_token_verification, on the MUC
-- component); with nothing answering the event the claims are refreshed
-- as they come, the same way a join is not room-checked without it.
local reverify_rooms = session.auth_token ~= from_session.auth_token;
local verified_claims = {};
if reverify_rooms then
for _, field in ipairs(TOKEN_CLAIM_FIELDS) do
verified_claims[field] = session[field];
end
end
-- we care to handle sessions from other hosts (anonymous hosts)
-- Skip if from_session was already authenticated by its own token-auth module
-- (indicated by _jitsi_auth_done=true), to avoid a second module instance
-- on a different VirtualHost (e.g. hs256.localhost) incorrectly re-verifying
-- a token signed for the original host and then closing the session.
if module.host ~= event.from_session.host and not from_session._jitsi_auth_done then
-- Handle session updates (e.g., when a session is resumed on some anonymous host with a token we need to do all the checks here)
session.auth_token = event.from_session.auth_token;
local s1_result = first_stage_auth(session);
if s1_result.res == false then
event.session:close();
return;
end
local s2_result = second_stage_auth(session);
if s2_result and s2_result.res == false then
event.session:close();
return;
end
session._jitsi_auth_done = true;
end
if not session._jitsi_auth_done then
module:log('warn', 'Impossible case hit where session did not pass auth flow');
event.session:close();
return;
end
-- copy all the custom fields we set in the session
session.auth_token = from_session.auth_token;
session.jitsi_meet_context_user = from_session.jitsi_meet_context_user;
session.jitsi_meet_context_group = from_session.jitsi_meet_context_group;
session.jitsi_meet_context_features = from_session.jitsi_meet_context_features;
session.jitsi_meet_context_room = from_session.jitsi_meet_context_room;
session.jitsi_meet_room = from_session.jitsi_meet_room;
session.jitsi_meet_str_tenant = from_session.jitsi_meet_str_tenant;
session.jitsi_meet_domain = from_session.jitsi_meet_domain;
session.jitsi_meet_tenant_mismatch = from_session.jitsi_meet_tenant_mismatch;
if reverify_rooms then
local result = prosody.events.fire_event('jitsi-verify-session-rooms', { session = session; });
if result and result.res == false then
module:log('warn',
'Token presented on resume does not authorize room:%s err:%s reason:%s, keeping claims from join',
result.room, result.error, result.reason);
measure_resume_room_mismatch(1);
for _, field in ipairs(TOKEN_CLAIM_FIELDS) do
session[field] = verified_claims[field];
end
end
end
end, 1);

View File

@@ -0,0 +1,437 @@
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 is_focus = util.is_focus;
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 is_focus(room_occupant.nick) 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 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, make sure it is whitelisted and send the whitelist to every moderator
if room_occupant.nick == occupant.nick and room_occupant.role == 'moderator' then
local moderator_jid = internal_room_jid_match_rewrite(room_occupant.nick);
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
local whitelist = room.av_moderation[mediaType];
if whitelist then
if not get_index_in_table(whitelist, moderator_jid) then
whitelist:push(moderator_jid);
end
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,205 @@
-- Taken from prosody-modules (https://hg.prosody.im/prosody-modules/) at
-- revision 4317:456b9f608fcf, with mod_roster_command.patch applied.
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 don’t 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 doesn’t
-- 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 doesn’t
-- 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,51 @@
-- Records the timestamp (ms since epoch) when a MUC room first reaches two
-- occupants and exposes it as the muc#roominfo_created_timestamp field in
-- disco#info responses, so clients can display how long the conference has run.
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,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,102 @@
-- Allows a conference moderator to destroy a MUC room by sending a message
-- stanza containing an <end_conference/> child element to this component. The
-- module looks up the room from the sender's jitsi_web_query_room / prefix
-- session fields (set from the WebSocket URL query string by mod_jitsi_session),
-- verifies the sender is an occupant with moderator role, and calls room:destroy().
-- Must be loaded as a dedicated Prosody component (type "end_conference"); the
-- muc_component option must point to the MUC component host.
--
-- 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 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,80 @@
-- Filters Jibri recording/livestreaming IQ stanzas (http://jitsi.org/protocol/jibri)
-- on the main VirtualHost. start/stop actions are allowed only when the sender's JWT
-- token grants the required feature ('recording' for file mode, 'livestreaming' for
-- stream mode), or, absent token features, when the sender holds moderator role in the
-- room. status IQs are always passed through. Blocked stanzas receive an
-- auth/forbidden error reply. Rate-limited per IP and per room.
-- 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)));
if not room then
session.send(st.error_reply(stanza, 'cancel', 'item-not-found'));
return true;
end
local occupant = room:get_occupant_by_real_jid(stanza.attr.from);
local recording_mode_lower = jibri.attr.recording_mode and jibri.attr.recording_mode:lower();
local feature = recording_mode_lower == 'file' and 'recording' or 'livestreaming';
local is_allowed = is_feature_allowed(
feature,
session.jitsi_meet_context_features,
occupant ~= nil and occupant.role == 'moderator');
local action_lower = jibri.attr.action and jibri.attr.action:lower();
if action_lower == 'start' or action_lower == '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,326 @@
-- Filters outbound-call and transcription IQ stanzas (Rayo dial, urn:xmpp:rayo:1)
-- on the main VirtualHost. Allows a stanza through only when the sender's JWT token
-- grants the required feature ('outbound-call' or 'transcription'), or, absent token
-- features, when the sender holds owner affiliation in the room. Blocked stanzas
-- receive an auth/forbidden error reply. Optionally rate-limits outgoing calls per
-- session when max_number_outgoing_calls is configured.
-- 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();
-- Normalise CR and LF in values sourced outside of XML (e.g. JWT claims) to
-- spaces before inserting them into stanza attributes. Prosody's util.stanza
-- serializer does not escape raw control characters, so this keeps the
-- forwarded attribute values consistent with how expat already normalises
-- whitespace in XML-attribute-sourced values.
local function sanitize_header_value(v)
return (tostring(v):gsub("[\r\n]", " "));
end
-- 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 OUT_ROOM_PASS_ATTR_NAME = 'JvbRoomPassword';
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
-- Per-room throttle for transcription dials: at most 1 allowed per this interval (seconds).
-- Burst requests within the window after a successful dial are dropped so that
-- only one Jigasi transcriber is started per conference.
local TRANSCRIPTION_THROTTLE_INTERVAL = 10;
local transcription_room_throttles = {};
-- 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' or 'JvbRoomPassword' and extract the values
local roomName;
local roomPassword;
-- Remove any 'header' element if it already exists, so it cannot be spoofed by a client
dial:maptags(function(tag)
if tag.name == 'header' then
if tag.attr.name == OUT_ROOM_NAME_ATTR_NAME then
roomName = tag.attr.value;
elseif tag.attr.name == OUT_ROOM_PASS_ATTR_NAME then
roomPassword = tag.attr.value;
end
return nil;
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 feature = (dial.attr.to and dial.attr.to:lower()) == 'jitsi_meet_transcribe' and 'transcription' or 'outbound-call';
local error_message = nil;
if not room or room:get_occupant_jid(stanza.attr.from) == nil then
error_message = "not in room";
elseif roomName == nil then
error_message = OUT_ROOM_NAME_ATTR_NAME.." header missing";
elseif roomName ~= room_jid then
error_message = OUT_ROOM_NAME_ATTR_NAME.." header mismatch";
elseif (token ~= nil and not token_util:verify_room(session, room_real_jid)) then
error_message = "no token or token room mismatch";
elseif not is_feature_allowed(
feature,
session.jitsi_meet_context_features,
room:get_affiliation(stanza.attr.from) == 'owner') then
error_message = "feature not allowed";
end
if error_message then
module:log("warn", "Filtering stanza dial, %s, stanza:%s", error_message, 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
-- Per-room throttle for transcription: drop burst dials so only one
-- Jigasi transcriber is dispatched per conference per interval.
if feature == 'transcription' then
if not transcription_room_throttles[room_real_jid] then
transcription_room_throttles[room_real_jid] = new_throttle(1, TRANSCRIPTION_THROTTLE_INTERVAL);
end
if not transcription_room_throttles[room_real_jid]:poll(1) then
module:log("warn",
"Filtering transcription dial for room %s - per-room throttle hit (limit: 1 per %ds)",
room_real_jid, TRANSCRIPTION_THROTTLE_INTERVAL);
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 = sanitize_header_value(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 = sanitize_header_value(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
roomName = string.gsub(roomName, local_domain, main_domain);
end
dial:tag("header", {
xmlns = "urn:xmpp:rayo:1",
name = OUT_ROOM_NAME_ATTR_NAME,
value = sanitize_header_value(roomName)
});
dial:up();
if roomPassword then
dial:tag("header", {
xmlns = "urn:xmpp:rayo:1",
name = OUT_ROOM_PASS_ATTR_NAME,
value = sanitize_header_value(roomPassword)
});
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;
-- Clean up the per-room throttle entry when a room is destroyed so the
-- table does not grow without bound across many short-lived conferences.
host_module:hook('muc-room-destroyed', function(event)
transcription_room_throttles[event.room.jid] = nil;
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
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 or data.isRecordingRequested ~= 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 is_feature_allowed('transcription', session.jitsi_meet_context_features) then
local res = {};
res.isTranscribingEnabled = data.isTranscribingEnabled;
res.isRecordingRequested = data.isRecordingRequested;
return res;
elseif not session.jitsi_meet_context_features and occupant.role == 'moderator' then
return data;
else
return false;
end
end
return nil;
end);

View File

@@ -0,0 +1,61 @@
-- Filters MUC group-chat messages when the room operator has restricted chat.
--
-- When room.jitsiMetadata.permissions.groupChatRestricted is true, a sender
-- must have the 'send-groupchat' feature set to true in their JWT context
-- (session.jitsi_meet_context_features) to send a message. Any message that
-- fails this check is rejected with a <not-allowed/> error stanza.
--
-- Feature evaluation via is_feature_allowed:
-- send-groupchat = true in token features → allowed
-- send-groupchat = false in token features → blocked
-- feature key absent from token features → blocked
-- no token features at all (anonymous) → blocked
--
-- Messages without a <body> (polls, lobby notifications, etc.) are always
-- passed through regardless of the restriction flag.
--
-- Hooks:
-- message/bare — ordinary MUC group-chat messages
-- jitsi-visitor-groupchat-pre-route — visitor messages routed via the
-- visitors component
--
-- Load on the MUC component.
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 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,989 @@
--- 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 datetime = require 'util.datetime';
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 is_focus_jid = util.is_focus_jid;
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';
local DISPLAY_NAME_NS = 'http://jitsi.org/protocol/display-name';
-- 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
-- Forwards this vnode's local 'recording' metadata (isTranscribingEnabled, set by a visitor allowed via
-- the transcription JWT feature -- see mod_filter_iq_rayo's 'jitsi-metadata-allow-moderation' hook) up to
-- the main room, the same way send_transcriptions_update() forwards transcription-languages: as a
-- 'visitors' IQ to the main prosody's visitors component, which applies it to the real room metadata.
local function send_recording_metadata_update(room)
local recording_metadata = room.jitsiMetadata and room.jitsiMetadata.recording;
if not recording_metadata then
return;
end
local json_data, encode_error = json.encode(recording_metadata);
if not json_data then
module:log('error', 'Error encoding recording metadata for room:%s error:%s', room.jid, encode_error);
return;
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('recording-metadata', { xmlns = 'jitsi:visitors' }):text(json_data):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
-- Adds a stanza this node has just routed to its own occupants to the local
-- history. Prosody writes room history from broadcast_message, and routing
-- straight to the occupants bypasses it, so without this the history stays as the
-- main prosody left it when this node connected and a visitor joining later sees
-- nothing that was said since. Fire prosody's own events, so the entry is built,
-- stamped and trimmed exactly as it is in the main room and the modules hooked on
-- them apply here too.
local function add_to_local_history(room, stanza)
local event = { room = room; stanza = stanza; };
if not module:fire_event('muc-message-is-historic', event) then
module:log('debug', 'Not storing %s in %s history', stanza.attr.id, room.jid);
return;
end
module:fire_event('muc-add-history', event);
module:log('debug', 'Stored %s in %s history, %s entries now',
stanza.attr.id, room.jid, room._history and #room._history or 0);
end
-- Receives history messages from the main prosody and adds them to the local history
-- this happens before the first participant joins and that participant gets the history using the standard flow on join
local function history_message_handler(event)
local origin, stanza = event.origin, event.stanza;
local delay_elem = stanza:get_child('delay', 'urn:xmpp:delay');
if not delay_elem then
return;
end
-- now let's add it history, we do not use the event as we want to keep
-- the delay element
local room = get_room_from_jid(room_jid_match_rewrite(
jid.bare(stanza.attr.from):sub(1, -(main_domain:len() + 1))..local_domain));
if not room then
return;
end
if room:get_historylength() == 0 then
return;
end
local history = room._history;
if not history then history = {}; room._history = history; end
local history_stanza = st.clone(stanza);
history_stanza.attr.to = '';
local node, host, resource = jid.split(room_jid_match_rewrite(history_stanza.attr.from));
history_stanza.attr.from = jid.join(node, local_muc_domain, resource);
-- the from in the delay extension is still the main prosody jid, not used for now
local entry = { stanza = history_stanza, timestamp = datetime.parse(delay_elem.attr.stamp) };
table.insert(history, entry);
while #history > room:get_historylength() do table.remove(history, 1) end
return false;
end
module:hook('message/host', history_message_handler);
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);
-- add to message stanza display name for the visitor
-- remove existing nick to avoid forgery
local function process_display_name(stanza, occupant)
-- TODO drop nick extension used in messages in favor of new display-name extension
-- we want mobile client to update before dropping support for nick from messages
stanza:remove_children('nick', NICK_NS);
stanza:remove_children('display-name', DISPLAY_NAME_NS);
local nick_element = occupant:get_presence():get_child('nick', NICK_NS);
if nick_element then
stanza:add_child(nick_element);
stanza:tag('display-name', {
xmlns = DISPLAY_NAME_NS,
source = 'visitor'
}):text(nick_element:get_text()):up();
else
stanza:tag('nick', { xmlns = NICK_NS })
:text('anonymous'):up();
stanza:tag('display-name', {
xmlns = DISPLAY_NAME_NS,
source = 'visitor'
}):text('anonymous'):up();
end
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
process_display_name(stanza, occupant);
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
add_to_local_history(room, stanza);
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;
process_display_name(stanza, sender_occupant);
-- 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
if visitors_iq:get_child('disconnect') then
-- maybe room was already destroyed, mark as processed so we can response with ok for the iq
return true;
end
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.allowUnauthenticatedAccess then
room._data.allowUnauthenticatedAccess = node.attr.allowUnauthenticatedAccess == 'true';
end
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
-- Mirror the main room's metadata (e.g. asyncTranscription) onto this vnode's room, so visitor
-- clients connected here see the same metadata main-room clients do. Fired as 'room-metadata-changed'
-- so a room_metadata_component enabled on this host (muc_component pointing at this muc) rebroadcasts
-- it to visitor occupants, exactly like it does for the main room.
local metadataEl = node:get_child('metadata', 'jitsi:visitors');
if metadataEl then
local metadataText = metadataEl:get_text();
local metadata, metadata_error = json.decode(metadataText);
if metadata then
room.jitsiMetadata = metadata;
module:fire_event('room-metadata-changed', { room = room; });
else
module:log('error', 'Failed to decode metadata for room:%s error:%s', room.jid, metadata_error);
end
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
local pollsEl = node:get_child('polls');
if pollsEl then
local polls = json.decode(pollsEl:get_text());
-- let's find is there a new poll
module:fire_event('jitsi-polls-update', { room = room; polls = polls; });
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);
-- Fired locally (by the room_metadata_component enabled on this vnode) after a client successfully
-- writes a metadata key on this vnode's mirrored room. When it's the 'recording' key (isTranscribingEnabled,
-- set by a visitor via the transcription JWT feature), forward it up to the main room.
module:hook('jitsi-metadata-updated', function(event)
if event.key == 'recording' then
send_recording_metadata_update(event.room);
end
end);
-- 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
-- do not send anything to the main prosody if the destination is the main domain
if jid.host(stanza.attr.to) == main_domain then
return nil; -- returning nil filters the stanza
end
-- 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 is_focus_jid(stanza.attr.to) 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
module:hook('answer-poll', function(answerData)
local room = answerData.room;
local room_jid = room_jid_match_rewrite(jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain));
-- now send it to the main prosody
local data = {
answers = answerData.data.answers;
command = 'answer-poll';
pollId = answerData.pollId;
roomJid = room_jid,
senderId = answerData.voterId;
senderName = answerData.voterName;
type = 'polls';
};
local data_str, error = json.encode(data);
if not data_str then
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
end
local stanza = st.message({
from = module.host,
to = 'polls.'..main_domain
})
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet"; roomJid = room_jid; })
:text(data_str)
:up();
room:route_stanza(stanza);
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,91 @@
-- mod_jibri_session
--
-- Enriches jibri `start` IQ stanzas with recording metadata before they reach
-- jibri. It handles two passes:
--
-- 1. Client → jicofo (no `room` attr): reads the initiating user's identity
-- from the XMPP session and writes it to
-- app_data.file_recording_metadata.initiator {id, group}.
--
-- 2. Jicofo → jibri (`room` attr present): looks up the MUC room, reads its
-- meetingId, and writes it to
-- app_data.file_recording_metadata.conference_details.session_id.
--
-- Must be loaded on both the main virtual host and the jicofo virtual host.
local json = require 'cjson.safe';
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 and jibri.attr.action:lower() == 'start' then
local update_app_data = false;
local app_data = jibri.attr.app_data;
if app_data then
local err;
app_data, err = json.decode(app_data);
if err then
module:log('warn', 'Failed to decode jibri app_data from %s: %s', stanza.attr.from, err);
return;
end
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,72 @@
local st = require "util.stanza";
local ext_services = module:depends("external_services");
-- 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();
session.send(stanza);
end);

View File

@@ -0,0 +1,251 @@
-- Injects a <permissions> element into a participant's self-presence to
-- communicate which features they are allowed to use.
--
-- The <permissions> element is only injected when the server is the authoritative
-- source of that information. When a participant authenticated with a JWT that
-- already contains context.features, the client reads those features directly
-- from the token; no <permissions> element is sent. This means:
--
-- * Token user WITH context.features → no injection (client uses token features)
-- * Token user WITHOUT context.features, becomes moderator → inject default_permissions
-- * Anonymous/non-token user, becomes moderator → inject default_permissions
-- * Non-moderator of any kind → no injection
--
-- There is no mechanism for a client to explicitly request its permissions;
-- <permissions> is only ever pushed by the server, either on the initial
-- self-presence when a moderator joins (if the conditions above are met), or
-- when an affiliation change triggers force_permissions_update (e.g. another
-- moderator grants owner to this participant).
--
-- 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 is_focus = util.is_focus;
local presence_check_status = util.presence_check_status;
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
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;
-- Returns a copy of a feature table. Sessions must not share one table, because
-- a change to the features of one session must not change the features of
-- another session.
local function copy_features(features)
local res = {};
for k, v in pairs(features) do
res[k] = v;
end
return res;
end
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;
['live-translation'] = true;
['live-translation-subscribe'] = 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
-- An affiliation belongs to a bare JID, and a bare JID can have more than one
-- live session (multiple devices/tabs). Resolve them all, but only touch a
-- session that is actually an occupant of this room or one of its own breakout
-- rooms: an affiliation set on this room must not reach a session that happens
-- to share the same bare JID while sitting in an unrelated conference.
local bare_session = prosody.bare_sessions[jid];
if not bare_session then
return;
end
-- Any transition into owner is a grant. Any transition out of owner is a revoke.
-- This also covers the admin and outcast affiliations, which the client does not
-- use but which a raw stanza can set.
local granting = affiliation == 'owner' and previous_affiliation ~= 'owner';
local revoking = previous_affiliation == 'owner' and affiliation ~= 'owner';
for _, occupant_session in pairs(bare_session.sessions) do
if get_occupant_by_real_jid(room, occupant_session.full_jid) then
if granting then
-- Keep the features the session had before the grant, so that a later
-- revoke can put them back. The value false marks a session that had no
-- features, which is not the same as a session that had an empty table.
if occupant_session.pre_grant_jitsi_meet_context_features == nil then
occupant_session.pre_grant_jitsi_meet_context_features
= occupant_session.jitsi_meet_context_features or false;
end
occupant_session.jitsi_meet_context_features
= copy_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 revoking then
occupant_session.granted_jitsi_meet_context_user_id = nil;
occupant_session.granted_jitsi_meet_context_group_id = nil;
local pre_grant = occupant_session.pre_grant_jitsi_meet_context_features;
if pre_grant ~= nil then
-- The session received a grant before. Put back the features it had
-- then. A token session gets its token features again, and any other
-- session gets no features and falls back to a check of its current role.
occupant_session.jitsi_meet_context_features = pre_grant or nil;
occupant_session.pre_grant_jitsi_meet_context_features = nil;
elseif not occupant_session.auth_token then
occupant_session.jitsi_meet_context_features = nil;
end
end
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 is_focus(stanza.attr.from) 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 = copy_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,71 @@
-- Jitsi session information
-- Copyright (C) 2021-present 8x8, Inc.
--
-- Hooks the BOSH and WebSocket session-init events to extract Jitsi-specific
-- parameters from the URL query string and request headers, then stores them
-- on the session object for use by other modules:
--
-- session.previd -- ?previd= for smacks session resumption; lets
-- -- mod_auth_jitsi-anonymous re-use the same
-- -- anonymous JID after a reconnect
-- session.customusername -- ?customusername= pre-set JID, used with the
-- -- "pre-jitsi-authentication" event
-- session.jitsi_web_query_room -- ?room= Jitsi conference room name
-- session.jitsi_web_query_prefix -- ?prefix= optional tenant prefix (defaults to "")
-- session.auth_token -- ?token= or Authorization: Bearer <token> header;
-- -- populated without validation — downstream
-- -- modules (mod_auth_token, etc.) validate it
-- session.user_region -- value of the configurable proxy header
-- -- (region_header_name option, default x_proxy_region)
-- session.user_agent_header -- User-Agent request header
module:set_global();
local formdecode = require "util.http".formdecode;
local region_header_name = module:get_option_string('region_header_name', 'x_proxy_region');
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
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 "";
-- 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
session.user_region = request.headers[region_header_name];
-- 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, 1);
module:hook_global("websocket-session", init_session, 1);

View File

@@ -0,0 +1,32 @@
if not pcall(require, 'util.async') then
module:log('warn', 'util.async not available; mod_limits_exception requires Prosody 0.11+, not loading');
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:log('info', 'loaded; unlimited_jids=%s unlimited_size=%d', tostring(unlimited_jids), unlimited_stanza_size_limit);
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,124 @@
-- Registers a custom Prosody log sink that buffers recent log lines in memory
-- using a ringbuffer (or a fixed-line queue) and dumps the buffer to a file
-- when triggered by a configured OS signal or Prosody event.
-- Source: https://hg.prosody.im/prosody-modules/file/tip/mod_log_ringbuffer/mod_log_ringbuffer.lua
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,171 @@
-- Counts messages and polls per conference and reports the totals to Amplitude
-- when the main room is destroyed. Loaded on both the main MUC and breakout MUC
-- components; in both cases messages are accumulated on the main room object
-- (session.jitsi_web_query_room always refers to the main conference), so
-- breakout messages are included in the main room's totals. The Amplitude event
-- is only sent when the main room is destroyed (skipped for breakout rooms).
-- 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;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_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 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 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); -- prosody handles it at 0
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,179 @@
--- 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 allowner_issuers = module:get_option_set('allowner_issuers');
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
-- indicates whether all participants in the room will be moderators
room.jitsiMetadata.allownersEnabled = not is_moderated(room.jid);
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('room_has_host', function(event)
local room, session = event.room, event.session;
local moderated, _, tenant = is_moderated(room.jid);
if not moderated then
return nil;
end
if not tenant and allowner_issuers and not allowner_issuers:contains(session.jitsi_meet_auth_issuer) then
-- this will stop listeners execution and will return false, if we require a specific issuer for
-- a moderated room without a tenant and the issuer is not correct
return false;
end
end, 1); -- we want it executed before the one in wait_for_host module
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);
-- if it is a moderated room we skip it
if room and is_moderated(room.jid) then
return nil;
end
for i=1,#stanza.tags[1] do
local item = stanza.tags[1].tags[i];
local _aff = item.attr.affiliation;
-- 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
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,99 @@
-- 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 util = module:require 'util';
local starts_with = util.starts_with;
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")
local ban_check_error_count = module:measure("muc_auth_ban_check_error", "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
-- cached tokens are banned
if cache:get(token) then
return false;
end
if not starts_with(session.jitsi_web_query_prefix, 'vpaas-magic-cookie-') then
return true;
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 ~= nil and r['access'] == false then
module:log("info", "User is banned room:%s tenant:%s user_id:%s group:%s",
session.jitsi_web_query_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
else
ban_check_error_count();
module:log("warn", "Error code:%s contacting url:%s content:%s room:%s tenant:%s response:%s request:%s",
code, ACCESS_MANAGER_URL, session.jitsi_web_query_room, session.jitsi_web_query_prefix,
inspect(response), inspect(request), content);
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,813 @@
-- 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 jid_resource = require 'util.jid'.resource;
local jid_bare = require 'util.jid'.bare;
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 is_admin = util.is_admin;
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
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 = {};
-- Maps a full room JID to a bare connection jid for a participant that's changing rooms.
local cache = require 'util.cache';
local switching_room_cache = cache.new(1000);
-- 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 not is_admin(occupant.bare_jid) 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 not is_admin(occupant.bare_jid) 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 not is_admin(occupant.bare_jid) 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 = get_occupant_by_real_jid(room, 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
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);
-- Tell the client that the join failed. Without this reply the join presence is
-- dropped without an answer, and the client waits until its own timeout.
-- Prosody sends an error for restrict_room_creation, but that handler and this one
-- both use the default priority, so the order between the two is not defined.
if event.origin and event.stanza then
event.origin.send(st.error_reply(
event.stanza, 'cancel', 'item-not-found', 'Breakout room is invalid.'));
end
breakout_room:destroy(main_room_jid, 'Breakout room is invalid.');
return true;
end
end
function on_occupant_joined(event)
local occupant, room, session = event.occupant, event.room, event.origin;
if is_healthcheck_room(room.jid) then
return;
end
local main_room, main_room_jid = get_main_room(room.jid);
local isFocus = is_admin(occupant.bare_jid);
if main_room and main_room._data.breakout_rooms_active then
if not isFocus 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
-- clear any switching state for this occupant, we always store main room / resource
switching_room_cache:set(main_room_jid..'/'..jid_resource(occupant.nick), nil);
end
-- check if this is joining main room or breakout room
if main_room == room and not isFocus then
-- mark that this session was in the main room
session.jitsi_breakout_main_jid = main_room_jid;
end
end
function exist_occupants_in_room(room)
if not room then
return false;
end
for _, occupant in room:each_occupant() do
if not is_admin(occupant.bare_jid) 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;
});
local presence_status = stanza:get_child_text('status');
if presence_status == 'switch_room' then
switching_room_cache:set(main_room.jid..'/'..jid_resource(occupant.nick), jid_bare(occupant.jid));
end
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 not is_admin(event.occupant.bare_jid) 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
-- Checks for a conflict with a JID in the switching_room_cache. In case of a conflict sends an error and returns true (the join is not allowed).
-- in switching_room_cache is the same as the jid that is sending the stanza, if that is the case we can allow
-- the join to proceed by returning false. If there is no match we send an error and return true
-- which should halt the join.
-- @param jid - The jid to check, this is the jid requested to join breakout or main room
-- @param from_bare_jid - The real jid of the occupant trying to join
-- @param room - The room being joined
-- @param stanza - The presence stanza
-- @param origin - The session origin to send error if needed
function check_switching_state(jid, from_bare_jid, room, stanza, origin)
local switching_session_jid = switching_room_cache:get(jid);
if switching_session_jid and switching_session_jid ~= from_bare_jid then
local reply = st.error_reply(stanza, "cancel", "conflict", nil, room.jid):up();
origin.send(reply);
return true;
end
return false;
end
function check_for_existing_occupant_in_room(room, requested_resource, bare_jid, stanza, origin)
local dest_occupant = room:get_occupant_by_nick(room.jid..'/'..requested_resource);
if dest_occupant ~= nil and bare_jid ~= jid_bare(dest_occupant.bare_jid) then
origin.send(st.error_reply(stanza, 'cancel', 'conflict', nil, room.jid):up());
return true;
end
end
-- This is a request to join or change jid in main or breakout room. We need to check whether the requested jid does not
-- conflict with a jid which is currently in switching state or already in another room.
function on_occupant_pre_join_or_change(e)
local room, stanza, origin = e.room, e.stanza, e.origin;
local occupant = e.dest_occupant or e.occupant;
local requested_jid = stanza.attr.to;
local main_room = get_main_room(room.jid);
-- case where the room can be destroyed while someone is switching to it
if not main_room then
origin.send(st.error_reply(stanza, 'cancel', 'service-unavailable'));
return true;
end
if is_admin(occupant.bare_jid) then
return;
end
local main_room_requested_jid = main_room.jid..'/'..jid_resource(requested_jid);
local bare_jid = jid_bare(stanza.attr.from);
-- we always store main room jid with resource in switching cache
if check_switching_state(main_room_requested_jid, bare_jid, room, stanza, origin) then
return true;
end
if main_room == room then
-- this is the main room we need to check all its breakout rooms
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
if check_for_existing_occupant_in_room(
breakout_room, jid_resource(requested_jid), bare_jid, stanza, origin) then
return true;
end
end
end
else
if origin.jitsi_breakout_main_jid ~= main_room.jid then
-- this session did not come from the main room
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed'));
return true;
end
-- this is a breakout room let's check the main room
if check_for_existing_occupant_in_room(main_room, jid_resource(requested_jid), bare_jid, stanza, origin) then
return true;
end
-- now let's check the rest of the breakout rooms
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 then
if check_for_existing_occupant_in_room(
breakout_room, jid_resource(requested_jid), bare_jid, stanza, origin) then
return true;
end
end
end
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-occupant-pre-join', on_occupant_pre_join_or_change);
host_module:hook('muc-occupant-pre-change', on_occupant_pre_join_or_change);
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);
-- clears switching_room_cache on resource unbind (disconnect one way or another)
local function handle_pre_resource_unbind(event)
local participant_bare_jid = jid_bare(event.jid);
-- check switching_room_cache for anyone that is switching rooms but got disconnected so we can clean up the map
local keysToRemove = {};
for key, value in switching_room_cache:items() do
if value == participant_bare_jid then
table.insert(keysToRemove, key)
end
end
for _, key in ipairs(keysToRemove) do
switching_room_cache:set(key, nil);
end
end
module:hook_global('jitsi-pre-session-unbind', handle_pre_resource_unbind);
-- 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, 1); -- prosody handles it at 0
host_module:hook('muc-occupant-pre-leave', function(event)
local room, occupant, session, stanza = event.room, event.occupant, event.origin, event.stanza;
local presence_status = stanza:get_child_text('status');
if presence_status == 'switch_room' then
switching_room_cache:set(occupant.nick, jid_bare(occupant.jid));
end
end);
host_module:hook('muc-occupant-pre-join', on_occupant_pre_join_or_change);
host_module:hook('muc-occupant-pre-change', on_occupant_pre_join_or_change);
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,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
-- cjson encodes an empty Lua table as {} (object); force array literal.
local rc_json = #room_data == 0 and "[]" or json.encode(room_data);
census_resp = '{"room_census":' .. rc_json .. '}';
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,82 @@
-- Module to be enabled under the main MUC component.
-- Destroys a conference room after a configurable timeout (default 20 s,
-- overridable via services_empty_meeting_timeout) when the room is left
-- occupied only by backend services — Jibri recorders or transcribers —
-- with no regular participants remaining.
--
-- Identity is determined by JID prefix (util.lib.lua):
-- is_jibri(): JID starts with 'recorder@recorder.', 'jibria@recorder.',
-- or 'jibrib@recorder.'
-- is_transcriber(): JID starts with 'transcriber@recorder.',
-- 'transcribera@recorder.', or 'transcriberb@recorder.'
-- is_admin(): bare JID is in the Prosody admins list
--
-- Hook summary:
-- muc-occupant-joined (-100) — cancel the destroy timer when a regular user
-- (not jibri, not transcriber) joins.
-- muc-occupant-left (-100) — start the destroy timer when the leaver is a
-- regular user and only backend services remain;
-- skip entirely if the leaver is admin, jibri,
-- or transcriber, or if breakout_rooms_active.
-- muc-room-destroyed (1) — cancel and clear the timer reference.
local util = module:require 'util';
local is_admin = util.is_admin;
local is_transcriber = util.is_transcriber;
local is_jibri = util.is_jibri;
local EMPTY_TIMEOUT = module:get_option_number('services_empty_meeting_timeout', 20);
module:hook('muc-occupant-joined', function (event)
local room = event.room;
local occupant = event.occupant;
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
return;
end
-- clear the timer when someone joins
if not is_jibri(occupant.jid) and not is_transcriber(occupant.jid) and room.empty_destroy_timer then
room.empty_destroy_timer:stop();
room.empty_destroy_timer = nil;
end
end, -100); -- make sure we are last in the chain
module:hook('muc-occupant-left', function (event)
local occupant, room = event.occupant, event.room;
if is_admin(occupant.bare_jid) or is_jibri(occupant.jid) or is_transcriber(occupant.jid)
or room._data.breakout_rooms_active then
return;
end
for _, o in room:each_occupant() do
if not is_jibri(o.jid) and not is_transcriber(o.jid)
and not is_admin(o.bare_jid) then
-- not empty
return;
end
end
-- seems the room only has jibri and transcriber, add a timeout to destroy the room
if room.empty_destroy_timer then
room.empty_destroy_timer:stop();
end
room.empty_destroy_timer = module:add_timer(EMPTY_TIMEOUT, function()
if room.destroying then return end
room:destroy(nil, 'Empty room with recording and/or transcribing.');
module:log('info',
'the conference terminated %s as being empty for %s seconds with recording/transcribing enabled. By %s',
room.jid, EMPTY_TIMEOUT, room.empty_destroy_timer);
end)
module:log('info', 'Added room destroy timer %s for %s', room.empty_destroy_timer, room.jid);
end, -100); -- the last thing to execute
module:hook('muc-room-destroyed', function (event)
local room = event.room;
if room.empty_destroy_timer then
room.empty_destroy_timer:stop();
room.empty_destroy_timer = nil;
end
end, 1); -- prosody handles it at 0

View File

@@ -0,0 +1,168 @@
-- Manages display names and identity visibility in MUC rooms.
--
-- Three behaviours:
--
-- 1. hideDisplayNameForGuests (outbound filter on stanzas/out, priority -100):
-- When room._data.hideDisplayNameForGuests is true, strips identity elements
-- (<nick>, <email>, <stats-id>, and name/email inside <identity>) from
-- presence stanzas before they are delivered to non-moderator occupants.
-- Moderator recipients and self-presence are always passed through unchanged.
-- Uses session.jitsi_web_query_room / jitsi_web_query_prefix to locate the room.
--
-- 2. name-readonly (inbound filter on stanzas/in, priority -100):
-- When session.jitsi_meet_context_features['name-readonly'] is true, enforces
-- the display name from the JWT token on every outgoing presence from that session:
-- * Token has context.user.name → replaces the <nick> element with the token name
-- * Token has no name → removes the <nick> element entirely
-- This prevents clients from changing their display name when the token marks
-- it as read-only.
--
-- 3. Message display-name handling (MUC hooks):
-- muc-broadcast-message (priority -1): strips <nick> and <display-name> from
-- every message before it is broadcast to occupants, so message payloads
-- never carry identity information to live receivers.
-- muc-add-history: injects <display-name source="token|guest"> into the
-- history copy of each message (cloned from the broadcast stanza), sourced
-- from session.jitsi_meet_context_user.name (token users) or the <nick>
-- element (guests). Late-joining clients receive this annotated history.
--
-- Load on the 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 is_focus = util.is_focus;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local NICK_NS = 'http://jabber.org/protocol/nick';
local DISPLAY_NAME_NS = 'http://jitsi.org/protocol/display-name';
local sessions = prosody.full_sessions;
local ignore_jwt_name = module:get_option_boolean('ignore_jwt_name', false);
-- 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 is_focus(stanza.attr.from) 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 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
shouldFilter = occupant.role ~= 'moderator' and not joining_moderator_participants[occupant.bare_jid];
end
end
if shouldFilter then
return filter_identity_from_presence(stanza);
else
return stanza;
end
end
-- When a participant has a name-readonly feature enabled, we need to ensure that the
-- nick element in the presence stanza is set to the user's name, if it exists.
-- If the user does not have a name, we remove the nick element.
-- This is to ensure that the name is not changed by the user, as it is read-only.
function filter_stanza_in(stanza, session)
if not session or not session.jitsi_meet_context_features
or session.jitsi_meet_context_features['name-readonly'] ~= true then
-- if the name-readonly feature is not set, there is nothing we need to do
return stanza;
end
if stanza.name ~= 'presence' or stanza.attr.type == 'error'
or stanza.attr.type == 'unavailable' or is_focus(stanza.attr.from or '') then
return stanza;
end
-- if the user does not have a name in token and name is readonly, remove any nick element
if not session.jitsi_meet_context_user or not session.jitsi_meet_context_user.name then
stanza:remove_children('nick', NICK_NS);
return stanza;
end
local nick_element = stanza:get_child('nick', NICK_NS);
if nick_element and nick_element:get_text() ~= session.jitsi_meet_context_user.name then
stanza:remove_children('nick', NICK_NS);
stanza:tag('nick', { xmlns = NICK_NS }):text(session.jitsi_meet_context_user.name):up();
end
return stanza;
end
-- 'muc-add-history' is called in 'muc-broadcast-message' with priority 0
-- we want to clean up message after we have processed it for history, but do it for all messages
-- not only those that are added to history (as someone can forge a message and add 'no-store')
module:hook('muc-broadcast-message', function(event)
-- any message that is about to be delivered, clear up nick and display-name elements
event.stanza:remove_children('nick', NICK_NS);
event.stanza:remove_children('display-name', DISPLAY_NAME_NS);
end, -1);
module:hook('muc-add-history', function(event)
local room, stanza = event.room, event.stanza;
local name;
local source;
local occupant = room:get_occupant_by_nick(stanza.attr.from);
if occupant then
local session = sessions[occupant.jid];
if session and session.jitsi_meet_context_user then
if ignore_jwt_name then
name = occupant:get_presence():get_child_text('nick', NICK_NS);
else
name = session.jitsi_meet_context_user.name;
end
source = 'token';
else
name = occupant:get_presence():get_child_text('nick', NICK_NS);
source = 'guest';
end
else
-- if no occupant found it is a message from visitor let's check display-name source
local display_name_element = stanza:get_child('display-name', DISPLAY_NAME_NS);
if display_name_element and display_name_element.attr.source == 'visitor' then
name = display_name_element:get_text();
source = display_name_element.attr.source;
end
end
if name then
-- clone the stanza, so only history has the display_name extension
event.stanza = st.clone(stanza);
event.stanza:tag('display-name', {
xmlns = DISPLAY_NAME_NS,
source = source
}):text(name):up();
end
end);
function filter_session(session)
filters.add_filter(session, 'stanzas/out', filter_stanza_out, -100);
filters.add_filter(session, 'stanzas/in', filter_stanza_in, -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 or session.type == 's2sout' 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,141 @@
-- Global HTTP module that exposes a POST /end-meeting endpoint for terminating
-- MUC rooms via an authenticated API call. Intended for internal system use
-- (e.g. by a backend service), not for end-user clients.
--
-- Authentication uses a SEPARATE ASAP key pair from the one used for login
-- tokens (mod_auth_token). The key server URL is read from
-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
-- are not accepted.
--
-- Request format:
-- POST /end-meeting?conference=<room-jid>[&silent-reconnect=true]
-- Authorization: Bearer <system-token>
--
-- Responses:
-- 200 Room destroyed.
-- 400 Missing or invalid query parameters.
-- 401 Missing, malformed, or unverifiable token.
-- 404 Room not found.
--
-- When silent-reconnect=true the room is destroyed with an alternate-venue JID
-- so clients silently reconnect rather than showing a "meeting ended" screen.
--
-- Copyright (C) 2023-present 8x8, Inc.
module:set_global();
local util = module:require "util";
local async_handler_wrapper = util.async_handler_wrapper;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
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 parse = require "util.http".formdecode;
-- 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 muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
local muc_domain = muc_domain_prefix..'.'..muc_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 silent_reconnect = params['silent-reconnect'];
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
if silent_reconnect == 'true' then
module:log('info', 'Setting silent_reconnect on room %s', room.jid);
room:destroy(internal_room_jid_match_rewrite(room.jid), 'The meeting has been terminated silently')
else
module:log("info", "Destroy room jid %s", room.jid)
room:destroy(nil, "The meeting has been terminated")
end
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,195 @@
-- 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 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 find_occupant_nick_by_user_id(room, user_id)
for _, occ in room:each_occupant() do
for full_jid in pairs(occ.sessions) do
local session = prosody.full_sessions[full_jid];
if session and session.jitsi_meet_context_user
and session.jitsi_meet_context_user.id == user_id then
return occ.nick;
end
end
end
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 id = session.jitsi_meet_context_user.id;
local first_device_occ_nick = find_occupant_nick_by_user_id(room, 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
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)
-- 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,33 @@
-- Hides all MUC rooms from disco#items queries on the MUC component, so that
-- room enumeration by external clients is prevented. Rooms are still joinable
-- by full JID; they simply do not appear in the public room list.
-- This module is enabled under the MUC component.
-- 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,218 @@
-- HTTP module that exposes a POST /invite-jigasi endpoint for inviting a Jigasi
-- SIP gateway instance to dial out to a phone number and connect it to a
-- conference. Intended for internal system use (e.g. by a backend service),
-- not for end-user clients.
--
-- Authentication uses a SEPARATE ASAP key pair from the one used for login
-- tokens (mod_auth_token). The key server URL is read from
-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
-- are not accepted.
--
-- Request format:
-- POST /invite-jigasi
-- Content-Type: application/json
-- Authorization: Bearer <system-token>
-- Body: { "conference": "<room-jid>", "phoneNo": "<dial-target>" }
--
-- The module selects a Jigasi instance from the brewery room
-- (muc_jigasi_brewery_jid, default: jigasibrewery@internal.auth.<base>):
-- 1. Iterates brewery occupants, skips focus.
-- 2. Filters for occupants with supports_sip=true in their colibri stats.
-- 3. Among those, picks the one with the lowest stress_level.
-- 4. Sends a Rayo <dial> IQ to that occupant (impersonating focus) with
-- phoneNo as the dial target and conference as JvbRoomName.
--
-- Responses:
-- 200 Jigasi invited successfully.
-- 400 Missing or invalid parameters / wrong Content-Type.
-- 401 Missing, malformed, or unverifiable token.
-- 404 Brewery room not found, or no SIP-capable Jigasi available.
--
-- Copyright (C) 2023-present 8x8, Inc.
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 is_focus = util.is_focus;
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 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_occupant;
for occupant_jid, occupant in jigasi_brewery_room:each_occupant() do
if not is_focus(occupant_jid) then
local occ = occupant:get_presence();
local stats_child = occ:get_child("stats", "http://jitsi.org/protocol/colibri")
if not stats_child then
module:log("warn", "Jigasi occupant %s has no stats element, skipping", occupant_jid);
else
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_occupant = occupant;
least_stressed_value = stress_level
end
end
end
end
end
end
end
if not least_stressed_jigasi_occupant then
module:log("error", "Cannot invite jigasi from room %s", jigasi_brewery_room.jid)
return 404, 'Jigasi not found'
end
module:log("debug", "Least stressed jigasi selected jid %s value %s", least_stressed_jigasi_occupant.jid, least_stressed_value)
-- invite Jigasi to join the conference
local stanza_id = hashes.sha256(random.bytes(8), true);
local invite_jigasi_stanza = st.iq({ xmlns = "jabber:client", type = "set", to = least_stressed_jigasi_occupant.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,194 @@
-- HTTP module that exposes a PUT /kick-participant endpoint for removing a
-- specific participant from a MUC room via an authenticated API call.
-- Intended for internal system use (e.g. by a backend service), not for
-- end-user clients.
--
-- Authentication uses a SEPARATE ASAP key pair from the one used for login
-- tokens (mod_auth_token). The key server URL is read from
-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
-- are not accepted.
--
-- Request format:
-- PUT /kick-participant?room=<room-name>[&prefix=<subdomain>]
-- Content-Type: application/json
-- Authorization: Bearer <system-token>
-- Body: { "participantId": "<occupant-nick>" }
-- OR: { "number": "<phone-prefix>" } (matches SIP Jigasi by display name)
-- Exactly one of participantId or number must be provided.
--
-- Responses:
-- 200 Participant kicked.
-- 400 Missing or invalid parameters / wrong Content-Type.
-- 403 Missing, malformed, or unverifiable token.
-- 404 Room not found, or no matching participant found.
--
-- Copyright (C) 2023-present 8x8, Inc.
local util = module:require "util";
local async_handler_wrapper = util.async_handler_wrapper;
local get_room_from_jid = util.get_room_from_jid;
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 { status_code = error_code; }
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,109 @@
-- Enforces a per-room message count cap (muc_limit_messages_count). Once the cap
-- is reached the triggering message is rejected with <not-allowed/>, a broadcast
-- is sent to the room, and all further messages are rejected. Messages from
-- sessions without room context (no jitsi_web_query_room) are passed through.
-- Body-less non-groupchat messages and answer-poll json-messages are exempt from
-- counting. With muc_limit_messages_check_token = true an authenticated sender
-- permanently lifts the cap for that room.
-- 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
-- No room context: cannot apply limits, pass through.
-- (Visitor messages from s2sin are also passed through — limits enforced at visitor node.)
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 for %s/%s',
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return;
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
-- Priority -1 ensures modules at the default priority (0) — notably
-- filter_messages — run first. Messages they block (return true) never reach
-- this handler and do not consume cap slots.
module:hook('message/full', on_message, -1); -- private messages
module:hook('message/bare', on_message, -1); -- room messages
module:hook_global('config-reloaded', load_config);

View File

@@ -0,0 +1,754 @@
-- mod_muc_lobby_rooms
--
-- Implements the Jitsi "lobby" feature: a waiting room that holds participants
-- until a moderator admits or rejects them.
--
-- How it works:
-- When a moderator sets a room to members-only (muc#roomconfig_membersonly),
-- a paired persistent lobby MUC room is created on the lobby component.
-- Subsequent join attempts by non-members are rejected with a 407 error that
-- includes the lobby room JID, redirecting the client to wait there.
-- Moderators (owners in the main room) are also owners in the lobby room and
-- can admit participants by inviting them or deny them by kicking.
--
-- Join-gate logic (muc-occupant-pre-join, priority -4):
-- 1. Healthcheck rooms, focus nick (/focus suffix) -> pass through.
-- 2. Whitelisted JID/domain (muc_lobby_whitelist) or correct password -> grant
-- member affiliation and pass through.
-- 3. Missing display name -> 406 not-acceptable with <displayname-required>.
-- 4. No affiliation -> 407 registration-required with <lobbyroom> JID.
-- 5. Existing member/owner affiliation -> pass through to the MUC layer.
--
-- Presence/message filtering:
-- Stanzas from the lobby component are filtered so lobby participants can only
-- see and message moderators, not each other.
--
-- Notifications:
-- Broadcasts JSON groupchat messages (type "lobby-notify") to the main room
-- when lobby is enabled/disabled (LOBBY-ENABLED) or when a participant is
-- admitted (LOBBY-ACCESS-GRANTED) or denied (LOBBY-ACCESS-DENIED).
--
-- Backend API:
-- Handles global events "create-lobby-room" and "destroy-lobby-room" so that
-- other Prosody modules (e.g. jicofo bridge) can manage the lobby
-- programmatically without a moderator submitting a config form.
--
-- Required configuration (on the main VirtualHost):
-- lobby_muc = "lobby.jitmeet.example.com"
-- main_muc = "conference.jitmeet.example.com"
--
-- 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 is_focus = util.is_focus;
local is_focus_jid = util.is_focus_jid;
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 get_room_from_jid = util.get_room_from_jid;
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_jid, 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
local room = get_room_from_jid(room_jid);
if not room then
module:log('error', 'Room not found for %s', room_jid)
return;
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_jid = room.jid;
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);
module:log('info', 'Lobby room destroyed %s', lobby_room_obj.jid)
if room.jitsiMetadata then
room.jitsiMetadata.lobbyEnabled = false;
module:context(main_muc_component_config):fire_event('room-metadata-changed', { room = room; });
end
end
room._data.lobbyroom = nil;
room._data.lobby_extra_reason = nil;
room._data.lobby_skip_display_name_check = 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 - need to kick", room_nick, existing_occupant.bare_jid, item.attr.jid);
success, errtype, err = self:set_role(actor, room_nick, nil, "This nickname is reserved");
if not success then
origin.send(st.error_reply(stanza, errtype, err));
return true;
end 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 then
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if room and room._data.lobby_disabled then
-- we cannot remove the child from the stanza so let's just change the type
local lobby_identity = reply:get_child_with_attr('identity', nil, 'type', LOBBY_IDENTITY_TYPE);
if lobby_identity then
lobby_identity.attr.type = 'DISABLED_'..LOBBY_IDENTITY_TYPE;
end
end
if check_display_name_required and 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_jid then
module:log('error', 'No main room(%s) for %s!', room.jid, jid);
return 'none';
end
-- moderators in main room are moderators here
local main_room = get_room_from_jid(room.main_room_jid);
if not main_room then
module:log('error', 'Main room not found for %s!', room.main_room_jid);
return 'none';
end
local role = main_room.get_affiliation(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_jid, 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;
if is_focus_jid(actor) then
return;
end
local members_only = event.fields['muc#roomconfig_membersonly'] and true or nil;
if members_only then
-- if lobby disabled just ignore and return
if room._data.lobby_disabled then
module:log('warn', 'Lobby is disabled for room %s, cannot enable members only', room.jid);
return;
end
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);
-- let's set it in the metadata and fire the event
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
room.jitsiMetadata.lobbyEnabled = true;
host_module:fire_event('room-metadata-changed', { room = room; });
end
elseif room._data.lobbyroom then
destroy_lobby_room(room, internal_room_jid_match_rewrite(room.jid), nil); --
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, 1); -- prosody handles it at 0
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 is_focus(occupant.nick) 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 valid_affiliations[affiliation or 'none'] >= valid_affiliations.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.jid, 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;
-- set in metadata without firing room-metadata-changed,
-- as this is a backend call and the caller will take care of that
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
room.jitsiMetadata.lobbyEnabled = true;
-- 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 room: %s", room.jid);
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 room:%s", room.jid);
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,339 @@
-- Assigns a unique UUID meeting ID to each MUC room at creation time and
-- advertises it in the room's config IQ response so that all participants share
-- a common conference identifier. Also enforces a jicofo lock: non-focus clients
-- that try to join before jicofo (identified by nick suffix '/focus') are queued
-- and admitted only after jicofo joins and fires the jicofo-unlock-room event.
-- This module is enabled under the MUC component.
local jid = require 'util.jid';
local json = require 'cjson.safe';
local st = require "util.stanza";
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 is_focus = main_util.is_focus;
local is_focus_jid = main_util.is_focus_jid;
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 util = module:require 'util';
local is_transcriber = util.is_transcriber;
local QUEUE_MAX_SIZE = 500;
module:depends("jitsi_permissions");
module:depends("jitsi_session");
-- 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).
-- Block non-focus participants from creating health-check rooms. This hook
-- runs at priority 100, before mod_token_verification (priority 99), so that
-- the error is service-unavailable (not the token verification not-allowed that
-- would fire because the room does not yet exist at pre-create time).
module:hook('muc-room-pre-create', function (event)
local stanza = event.stanza;
if is_healthcheck_room(jid.bare(stanza.attr.to)) then
if not is_focus(stanza.attr.to) then
module:log('info', 'Blocking non-focus from creating health-check room');
event.origin.send(st.error_reply(stanza, 'cancel', 'service-unavailable'));
return true;
end
end
end, 100);
-- 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
if is_health_room then
-- Only jicofo (focus) may join health-check rooms.
if not is_focus(occupant.nick) then
module:log('info', 'Blocking non-focus participant from health-check room: %s', room.jid);
event.origin.send(st.error_reply(stanza, 'cancel', 'service-unavailable'));
return true;
end
return;
end
-- we skip processing only if jicofo_lock is set to false
if room._data.jicofo_lock == false then
return;
end
if is_focus(occupant.nick) 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);
-- Prevents some participant from being modified in any way through a
-- muc#admin set IQ (kick, ban, role/affiliation change, ...). This runs at a high
-- priority (10) so it precedes prosody's own admin-query handler (default priority
-- -2) and any other module hooks (e.g. allowners at 5). There is no legitimate
-- reason for a client/moderator to target certain participants, so any such item is rejected.
local function filter_admin_set(event)
local origin, stanza = event.origin, event.stanza;
local room = get_room_from_jid(jid.bare(stanza.attr.to));
if not room then
return;
end
local query = stanza.tags[1];
if not query then
return;
end
for item in query:childtags('item') do
-- An item may carry a nick (role changes) and/or a jid (affiliation
-- changes). Prosody's own handler applies a role change by nick and an
-- affiliation change by jid, so both must be checked independently: a
-- decoy nick must not mask a jid that targets focus (and vice versa).
if item.attr.nick and is_focus(room.jid .. '/' .. item.attr.nick) then
module:log('warn', 'Blocking attempt to modify %s in room %s', item.attr.nick, room.jid);
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed'));
return true;
end
if item.attr.jid and is_focus_jid(item.attr.jid) then
module:log('warn', 'Blocking attempt to modify %s in room %s', item.attr.jid, room.jid);
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed'));
return true;
end
end
end
module:hook('iq-set/bare/http://jabber.org/protocol/muc#admin:query', filter_admin_set, 10);
module:hook('iq-set/host/http://jabber.org/protocol/muc#admin:query', filter_admin_set, 10);
-- 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
local function filterTranscriptionResult(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 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
-- skip logs for messages coming from s2s
if event.origin.type == 'c2s' then
module:log("error", "Occupant sending msg %s was not found in room %s", occupant_jid, room.jid);
end
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
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.type == 'transcription-result' then
if not is_transcriber(stanza.attr.from) then
module:log('warn', 'Filtering transcription-result message from non-transcriber: %s', stanza.attr.from);
-- Do not fire the event, and do not forward the message
return true
end
if msg_obj.is_interim then
-- Do not fire the event, but forward the message
return
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
if not transcription.transcript[1] then
module:log('warn', 'Empty transcript array in transcription-result from %s', stanza.attr.from);
return true;
end
local text_message = transcription.transcript[1].text;
--do not send empty messages
if text_message == '' then
return;
end
if not transcription.participant then
module:log('warn', 'Missing participant field in transcription-result from %s', stanza.attr.from);
return true;
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
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
module:hook('message/bare', filterTranscriptionResult);
module:hook('jitsi-visitor-groupchat-pre-route', filterTranscriptionResult);

View File

@@ -0,0 +1,460 @@
-- Server side handling of chat message moderation (XEP-0425) and message
-- correction (XEP-0308) for Jitsi MUC rooms.
--
-- Both operations used to be synchronised client to client over json messages,
-- and late joiners were brought up to date by every participant replaying the
-- current state to them on join. Handling both here keeps the room history
-- itself correct, so a joining client needs no follow up at all.
--
-- 1. Moderation (XEP-0425). An occupant sends:
--
-- <message type='groupchat'>
-- <apply-to xmlns='urn:xmpp:fasten:0' id='ORIG'>
-- <moderated xmlns='urn:xmpp:message-moderate:1'>
-- <retract xmlns='urn:xmpp:message-retract:1'/>
-- <reason>spam</reason>
-- </moderated>
-- </apply-to>
-- </message>
--
-- The request is honoured only when the sending occupant's MUC role is
-- 'moderator'. The room history entry for ORIG is replaced with a tombstone
-- (body stripped, <moderated by='...'><retracted/></moderated> added) so late
-- joiners never receive the moderated text at all, and the moderation is
-- broadcast from the room JID. The occupant's own request is not relayed, the
-- room emits its own stanza and takes 'by' from the sender's occupant JID.
-- A message that has aged out of the history window is still moderated for
-- everyone in the room, there is just no entry left to turn into a tombstone.
-- 'jitsi-message-moderated' is fired alongside the broadcast so mod_visitors
-- can forward it to the visitor nodes, which the room's own broadcast does
-- not reach.
--
-- 2. Correction (XEP-0308). An occupant sends a new body carrying:
--
-- <replace xmlns='urn:xmpp:message-correct:0' id='ORIG'/>
--
-- The request is honoured only when ORIG was authored by the same occupant
-- and has not been moderated; anything else is dropped and reaches nobody.
-- An honoured correction is relayed untouched: it carries the new body and
-- asks to be stored, so the room archives it and replays it after the message
-- it corrects, and a client joining later applies it the same way one in the
-- room does. Nothing here rewrites the history entry.
--
-- 3. Retraction (XEP-0424). The author retracts their own message with a
-- top level:
--
-- <retract xmlns='urn:xmpp:message-retract:1' id='ORIG'/>
--
-- The request is honoured only when ORIG was authored by the same occupant.
-- The history entry is removed outright rather than tombstoned, so a joining
-- client never receives the message at all, and the retraction is not stored
-- either since there is no longer anything for it to pair with. The retraction
-- stanza itself is what reaches the occupants already in the room.
--
-- The moderation the room broadcasts and the retraction are kept out of history:
-- the first is already reflected in the tombstone, and the second removed the
-- entry it referred to.
--
-- Rooms advertise 'muc#roominfo_messageModerationEnabled' so a client can tell
-- whether the server is here to apply these operations, and offer the moderate
-- and edit actions only when it is.
--
-- Needs to be activated under the muc component where message moderation and
-- editing should be handled. Without it the client hides both actions, as the
-- room stops advertising them.
--
-- Load it on a visitor prosody's muc component too. There it keeps the local
-- history in step for the operations that are not replayed on their own, and
-- advertises the room info field so visitors see the same actions. It does not
-- check anything that reached it from the main prosody, which is where the room
-- the occupants act in lives and where every request is checked. It does check
-- the author of a visitor's own request, since that has not been past the main
-- prosody yet at the point this node routes it.
--
-- Component "conference.meet.jitsi" "muc"
-- modules_enabled = { "muc_message_moderation" }
--
-- Copyright (C) 2026-present 8x8, Inc.
local st = require 'util.stanza';
local id = require 'util.id';
local jid = require 'util.jid';
local datetime = require 'util.datetime';
local ends_with = module:require 'util'.ends_with;
-- only the visitor prosody has a main_domain setting
local main_domain = module:get_option_string('main_domain');
local local_domain = module:get_option_string('muc_mapper_domain_base');
local FASTEN_NS = 'urn:xmpp:fasten:0';
local MODERATE_NS = 'urn:xmpp:message-moderate:1';
local RETRACT_NS = 'urn:xmpp:message-retract:1';
local CORRECT_NS = 'urn:xmpp:message-correct:0';
local HINTS_NS = 'urn:xmpp:hints';
-- Returns the id of the message the stanza asks to moderate and the optional
-- reason, or nil when the stanza is not a moderation request.
function parse_moderation_request(stanza)
local apply_to = stanza:get_child('apply-to', FASTEN_NS);
if not apply_to or not apply_to.attr.id then
return nil;
end
local moderated = apply_to:get_child('moderated', MODERATE_NS);
if not moderated or not moderated:get_child('retract', RETRACT_NS) then
return nil;
end
-- 'by' is only meaningful on a stanza a room has sent, where the server
-- stamped it. On an occupant's own request it is whatever they typed.
return apply_to.attr.id, moderated:get_child_text('reason'), moderated.attr.by;
end
-- Returns the id of the message the stanza corrects and the new body, or nil
-- when the stanza is not a correction.
function parse_correction(stanza)
local replace = stanza:get_child('replace', CORRECT_NS);
if not replace or not replace.attr.id then
return nil;
end
local body = stanza:get_child_text('body');
if not body then
return nil;
end
return replace.attr.id, body;
end
-- Returns the id of the message the stanza retracts, or nil when the stanza is
-- not a retraction. A moderation request also carries a <retract/>, but nested
-- inside <apply-to><moderated/>, so it is not matched here.
function parse_retraction(stanza)
local retract = stanza:get_child('retract', RETRACT_NS);
if not retract or not retract.attr.id then
return nil;
end
return retract.attr.id;
end
-- Finds the room history entry for a message id. Searches newest first, as
-- edits and moderation overwhelmingly target recent messages.
function find_history_entry(room, message_id)
local history = room._history;
if not history or not message_id then
return nil;
end
for i = #history, 1, -1 do
local entry = history[i];
if entry.stanza and entry.stanza.attr.id == message_id then
return entry, i;
end
end
return nil;
end
function is_entry_moderated(entry)
return entry.stanza:get_child('moderated', MODERATE_NS) ~= nil;
end
-- Replaces a history entry with a XEP-0425 tombstone. The body is dropped so
-- the moderated text is never sent to anyone joining later.
function tombstone_entry(entry, by, reason, stamp)
local tombstone = st.clone(entry.stanza);
tombstone:remove_children('body');
tombstone:remove_children('replace', CORRECT_NS);
-- never nest two moderation markers if the same message is moderated twice
tombstone:remove_children('moderated', MODERATE_NS);
local moderated = st.stanza('moderated', { xmlns = MODERATE_NS, by = by })
:tag('retracted', { xmlns = RETRACT_NS, stamp = stamp }):up();
if reason and reason ~= '' then
moderated:tag('reason'):text(reason):up();
end
tombstone:add_child(moderated);
entry.stanza = tombstone;
return entry;
end
-- The stanza the room sends to announce a moderation. It carries no body, so a
-- client that does not understand it simply ignores it.
function build_moderation_broadcast(room, target_id, by, reason)
local moderated = st.stanza('moderated', { xmlns = MODERATE_NS, by = by })
:tag('retract', { xmlns = RETRACT_NS }):up();
if reason and reason ~= '' then
moderated:tag('reason'):text(reason):up();
end
local broadcast = st.message({ from = room.jid, type = 'groupchat', id = id.medium() })
:tag('apply-to', { xmlns = FASTEN_NS, id = target_id });
broadcast:add_child(moderated);
broadcast:up();
broadcast:add_child(st.stanza('no-store', { xmlns = HINTS_NS }));
return broadcast;
end
-- 'occupant' comes from the hook rather than from stanza.attr.from, which still
-- holds the sender's real jid at this point; the room rewrites it to the
-- occupant nick only after the event has run.
function handle_moderation(origin, room, stanza, occupant, target_id, reason)
if not occupant or occupant.role ~= 'moderator' then
module:log('warn', 'Rejected moderation of %s from non-moderator %s', target_id, stanza.attr.from);
origin.send(st.error_reply(stanza, 'auth', 'forbidden', 'Only moderators can moderate messages'));
return true;
end
local entry = find_history_entry(room, target_id);
-- The message may have aged out of the room's history window. The moderator's
-- decision still stands for everyone in the room, there is simply no entry
-- left to turn into a tombstone.
if entry then
tombstone_entry(entry, occupant.nick, reason, datetime.datetime());
end
local broadcast = build_moderation_broadcast(room, target_id, occupant.nick, reason);
room:broadcast_message(broadcast);
-- broadcast_message only reaches occupants of this room. Visitor nodes are
-- separate prosodies, and the hook that forwards occupant messages to them
-- never sees this stanza because the room sent it, so announce it for
-- mod_visitors to fan out.
module:fire_event('jitsi-message-moderated', { room = room; stanza = broadcast; });
-- the room has emitted its own stanza, don't relay the occupant's request
return true;
end
function handle_correction(origin, room, stanza, occupant, target_id, body)
local entry = find_history_entry(room, target_id);
-- With no entry the message has aged out of the room's history window, so
-- there is nothing to rewrite and no author to check it against. Relay it and
-- let the receiving clients apply it to their own copy.
if entry then
if not occupant or entry.stanza.attr.from ~= occupant.nick then
module:log('warn', 'Rejected correction of %s from %s, authored by %s',
target_id, occupant and occupant.nick, entry.stanza.attr.from);
origin.send(st.error_reply(stanza, 'auth', 'forbidden', 'Only the author can correct a message'));
return true;
end
if is_entry_moderated(entry) then
module:log('debug', 'Rejected correction of moderated message %s', target_id);
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Message has been moderated'));
return true;
end
end
module:log('debug', 'Relaying correction of %s from %s', target_id, occupant and occupant.nick);
-- fall through. The correction carries the new body and asks to be stored, so
-- the room archives it and replays it to anyone joining later, in order after
-- the message it corrects. Nothing here has to rewrite the entry.
end
-- The author retracting their own message. The history entry is dropped, so the
-- message is simply absent for anyone joining later.
function handle_retraction(origin, room, stanza, occupant, target_id)
local entry, index = find_history_entry(room, target_id);
-- With no entry the message has aged out of the room's history window, so
-- there is nothing to remove and no author to check it against.
if entry then
if not occupant or entry.stanza.attr.from ~= occupant.nick then
module:log('warn', 'Rejected retraction of %s from %s, authored by %s',
target_id, occupant and occupant.nick, entry.stanza.attr.from);
origin.send(st.error_reply(stanza, 'auth', 'forbidden', 'Only the author can retract a message'));
return true;
end
table.remove(room._history, index);
end
-- The message is gone from history, so a stored retraction has nothing left to
-- pair with. The client asks for it to be stored, override that.
stanza:remove_children('store', HINTS_NS);
stanza:add_child(st.stanza('no-store', { xmlns = HINTS_NS }));
-- fall through, the retraction the client sent is what reaches the occupants
-- in the room
end
function handle_groupchat(event)
local origin, room, stanza, occupant = event.origin, event.room, event.stanza, event.occupant;
if stanza.attr.type ~= 'groupchat' then
return;
end
local moderate_id, reason = parse_moderation_request(stanza);
if moderate_id then
return handle_moderation(origin, room, stanza, occupant, moderate_id, reason);
end
local correct_id, body = parse_correction(stanza);
if correct_id then
return handle_correction(origin, room, stanza, occupant, correct_id, body);
end
local retract_id = parse_retraction(stanza);
if retract_id then
return handle_retraction(origin, room, stanza, occupant, retract_id);
end
end
-- Whether this node may apply an operation to its own history entry. A stanza
-- from an occupant of this node is a visitor's own request that the main prosody
-- has not seen yet, so the author is checked against the entry. Anything else
-- arrived from the main prosody, which has already checked it.
function may_apply_locally(entry, occupant)
if not occupant or jid.host(occupant.bare_jid) ~= local_domain then
return true;
end
return entry.stanza.attr.from == occupant.nick;
end
-- On a visitor node this module only keeps the local history in step. The room
-- that occupants act in lives on the main prosody, which has already checked the
-- role or the authorship, so nothing is checked again here and no reply is sent.
-- Runs above mod_fmuc's hook and falls through, so fmuc still routes the stanza
-- to the local occupants exactly as before.
function handle_main_groupchat(event)
local room, stanza, occupant = event.room, event.stanza, event.occupant;
if stanza.attr.type ~= 'groupchat' then
return;
end
-- Editing and retraction already reach the occupants of this node through the
-- normal forwarding, so only the local history entry needs bringing in line.
local retract_id = parse_retraction(stanza);
if retract_id then
local entry, index = find_history_entry(room, retract_id);
if entry and may_apply_locally(entry, occupant) then
module:log('debug', 'Removing retracted %s from %s history', retract_id, room.jid);
table.remove(room._history, index);
end
return;
end
-- A moderation is sent by the main room itself, so it never arrives as an
-- occupant's stanza and nothing else routes it to the occupants here.
if occupant then
return;
end
local from_host = jid.host(stanza.attr.from or '');
if not from_host or not ends_with(from_host, main_domain) then
if parse_moderation_request(stanza) then
module:log('debug', 'Ignoring moderation from %s, not under %s', stanza.attr.from, main_domain);
end
return;
end
local moderate_id, reason, by = parse_moderation_request(stanza);
if moderate_id then
local entry = find_history_entry(room, moderate_id);
if entry then
tombstone_entry(entry, by, reason, datetime.datetime());
end
-- Announce it from this room rather than relaying what arrived. The
-- forwarded stanza was sent by the main room, which is not an occupant
-- here, so the from it ends up with depends on how the hop rewrites it.
-- Building it locally means only the addressing had to survive the hop.
module:log('debug', 'Announcing moderation of %s on %s', moderate_id, room.jid);
local broadcast = build_moderation_broadcast(room, moderate_id, by, reason);
-- Route it to the occupants of this node only. The main prosody's
-- participants are occupants here as well and it has already told them;
-- this node has no route to them and would only log a routing error.
for _, o in room:each_occupant() do
if jid.host(o.bare_jid) == local_domain then
room:route_to_occupant(o, broadcast);
end
end
return true;
end
end
-- Belt and braces next to the no-store hint: moderation broadcasts, corrections
-- and retractions are already reflected in the entry for the original message, or
-- in its removal, so they must not be appended to history as messages of their own.
function skip_history(event)
local stanza = event.stanza;
if stanza:get_child('apply-to', FASTEN_NS)
or stanza:get_child('retract', RETRACT_NS) then
return true;
end
end
-- Advertised so clients only offer moderating and editing where the server is
-- here to apply them and keep the room history in step.
local message_moderation_field = {
name = 'muc#roominfo_messageModerationEnabled';
type = 'boolean';
label = 'Whether message moderation and editing are handled by the server.';
value = 1;
};
function add_room_info(event)
table.insert(event.form, message_moderation_field);
end
module:hook('muc-add-history', skip_history, 10);
module:hook('muc-disco#info', add_room_info);
module:hook('muc-config-form', add_room_info);
if main_domain then
-- A visitor node only mirrors what the main prosody applied. The checking
-- handlers are deliberately not registered here: they compare an author
-- against the local occupant nick, and on a visitor node the history entry
-- came from the main prosody's snapshot, so the comparison would refuse a
-- visitor's own edit rather than apply it.
--
-- mod_fmuc handles visitor chat at priority 55 and stops there, so run above
-- it and fall through once the local history has been brought in line.
module:hook('muc-occupant-groupchat', handle_main_groupchat, 60);
else
module:hook('muc-occupant-groupchat', handle_groupchat, 10);
end
module:log('info', 'Loaded MUC message moderation and correction for %s', module.host);

View File

@@ -0,0 +1,219 @@
-- HTTP module that exposes GET and PUT endpoints at /room-info for querying
-- and validating MUC room password information.
--
-- Endpoints:
-- GET /room-info?room=<name>[&prefix=<subdomain>]
-- Returns a JSON object: { conference, passcodeProtected, lobbyEnabled }
-- where conference is the full room JID, passcodeProtected is true when the
-- room has a password set, and lobbyEnabled is true when a lobby room is
-- attached.
--
-- PUT /room-info?room=<name>[&prefix=<subdomain>]
-- Body: application/json { passcode: "<value>" }
-- Returns: { valid: true|false } indicating whether the submitted passcode
-- matches the room password.
--
-- Authentication:
-- Both endpoints require a valid ASAP Bearer token (RS256) in the
-- Authorization header. Token verification is controlled by the
-- enable_password_token_verification option (default: true).
--
-- Error responses:
-- 400 Missing or invalid parameters (no room param, wrong Content-Type,
-- empty body, missing passcode key).
-- 403 Missing or invalid Bearer token.
-- 404 Room not found.
--
-- The HTTP routes are registered on the MUC base domain (muc_mapper_domain_base,
-- default "localhost") because the HTTP server runs on port 5280 on that host.
-- The room query parameter is decoded using the muc_mapper_domain_prefix option
-- (default "conference") to build the full internal MUC JID.
--
-- Copyright (C) 2023-present 8x8, Inc.
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,62 @@
-- Allows clients from whitelisted domains (muc_password_whitelist) to join
-- password-protected MUC rooms without supplying the room password. The module
-- injects the room's current password into the join stanza on behalf of
-- whitelisted clients, so the standard password check in Prosody's MUC module
-- succeeds transparently.
-- This module is enabled under the MUC component.
--- 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,273 @@
-- mod_muc_rate_limit - Per-room join and leave rate limiting for MUC components.
--
-- Prevents presence storms in large meetings by throttling the rate at which
-- occupants can join or leave a room. When the configured rate is exceeded,
-- excess presence events are held in a per-room FIFO queue and replayed at the
-- allowed rate by a 1-second recurring timer.
--
-- Join limiting (muc-occupant-pre-join, priority 9):
-- Each room maintains a token-bucket throttle (muc_rate_joins tokens/s,
-- default 3). When the bucket is empty the join event is stopped and the
-- origin/stanza pair is pushed onto a bounded queue (max 1000 entries).
-- A timer is started (if not already running) that pops up to
-- muc_rate_joins entries per second and replays them through
-- room:handle_normal_presence. A delayed_join_skip flag on the replayed
-- stanza prevents it from being re-queued on the second pass. Any join
-- that arrives while the timer is active is also queued (even if the
-- throttle would allow it) to preserve FIFO ordering.
--
-- Leave limiting (muc-occupant-pre-leave):
-- Same token-bucket logic with muc_rate_leaves tokens/s (default 5).
-- Queued leavers have their role set to nil and are saved immediately
-- (removing them from the room roster) so that in-progress joins are not
-- blocked; however publicise_occupant_status (the unavailable presence
-- broadcast to peers) is deferred until the timer processes the event.
--
-- Room destruction (muc-room-destroyed, priority 1):
-- Both queues are marked empty so the in-flight timer skips further
-- processing cleanly without touching a destroyed room.
--
-- Configuration (set on the MUC component):
-- muc_rate_joins (number, default 3) — max join events per second per room
-- muc_rate_leaves (number, default 5) — max leave events per second per room
--
-- 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 ()
if room.destroying then
-- if room was destroyed in the mean time, ignore
return;
end
local status, result = pcall(
timer_process_queue_elements,
join_rate_per_conference,
room.join_rate_presence_queue,
function(ev)
-- if the connection was closed while waiting in the queue, ignore
if ev.origin.conn then
-- 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
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, 1); -- prosody handles it at 0
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,127 @@
-- Validates the resourcepart of client JIDs when joining a MUC.
-- Copyright (C) 2024-present 8x8, Inc.
--
-- Hooks into muc-occupant-pre-join and rejects joins whose MUC resource is invalid.
--
-- A valid resource consists only of alphanumerics and underscores and must
-- NOT start with an underscore: ^[a-zA-Z0-9][a-zA-Z0-9_]*$
--
-- When "anonymous_strict" mode is enabled, users on domains whose
-- authentication provider is considered anonymous are additionally required
-- to use a MUC resource equal to the first 8 characters of their real JID
-- username (the UUID prefix assigned by the server).
-- The check is dynamic: the auth provider is read from prosody.hosts at
-- join time, so no static domain list is needed.
--
-- The module also hooks muc-occupant-pre-change and rejects MUC nick (resource)
-- changes: an occupant's resource is its stable identity for the session and
-- must never change. Legitimate display-name changes travel in the presence
-- <nick/> child, not the MUC resource, so they are unaffected.
--
-- Example configuration (place under the MUC component):
--
-- Component "conference.meet.jitsi" "muc"
-- modules_enabled = { "muc_resource_validate" }
-- -- Enable strict anonymous-user check
-- anonymous_strict = true
-- -- Optional: extend the list of auth methods treated as anonymous
-- anonymous_auth_methods = { "anonymous", "jitsi-anonymous", "token", "custom-anon" }
local jid_split = require "util.jid".split;
local st = require "util.stanza";
local util = module:require "util";
local is_healthcheck_room = util.is_healthcheck_room;
-- Valid resource: starts with alphanumeric, followed by zero-or-more alphanumerics / underscores.
local VALID_RESOURCE_PATTERN = "^[a-zA-Z0-9][a-zA-Z0-9_]*$";
local anonymous_strict;
local anonymous_auth_methods;
local function load_config()
anonymous_strict = module:get_option_boolean("anonymous_strict", false);
anonymous_auth_methods = module:get_option_set(
"anonymous_auth_methods", { "anonymous", "jitsi-anonymous", "token" });
end
load_config();
-- Returns true when the domain's authentication provider is considered anonymous.
-- Prosody's usermanager sets host.users.name to the value of the
-- "authentication" config option.
local function is_anonymous_domain(domain)
local host_obj = prosody.hosts[domain];
if not host_obj or not host_obj.users then
return false;
end
return anonymous_auth_methods:contains(host_obj.users.name);
end
-- Returns true (halts event processing) when the join should be rejected.
local function check_resource(event)
local room, origin, stanza = event.room, event.origin, event.stanza;
if is_healthcheck_room(room.jid) then
return;
end
local user, domain = jid_split(stanza.attr.from);
local _, _, muc_resource = jid_split(stanza.attr.to);
if user == nil then
return;
end
-- 1. Validate the resource part of the MUC JID (the occupant nickname).
if not muc_resource or not muc_resource:match(VALID_RESOURCE_PATTERN) then
module:log("warn", "Rejecting join with invalid MUC resource from %s (muc_resource: %s)",
stanza.attr.from, tostring(muc_resource));
origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Invalid resource"));
return true;
end
-- 2. Anonymous strict mode: MUC resource must equal the 8-char UUID prefix of the real JID username.
if anonymous_strict and is_anonymous_domain(domain) then
local uuid_prefix = user:sub(1, 8);
if muc_resource ~= uuid_prefix then
module:log("warn", "Rejecting anonymous user: MUC resource %s does not match UUID prefix %s (from %s)",
muc_resource, uuid_prefix, stanza.attr.from);
origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Invalid anonymous user ID"));
return true;
end
end
end
-- Returns true (halts event processing) when a MUC nick (resource) change should
-- be rejected. In Jitsi the occupant resource is the stable per-session identity
-- and must never change.
local function reject_nick_change(event)
local room, origin, stanza = event.room, event.origin, event.stanza;
if is_healthcheck_room(room.jid) then
return;
end
local orig_occupant = event.orig_occupant;
-- No original occupant means there is no rename to police here.
if not orig_occupant then
return;
end
local _, _, current_resource = jid_split(orig_occupant.nick);
local _, _, new_resource = jid_split(stanza.attr.to);
if new_resource ~= current_resource then
module:log("warn", "Rejecting MUC nick change from %s (%s -> %s)",
stanza.attr.from, tostring(current_resource), tostring(new_resource));
origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Nick changes are not allowed"));
return true;
end
end
module:log("info", "module loaded (anonymous_strict=%s)", tostring(anonymous_strict));
module:hook("muc-occupant-pre-join", check_resource, 11);
module:hook("muc-occupant-pre-change", reject_nick_change, 11);
module:hook_global("config-reloaded", load_config);

View File

@@ -0,0 +1,245 @@
-- Prosody IM
-- Copyright (C) 2021-present 8x8, Inc.
--
-- mod_muc_size exposes HTTP endpoints that let external services query the
-- occupancy and participant details of MUC rooms managed by the associated
-- conference component. It provides three routes:
--
-- GET /room-size?room=<name>&domain=<base>[&subdomain=<sub>][&token=<jwt>]
-- Returns {"participants": N} where N is the non-focus occupant count.
-- Returns 404 when the room does not exist.
--
-- GET /room?room=<name>&domain=<base>[&subdomain=<sub>][&token=<jwt>]
-- Returns a JSON array of occupant objects {jid, email, display_name},
-- excluding the hidden Jitsi focus participant.
-- Returns 404 when the room does not exist.
--
-- GET /sessions
-- Returns the total number of active Prosody client sessions as a plain
-- integer string.
--
-- The room address is built as <room>@<muc_domain_prefix>.<domain>, optionally
-- prefixed with [<subdomain>] for multi-tenant deployments.
-- Token-based JWT verification is optional and controlled by the
-- enable_roomsize_token_verification module option (default: false).
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
-- muc_domain_prefix is needed by the build_room_address fallback below.
local muc_domain_prefix
= module:get_option_string("muc_mapper_domain_prefix", "conference");
-- Load shared utility library. If it fails (e.g. a transitive dependency is
-- missing in the current environment) log the error and fall back to inline
-- implementations so the HTTP routes are always registered.
local async_handler_wrapper, get_room_from_jid, build_room_address, is_focus;
local ok_util, util_or_err = pcall(function() return module:require "util" end);
if ok_util then
local util = util_or_err;
async_handler_wrapper = util.async_handler_wrapper;
get_room_from_jid = util.get_room_from_jid;
build_room_address = util.build_room_address;
is_focus = util.is_focus;
else
module:log("warn", "mod_muc_size: util.lib.lua unavailable (%s); using inline fallbacks",
tostring(util_or_err));
async_handler_wrapper = function(_, handler) return handler(_) end;
get_room_from_jid = function(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) end
end
end;
build_room_address = function(room_name, domain_name, subdomain)
local addr = jid.join(room_name, muc_domain_prefix.."."..domain_name);
if subdomain and subdomain ~= "" then
addr = "["..subdomain.."]"..addr;
end
return addr;
end;
is_focus = function(nick)
return string.sub(nick, -string.len("/focus")) == "/focus";
end;
end
local tostring = tostring;
-- Simple query-string parser: "room=foo&domain=bar" → { room="foo", domain="bar" }.
-- Avoids a dependency on the optional third-party net.url LuaRocks module.
local function parse(q)
local t = {};
for k, v in (q or ""):gmatch("([^=&]+)=([^&]*)") do
t[k] = v;
end
return t;
end
-- option to enable/disable room API token verifications
local enableTokenVerification
= module:get_option_boolean("enable_roomsize_token_verification", false);
local ok, token_util_mod = pcall(function() return module:require "token/util" end);
local token_util = ok and token_util_mod and token_util_mod.new(module) or nil;
-- no token configuration but required
if token_util == nil and enableTokenVerification then
log("error", "no token configuration but it is required");
return;
end
--- 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 = build_room_address(room_name, domain_name, subdomain);
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 = build_room_address(room_name, domain_name, subdomain);
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 not is_focus(occupant.nick) 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;
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;
};
});

View File

@@ -0,0 +1,117 @@
-- Loaded on the main MUC component.
-- Holds unauthenticated guests in a persistent lobby until an authenticated host joins.
-- A "host" is any session with a JWT auth_token or with PLAIN credentials whose JID
-- domain matches muc_mapper_domain_base. Prosody admins (e.g. jicofo) bypass the check
-- and are not counted as hosts.
-- When the first guest arrives before any host, a persistent lobby room is created via
-- mod_persistent_lobby + mod_muc_lobby_rooms, and guests are held there. When an
-- authenticated host joins the main room the lobby is destroyed and guests are admitted.
-- Once a host has been detected the room.has_host flag is cached on the room object for
-- the lifetime of the room, so subsequent joins skip the host re-check.
-- By default, authenticated users are promoted to owner affiliation on join. Set
-- wait_for_host_disable_auto_owners = true to disable automatic owner promotion.
-- Depends on: mod_persistent_lobby (must be loaded on the main VirtualHost).
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 internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
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
-- the main virtual host that requires tokens
if jid.host(o.bare_jid) == muc_domain_base
-- or this is anonymous that upgraded by passing token which we validated
or prosody.full_sessions[o.jid].auth_token then
room.has_host = true;
end
end
if not room.has_host then
if module:fire_event('room_has_host', { room = room; occupant = occupant; session = session; }) then
-- the host is here, let's drop the lobby
room:set_members_only(false);
-- this is set by create-persistent-lobby-room, so let's clear it
room:set_persistent(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 = internal_room_jid_match_rewrite(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);
module:hook('room_has_host', function(event)
local room, occupant, session = event.room, event.occupant, event.session;
if session.auth_token
or (session.username and jid.host(occupant.bare_jid) == muc_domain_base) then
return true;
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,205 @@
-- 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 get_room_from_jid = util.get_room_from_jid;
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)
or main_room.destroying 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;
if not lobby_room.main_room_jid then
return;
end
local main_room = get_room_from_jid(lobby_room.main_room_jid);
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,560 @@
-- 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 it = require 'util.iterators';
local json = require 'cjson.safe';
local array = require 'util.array';
local st = require("util.stanza");
local jid = require "util.jid";
local util = module:require("util");
local NS_NICK = 'http://jabber.org/protocol/nick';
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local get_room_from_jid = util.get_room_from_jid;
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
local is_healthcheck_room = util.is_healthcheck_room;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local table_compare = util.table_compare;
local POLLS_LIMIT = 128;
local POLL_PAYLOAD_LIMIT = 1024;
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 muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
-- 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 function validate_polls(data)
if type(data) ~= 'table' then
return false;
end
if data.type ~= 'polls' or type(data.pollId) ~= 'string' then
return false;
end
if data.command ~= 'new-poll' and data.command ~= 'answer-poll' then
return false;
end
if type(data.answers) ~= 'table' or #data.answers == 0 then
return false;
end
if data.command == "new-poll" then
if type(data.question) ~= 'string' then
return false;
end
for _, answer in ipairs(data.answers) do
if type(answer) ~= "table" or type(answer.name) ~= "string" then
return false;
end
end
return true;
elseif data.command == "answer-poll" then
for _, answer in ipairs(data.answers) do
if type(answer) ~= "boolean" then
return false;
end
end
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 same and for name
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_text('nick', NS_NICK);
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
local function send_polls_message(room, data_str, to)
local stanza = st.message({
from = module.host,
to = to
})
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet" })
:text(data_str)
:up();
room:route_stanza(stanza);
end
local function send_polls_message_to_all(room, data_str)
for _, room_occupant in room:each_occupant() do
-- in case of visitor node send only to visitors
if not is_visitor_prosody or room_occupant.role == 'visitor' then
send_polls_message(room, data_str, room_occupant.jid);
end
end
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('message/host', function(event)
local session, stanza = event.origin, event.stanza;
-- we are interested in all messages without a body that are not groupchat
if stanza.attr.type == 'groupchat' or stanza:get_child('body') then
return;
end
local json_message = stanza:get_child('json-message', 'http://jitsi.org/jitmeet')
or stanza:get_child('json-message');
if not json_message then
return;
end
local room;
local occupant;
if session.type == 's2sin' then
if not json_message.attr.roomJid then
module:log('warn', 'No room jid found in %s', stanza);
return;
end
room = get_room_from_jid(room_jid_match_rewrite(json_message.attr.roomJid));
else
local main_room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
local occupant_jid = stanza.attr.from;
if not main_room then
module:log('warn', 'No main room found for %s %s', session.jitsi_web_query_room, session.jitsi_web_query_prefix);
return;
end
local found_room;
occupant, found_room = get_occupant_by_real_jid(main_room, occupant_jid);
room = found_room or main_room;
if not occupant then
module:log('error', 'Occupant sending poll msg %s was not found in room %s', occupant_jid, room.jid)
return;
end
end
if not room then
module:log('warn', 'No room found for %s %s', session.jitsi_web_query_room, session.jitsi_web_query_prefix);
return;
end
local json_message_text = json_message:get_text();
if string.len(json_message_text) >= 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
local data, error = json.decode(json_message_text);
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 not data or (data.command ~= "new-poll" and data.command ~= "answer-poll") then
return;
end
if not validate_polls(data) then
module:log('error', 'Invalid poll data. Sender: %s (%s)', stanza.attr.from, json_message_text);
return true;
end
local occupant_details;
if session.type ~= 's2sin' then
local occupant_jid = stanza.attr.from;
occupant_details = get_occupant_details(occupant)
if not occupant_details then
module:log("error", "Cannot retrieve poll creator or voter id and name for %s from %s",
occupant.jid, room.jid)
return
end
else
-- this is a message from a visitor prosody, we will trust it
occupant_details = { occupant_id = data.senderId; occupant_name = data.senderName; };
end
if data.command == "new-poll" then
if is_visitor_prosody then
module:log("error", "Poll cannot be created on visitor node.");
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll cannot be created by visitor node'));
return true;
end
local poll_creator = occupant_details;
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);
session.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', session.jitsi_meet_context_features) then
session.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, a in ipairs(data.answers) do
table.insert(answers, { name = a.name });
table.insert(compact_answers, { key = i, name = a.name});
end
local poll = {
pollId = data.pollId,
senderId = poll_creator.occupant_id,
senderName = 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
}
}
-- now send message to all participants
-- Only relay the sanitized answers (name only) so no unexpected
-- fields from the sender's payload are forwarded to participants.
data.answers = answers;
data.senderId = poll_creator.occupant_id;
data.type = 'polls';
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
send_polls_message_to_all(room, json_msg_str);
module:context(jid.host(room.jid)):fire_event('poll-created', pollData);
elseif data.command == "answer-poll" then
local poll = room.polls.by_id[data.pollId];
if poll == nil then
module:log("warn", "answering inexistent poll %s", data.pollId);
return;
end
local voter = occupant_details;
-- Capture the voter's current vote state so we can detect changes.
local previous_answers = {};
for idx, answer in ipairs(poll.answers) do
previous_answers[idx] = false;
if answer.voters then
for _, v in ipairs(answer.voters) do
if v.id == voter.occupant_id then
previous_answers[idx] = true;
break;
end
end
end
end
-- Skip processing if the vote is identical to the previous one.
local vote_changed = false;
for idx, vote_flag in ipairs(data.answers) do
if previous_answers[idx] ~= vote_flag then
vote_changed = true;
break;
end
end
if not vote_changed then
return true;
end
-- Remove any previous votes by this voter before recording new ones,
-- preventing duplicate votes for the same option.
for _, answer in ipairs(poll.answers) do
if answer.voters then
local filtered = {};
for _, v in ipairs(answer.voters) do
if v.id ~= voter.occupant_id then
table.insert(filtered, v);
end
end
answer.voters = filtered;
end
end
local answers = {};
for vote_option_idx, vote_flag in ipairs(data.answers) do
local answer = poll.answers[vote_option_idx]
table.insert(answers, {
key = vote_option_idx,
value = vote_flag,
name = answer.name,
});
if vote_flag then
local voters = answer.voters;
if not voters then
answer.voters = {};
voters = answer.voters;
end
table.insert(voters, {
id = voter.occupant_id;
name = vote_flag and voter.occupant_name or nil;
});
end
end
local answerData = {
data = data,
event = event,
room = room,
pollId = poll.pollId,
voterName = voter.occupant_name,
voterId = voter.occupant_id,
answers = answers,
}
data.senderId = voter.occupant_id;
data.senderName = voter.occupant_name;
data.type = 'polls';
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);
return;
end
send_polls_message_to_all(room, json_msg_str);
module:context(jid.host(room.jid)):fire_event('answer-poll', answerData);
end
return true;
end);
-- Find in which poll in newPolls we have updated answers
-- @returns poll, senderId, array of boolean values for the answers of this sender
function find_updated_poll(oldPolls, newPolls)
for _, v in pairs(newPolls) do
local existing_poll = oldPolls[v.pollId];
local senderId;
for idx, newAnswer in ipairs(v.answers) do
-- let's examine now the voters
-- Create lookup tables using id as key for efficient searching
local oldLookup = {}
local newLookup = {}
-- Build lookup table for old array
if existing_poll.answers[idx].voters then
for _, element in ipairs(existing_poll.answers[idx].voters) do
oldLookup[element.id] = element
end
end
-- Build lookup table for new array
if newAnswer.voters then
for _, element in ipairs(newAnswer.voters) do
newLookup[element.id] = element
end
end
-- Find removed elements (in old but not in new)
if existing_poll.answers[idx].voters then
for _, element in ipairs(existing_poll.answers[idx].voters) do
if not newLookup[element.id] then
senderId = element.id;
end
end
end
-- Find added elements (in new but not in old)
if newAnswer.voters then
for _, element in ipairs(newAnswer.voters) do
if not oldLookup[element.id] then
senderId = element.id;
end
end
end
end
if senderId ~= nil then
-- an array of true/false values for this sender
local senderAnswers = {};
for idx, newAnswer in ipairs(v.answers) do
senderAnswers[idx] = false;
if newAnswer.voters then
for _, element in ipairs(newAnswer.voters) do
if element.id == senderId then
senderAnswers[idx] = true;
end
end
end
end
return v, senderId, senderAnswers;
end
end
end
local setup_muc_component = function(host_module, host)
-- Sets up poll data in new rooms.
host_module:hook("muc-room-created", function(event)
local room = event.room;
if is_healthcheck_room(room.jid) then return end
room.polls = {
by_id = {};
order = {};
count = 0;
};
end);
-- Sends the current poll state to new occupants after joining a room.
host_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 = {
command = "old-polls",
polls = {},
type = 'polls'
};
for i, poll in ipairs(room.polls.order) do
data.polls[i] = {
pollId = poll.pollId,
senderId = poll.senderId,
senderName = poll.senderName,
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
send_polls_message(room, json_msg_str, event.occupant.jid);
end);
-- Handles poll updates coming for a visitor node, the event contain polls structure
-- like 'old-polls' one
host_module:hook('jitsi-polls-update', function(event)
local polls_command, room = event.polls, event.room;
-- this is the initial state coming from the main prosody when only jicofo is in the room
if room.polls.count == 0 and it.count(room:each_occupant()) == 1 then
for i, v in ipairs(polls_command.polls) do
room.polls.by_id[v.pollId] = v;
table.insert(room.polls.order, v);
room.polls.count = room.polls.count + 1;
end
return;
end
-- at this point we need to find which is the new poll
local new_poll;
for _, v in pairs(polls_command.polls) do
if not room.polls.by_id[v.pollId] then
new_poll = v;
break;
end
end
if not new_poll then
-- this is an update of the voters in some of the existing polls
local updatedPoll, senderId, answers = find_updated_poll(room.polls.by_id, polls_command.polls);
if not updatedPoll then
module:log('warn', 'no new or updated poll found in update for room %s', room.jid);
return;
end
local data = {
answers = answers,
command = 'answer-poll',
pollId = updatedPoll.pollId,
senderId = senderId,
roomJid = internal_room_jid_match_rewrite(room.jid),
type = 'polls'
};
-- we need to update the history
room.polls.by_id[updatedPoll.pollId].answers = updatedPoll.answers;
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
send_polls_message_to_all(room, json_msg_str);
return;
end
room.polls.by_id[new_poll.pollId] = new_poll;
table.insert(room.polls.order, new_poll);
room.polls.count = room.polls.count + 1;
local data = {
answers = new_poll.answers,
command = 'new-poll',
pollId = new_poll.pollId,
question = new_poll.question,
senderId = new_poll.senderId,
roomJid = internal_room_jid_match_rewrite(room.jid),
type = 'polls'
};
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
send_polls_message_to_all(room, json_msg_str);
end);
end
process_host_module(muc_domain_prefix..'.'..main_virtual_host, setup_muc_component);
process_host_module('breakout.' .. main_virtual_host, setup_muc_component);
process_host_module(main_virtual_host, function(host_module)
module:context(host_module.host):fire_event('jitsi-add-identity', {
name = 'polls'; host = module.host;
});
end);

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,257 @@
-- 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);
-- Number of stanza rate violations allowed while already throttled before the session is closed.
config.max_violations = module:get_option_number("rate_limit_max_violations", 10);
-- 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, max_violations=%s",
config.login_rate, config.presence_rate, config.iq_rate, config.message_rate, config.max_violations);
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 and not session.jitsi_throttle_setlimit 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);
session.jitsi_throttle_setlimit = true;
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_setlimit = nil;
session.jitsi_throttle_timer = nil;
session.jitsi_throttle_violations = 0;
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
if session.jitsi_throttle then
session.jitsi_throttle.start = gettime();
end
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
if session.jitsi_throttle or session.jitsi_throttle_setlimit then
-- already throttled and still violating
session.jitsi_throttle_violations = session.jitsi_throttle_violations + 1;
if session.jitsi_throttle_violations >= config.max_violations then
module:log("warn", "%s rate exceeded %d times while throttled for %s, closing connection.",
stanza.name, session.jitsi_throttle_violations, session.full_jid);
session:close({ condition = "policy-violation", text = "Rate limit exceeded" });
return;
end
end
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 s2s sessions, in both directions. s2s is restricted to the hosts in
-- s2s_whitelist, so a rate limit adds no protection, but it can throttle a legitimate
-- burst of stanzas from a trusted node.
-- Use base_type, not type. util.session sets base_type when it creates the session and
-- never changes it. It sets type to 's2sin_unauthed' first, then to 's2sin' after the
-- session authenticates.
if session.base_type == 's2sin' or session.base_type == 's2sout' 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.jitsi_throttle_violations = 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,591 @@
-- mod_room_metadata_component.lua
--
-- Prosody component that provides a generic key-value metadata store for MUC
-- rooms. Metadata is held in room.jitsiMetadata (a Lua table) and broadcast
-- to all occupants as a JSON <json-message> stanza whenever it changes.
--
-- ── Metadata updates (client → component) ────────────────────────────────────
-- Clients send a <message> to this component containing a <room_metadata
-- xmlns='http://jitsi.org/jitmeet'> child whose text is JSON:
--
-- { "key": "<field-name>", "data": <any JSON value> }
--
-- Authorization is a three-step process (on_message):
-- 1. The sender must be an occupant of the room identified by the session's
-- jitsi_web_query_room field (set from the WebSocket ?room= URL param).
-- 2. The 'jitsi-metadata-allow-moderation' event is fired on the main virtual
-- host so that other modules can override the default access rules:
-- · returns false → deny the update unconditionally
-- · returns non-nil → allow the update and use the returned value as data
-- · returns nil → fall through to the default moderator-only check
-- 3. Default: the occupant must have role='moderator'.
--
-- If authorized and the value changed, room.jitsiMetadata[key] is updated and
-- the full metadata table is broadcast to every occupant. A
-- 'jitsi-metadata-updated' event is also fired on the main MUC module so that
-- other modules can react to specific key changes.
--
-- ── Metadata delivery (component → client) ────────────────────────────────────
-- Initial delivery: a stanza filter intercepts the self-presence (status 110)
-- about to be sent to a newly-joining occupant and, before it goes out, pushes
-- the current metadata to that client. TURN credentials (from external_services)
-- are included in this initial push. room.sent_initial_metadata[bare_jid] is
-- set to prevent double-delivery on admin reconnects.
--
-- Broadcast: broadcastMetadata() / send_metadata() send a <json-message> to
-- every occupant. Admins (jicofo) additionally receive the room's participant
-- and moderator lists from room._data, plus the transcription HTTP headers.
-- Those headers may carry secrets (e.g. a custom OpenAI API key), so they are
-- stripped from the payload sent to regular occupants (and redacted in logs).
--
-- ── Internal metadata changes ─────────────────────────────────────────────────
-- Other modules update room.jitsiMetadata directly and then fire
-- 'room-metadata-changed' on the MUC host to trigger a broadcast.
--
-- ── Legacy startMuted shim ────────────────────────────────────────────────────
-- When a moderator sends a presence containing <startmuted> (old client API),
-- the values are copied into room.jitsiMetadata.startMuted and a
-- 'room-metadata-changed' broadcast is triggered. This preserves backward
-- compatibility until all clients switch to the metadata API.
--
-- ── TURN / extdisco gating (optional) ────────────────────────────────────────
-- When extdisco_occpuant_check=true, extdisco IQ-get requests are intercepted
-- at priority 100 so that only room occupants can obtain TURN credentials.
--
-- 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 get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
local dt = require "util.datetime";
local ext_services = module:depends("external_services");
local get_services = ext_services.get_services;
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');
-- TODO: flip default once mobile clients update to latest ljm that supports transition of turn data in metadata
local extdisco_occpuant_check = module:get_option_boolean('extdisco_occpuant_check', false);
-- Keys that clients (including moderators) are not allowed to set via the
-- metadata message API. These are either server-controlled fields or keys whose
-- values are assembled and injected by the server at broadcast time.
local blocked_metadata_keys = module:get_option_set('room_metadata_blocked_keys', {
'allownersEnabled',
'asyncTranscription',
'audioTranslationAvailable',
'audioTranslationListenerCounts',
'audioTranslationRequests',
'conferencePresetsServiceEnabled',
'dialinEnabled',
'moderators',
'participants',
'participantsSoftLimit',
'services',
'transcriberType',
'transcription',
'visitorsEnabled',
});
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
-- Returns a shallow copy of the room metadata with jicofo-only fields removed,
-- suitable for sending to regular (non-admin) occupants. Currently this strips
-- transcription.httpHeaders, which may carry secrets (e.g. a custom OpenAI API
-- key) meant only for jicofo and the transcriber.
function get_client_metadata(room)
local metadata = table_shallow_copy(room.jitsiMetadata) or {};
if metadata.transcription and metadata.transcription.httpHeaders then
local transcription_copy = table_shallow_copy(metadata.transcription);
transcription_copy.httpHeaders = nil;
metadata.transcription = transcription_copy;
end
return metadata;
end
function broadcastMetadata(room)
-- Build the client-safe payload once and reuse it for every non-admin
-- occupant; admins get a freshly assembled payload in send_metadata.
local client_json = getMetadataJSON(room, get_client_metadata(room));
if not client_json then
return;
end
for _, occupant in room:each_occupant() do
send_metadata(occupant, room, client_json)
end
end
function send_metadata(occupant, room, json_msg, include_services)
if not json_msg or is_admin(occupant.bare_jid) then
local metadata_to_send;
if is_admin(occupant.bare_jid) then
-- jicofo gets the full metadata (including transcription httpHeaders)
-- plus the main meeting participant and moderator lists.
metadata_to_send = table_shallow_copy(room.jitsiMetadata) or {};
local participants;
local moderators = array();
if room._data.participants then
participants = array();
participants:append(room._data.participants);
end
if room._data.moderators then
moderators:append(room._data.moderators);
end
metadata_to_send.participants = participants;
metadata_to_send.moderators = moderators;
-- The aggregated live-translation request map is for jicofo only.
-- It is kept on room._data (never in jitsiMetadata) so it is never
-- broadcast to regular clients.
metadata_to_send.audioTranslationRequests = room._data.audioTranslationRequests;
-- Neutral, default-open extension point: an external module may contribute
-- additional jicofo-only metadata fields by returning a table from this
-- event -- for instance per-room translator connect headers. Fired inside
-- the admin branch, so injected fields reach only jicofo and are never
-- broadcast to client occupants. No handler means nothing is added; this
-- module holds no token/entitlement logic of its own.
local admin_extra = main_muc_module and main_muc_module:fire_event(
'jitsi-room-metadata-admin-extra', { room = room; });
if type(admin_extra) == 'table' then
for k, v in pairs(admin_extra) do
metadata_to_send[k] = v;
end
end
module:log('info', 'Sending metadata to jicofo room=%s,meeting_id=%s', room.jid, room._data.meetingId);
else
-- regular occupants get a sanitized copy (jicofo-only fields removed)
metadata_to_send = get_client_metadata(room);
if include_services then
metadata_to_send.services = {};
for _, srv in ipairs(get_services()) do
table.insert(metadata_to_send.services, {
type = srv.type;
transport = srv.transport;
host = srv.host;
port = srv.port and string.format('%d', srv.port) or nil;
username = srv.username;
password = srv.password;
expires = srv.expires and dt.datetime(srv.expires) or nil;
restricted = srv.restricted and '1' or nil;
});
end
end
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');
if not message then
return true;
end
local messageText = message:get_text();
if not messageText or messageText == '' then
return true;
end
local roomJid = message.attr.room;
if not roomJid then
return true;
end
local room = get_room_from_jid(room_jid_match_rewrite(roomJid));
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
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 type(jsonData.key) ~= 'string' then
module:log('error', 'Invalid JSON payload, key is not a string: %s', messageText);
return true;
end
if jsonData.key == nil or jsonData.data == nil or jsonData.data == json.null then
module:log("error", "Invalid JSON payload, key or data are missing: %s", messageText);
return false;
end
-- Fire 'jitsi-metadata-allow-moderation' so other modules can override the
-- default moderator-only access control on a per-key basis.
-- false → deny the update unconditionally (e.g. key restricted to server)
-- non-nil → allow the update; the returned value replaces jsonData.data
-- (lets a hook sanitise or filter the payload before it is stored)
-- nil → no opinion; fall through to the default moderator-only check
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 res == false then
module:log('warn', 'Occupant %s features do not allow this operation(%s) for %s', from, jsonData.key, room.jid);
return false;
elseif res ~= nil then
jsonData.data = res;
else
if occupant.role ~= 'moderator' then
module:log('warn', 'Occupant %s is not moderator and not allowed this operation(%s) for %s',
from, jsonData.key, room.jid);
return false;
end
end
if blocked_metadata_keys:contains(jsonData.key) then
module:log('warn', 'Occupant %s attempted to set blocked metadata key "%s" in room:%s',
from, jsonData.key, room.jid);
return false;
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', 'Metadata key "%s" updated by %s in room:%s,meeting_id:%s', jsonData.key, from, room.jid, room._data.meetingId);
broadcastMetadata(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);
local log_json = json_msg;
if room.jitsiMetadata and room.jitsiMetadata.transcription
and room.jitsiMetadata.transcription.httpHeaders then
local metadata_copy = table_shallow_copy(room.jitsiMetadata);
local transcription_copy = table_shallow_copy(metadata_copy.transcription);
local headers_redacted = {};
for k, _ in pairs(transcription_copy.httpHeaders) do
headers_redacted[k] = '***';
end
transcription_copy.httpHeaders = headers_redacted;
metadata_copy.transcription = transcription_copy;
log_json = getMetadataJSON(room, metadata_copy) or log_json;
end
module:log('info', 'Metadata changed internally in room:%s,meeting_id:%s - broadcasting data:%s', room.jid, room._data.meetingId, log_json);
broadcastMetadata(room);
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
-- checks whether the event is from an occupant of the room specified in the session,
-- if not, it returns an error and stops the processing of the event
local function check_occupant(event)
local origin, stanza = event.origin, event.stanza;
local room_name = origin.jitsi_web_query_room;
if not room_name then
module:log('warn', 'No room in session: %s', origin.full_jid);
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
end
local subdomain = origin.jitsi_web_query_prefix or '';
local room = get_room_by_name_and_subdomain(room_name, subdomain);
if not room then
module:log('warn', 'Room not found (%s/%s) for %s', subdomain, room_name, origin.full_jid);
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
end
local from = stanza.attr.from or origin.full_jid;
local occupant = get_occupant_by_real_jid(room, from);
if not occupant then
module:log('warn', '%s not an occupant of %s/%s', from, subdomain, room_name);
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
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, nil, true);
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)
local main_host_module = module:context(host_module.host);
main_host_module:fire_event('jitsi-add-identity', {
name = 'room_metadata'; host = module.host;
});
if extdisco_occpuant_check then
-- Hook at priority 100 so we run before mod_external_services (default priority 0) for both XEP-0215 v2 and legacy v1.
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:2:services', check_occupant, 100);
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:2:service', check_occupant, 100);
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:1:services', check_occupant, 100);
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:1:service', check_occupant, 100);
end
end);

View File

@@ -0,0 +1,174 @@
-----------------------------------------------------------
-- 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.
-----------------------------------------------------------
--
-- Source: https://modules.prosody.im/mod_roster_command.html (community module,
-- minor local patch to handle bare host JIDs in subscribe/unsubscribe).
--
-- This module is NOT loaded in prosody.cfg.lua. It is a prosodyctl command
-- plugin, invoked at provisioning time via:
-- prosodyctl mod_roster_command subscribe <jid1> <jid2>
-- Used by Ansible to subscribe focus.<domain> to focus@auth.<domain>.
-- Only needed for prosody versions prior 13
-----------------------------------------------------------
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,143 @@
-- 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 '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 sub = module.host;
if session.jitsi_web_query_prefix and session.jitsi_web_query_prefix ~= '' then
sub = session.jitsi_web_query_prefix;
end
local payload = {
iss = options.issuer,
aud = audience,
nbf = t,
exp = exp,
sub = sub,
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,395 @@
-- Tracks speaking time statistics for conference participants and broadcasts
-- them to clients via XMPP messages. Receives incoming <message> stanzas
-- carrying <speakerstats> (dominant speaker events) or <faceLandmarks> children
-- from clients, and sends outgoing <message> stanzas with a <json-message>
-- payload (type "speakerstats") to each occupant on request or on join.
-- Aggregates per-room stats across Jicofo and MUC events, supporting Jibri
-- recording sessions and face landmarks data.
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_focus_nick = util.is_focus_nick;
local is_transcriber = util.is_transcriber;
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;
local queue = require "util.queue";
local array = require "util.array";
-- 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;
local face_landmarks_history_size = module:get_option_number('faceLandmarks_history_size', 20);
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;
faceLandmarks:push(
{
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 = queue.new(face_landmarks_history_size);
}, 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(occupant.jid)
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 not is_focus_nick(values.nick) 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 = array(faceLandmarks:items())
};
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); -- prosody handles it at 0
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); -- prosody handles it at 0
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,153 @@
-- HTTP module that exposes a POST /send-system-chat-message endpoint for
-- delivering private system chat messages to specific participants in a MUC
-- room. Intended for internal system use (e.g. by a backend service), not for
-- end-user clients.
--
-- Authentication uses a SEPARATE ASAP key pair from the one used for login
-- tokens (mod_auth_token). The key server URL is read from
-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
-- are not accepted.
--
-- Request format:
-- POST /send-system-chat-message
-- Content-Type: application/json
-- Authorization: Bearer <system-token>
-- Body: {
-- "room": "<room-jid>",
-- "connectionJIDs": ["<full-jid>", ...],
-- "message": "<text>",
-- "displayName": "<name>" (optional)
-- }
--
-- Each JID in connectionJIDs receives a private <message> stanza from the
-- room JID containing a <json-message xmlns='http://jitsi.org/jitmeet'>
-- payload of the form:
-- { "type": "system_chat_message", "message": "...", "displayName": "..." }
--
-- Responses:
-- 200 Messages dispatched.
-- 400 Missing or invalid parameters / wrong Content-Type.
-- 401 Missing, malformed, or unverifiable token.
-- 404 Room not found.
--
-- Copyright (C) 2024-present 8x8, Inc.
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,92 @@
-- Test-only module. Never load in production.
-- Hooks MUC events on the conference component and stores them in a shared table
-- so that mod_test_observer_http (loaded on the main VirtualHost) can serve them
-- over plain HTTP without virtual-host routing conflicts.
-- Shared table. Absolute path so mod_test_observer_http (on a different host)
-- can access the same table via module:shared("/<this_host>/mod_test_observer").
-- module:shared() uses the path string as-is — no automatic host prefix.
local shared = module:shared("/" .. module.host .. "/mod_test_observer");
if not shared.events then shared.events = {}; end
if not shared.rooms then shared.rooms = {}; end
if not shared.jibri_iqs then shared.jibri_iqs = {}; end
if not shared.dial_iqs then shared.dial_iqs = {}; end
local tracked = {
"muc-room-pre-create";
"muc-room-created";
"muc-occupant-pre-join";
"muc-occupant-joined";
"muc-occupant-left";
"muc-room-destroyed";
};
for _, name in ipairs(tracked) do
module:hook(name, function(event)
local entry = { event = name, timestamp = os.time() };
if event.room then entry.room = event.room.jid end
if event.occupant then entry.occupant = tostring(event.occupant.bare_jid) end
table.insert(shared.events, entry);
end, -1000); -- run after all other handlers
end
-- Keep a live reference to each room so mod_test_observer_http can query state
-- without needing get_room_from_jid (which is not accessible from the VirtualHost
-- module's context in Prosody 13).
module:hook("muc-room-created", function(event)
shared.rooms[event.room.jid] = event.room;
end, -999);
module:hook("muc-room-destroyed", function(event)
shared.rooms[event.room.jid] = nil;
end, -999);
-- Record Jibri IQs that reach the MUC component (i.e. passed mod_filter_iq_jibri).
-- If the filter blocks an IQ it never arrives here, so absence = blocked.
-- High priority (500) so this runs before mod_muc's iq/full handler, which
-- returns non-nil and would prevent lower-priority hooks from firing.
-- Guard: only record IQs addressed to this component (to = room@<this_host>/nick).
-- The MUC re-routes the IQ to the occupant's real JID on another host, which
-- would fire iq/full a second time — skip those forwarded copies.
local jid_util = require "util.jid";
module:hook("iq/full", function(event)
local stanza = event.stanza;
-- Only capture initial requests; ignore error/result responses routed back
-- through the MUC (e.g. @xmpp/client auto-replying to unhandled IQs).
local iq_type = stanza.attr.type;
if iq_type ~= "set" and iq_type ~= "get" then return; end
local _, to_host = jid_util.split(stanza.attr.to);
if to_host ~= module.host then return; end
local jibri = stanza:get_child('jibri', 'http://jitsi.org/protocol/jibri');
if jibri then
table.insert(shared.jibri_iqs, {
from = stanza.attr.from;
to = stanza.attr.to;
action = jibri.attr.action;
recording_mode = jibri.attr.recording_mode;
});
end
local dial = stanza:get_child('dial', 'urn:xmpp:rayo:1');
if dial then
local headers = {};
for _, child in ipairs(dial.tags) do
if child.name == "header" and child.attr.name then
headers[child.attr.name] = child.attr.value;
end
end
table.insert(shared.dial_iqs, {
from = stanza.attr.from;
to = stanza.attr.to;
dial_to = dial.attr.to;
room_name_header = headers["JvbRoomName"];
room_pass_header = headers["JvbRoomPassword"];
headers = headers;
-- Raw serialized form of the stanza as util.stanza would write it
-- to the wire. Used by JS tests to inspect the actual byte content
-- of forwarded attributes.
raw_xml = tostring(stanza);
});
end
end, 500);
module:log("info", "test_observer loaded");

View File

@@ -0,0 +1,658 @@
-- Test-only module. Never load in production.
-- Serves HTTP endpoints for test assertions. Must be loaded on the main VirtualHost
-- (not the MUC component) so that plain HTTP on port 5280 is reachable with
-- Host: localhost — the VirtualHost's domain.
--
-- Data is supplied by mod_test_observer (loaded on the MUC component) via
-- module:shared. Load order does not matter; shared tables are created lazily.
--
-- Also serves a mock access manager endpoint for mod_muc_auth_ban tests:
-- GET /test-observer/access-manager — called by Prosody; returns configured response
-- POST /test-observer/access-manager — called by tests to configure the response
local json = require "cjson.safe";
local io = require "io";
local jid_lib = require "util.jid";
-- Mock access-manager state for mod_muc_auth_ban tests.
-- access: true → return {"access": true} (user allowed)
-- access: false → return {"access": false} (user banned)
-- status: any non-200 value → return that HTTP status code with no JSON body
-- (simulates HTTP errors; mod_muc_auth_ban fails open on non-200)
-- non_json: true → return a 200 with a plain-text body (not valid JSON)
-- (tests the nil-check bug: json.decode returns nil, r['access'] would crash)
-- Reset to the default (allow, 200) between tests via POST /test-observer/access-manager.
local access_manager_state = { access = true, status = 200, non_json = false };
-- Mock OTLP trace receiver for mod_trace tests. Stores every exported
-- resource_spans payload POSTed by the module's Exporter so tests can assert
-- a span was (or was not) exported without needing a real OTLP collector.
local otlp_traces = {};
-- ASAP public key servers: serve test RSA public keys so that Prosody can
-- fetch them when verifying RS256 tokens signed by the matching private keys.
-- util.lib.lua constructs the URL as: <asap_key_server>/<sha256hex(kid)>.pem
--
-- Two separate key pairs are used:
-- Login tokens (kid "test-asap-key"): signed by the login key pair,
-- served at /test-observer/asap-keys/
-- used by mod_auth_token (VirtualHost "localhost")
-- System tokens (kid "test-system-asap-key"): signed by the system key pair,
-- served at /test-observer/system-asap-keys/
-- used by mod_muc_end_meeting and similar HTTP API modules
local ASAP_KEY_PATH = "/opt/prosody-jitsi-plugins/test-asap-public.pem";
local ASAP_KID_SHA256 = "dc6983da8e703a3f51d4c1cb92b52c982f7853ce3d5ba20c782fcd13616f6dfc";
local SYSTEM_ASAP_KEY_PATH = "/opt/prosody-jitsi-plugins/test-system-asap-public.pem";
local SYSTEM_ASAP_KID_SHA256 = "e76ed986a75a90756e5add6e8b56efc3d3f027764436d2744d29a33f0ec24fea";
local function load_pem(path)
local f = io.open(path, "r");
if not f then return nil; end
local data = f:read("*all");
f:close();
return data;
end
local asap_public_key = load_pem(ASAP_KEY_PATH);
if asap_public_key then
module:log("info", "Loaded test ASAP public key from %s", ASAP_KEY_PATH);
else
module:log("warn", "Test ASAP public key not found at %s; login ASAP key-server routes will 404", ASAP_KEY_PATH);
end
local system_asap_public_key = load_pem(SYSTEM_ASAP_KEY_PATH);
if system_asap_public_key then
module:log("info", "Loaded test system ASAP public key from %s", SYSTEM_ASAP_KEY_PATH);
else
module:log("warn", "Test system ASAP public key not found at %s; system ASAP key-server routes will 404", SYSTEM_ASAP_KEY_PATH);
end
-- session-info: capture mod_jitsi_session / mod_auth_token fields after
-- resource-bind so tests can assert URL query params and JWT claims were
-- stored correctly on the session object.
local session_info = {}; -- full_jid -> field snapshot
local function capture_session_info(event)
local session = event.session;
local jid = session.full_jid;
if jid then
session_info[jid] = {
previd = session.previd,
customusername = session.customusername,
jitsi_web_query_room = session.jitsi_web_query_room,
jitsi_web_query_prefix = session.jitsi_web_query_prefix,
auth_token = session.auth_token,
user_region = session.user_region,
user_agent_header = session.user_agent_header,
-- Fields set by mod_auth_token after JWT verification:
jitsi_meet_room = session.jitsi_meet_room,
jitsi_meet_context_user = session.jitsi_meet_context_user,
jitsi_meet_context_group = session.jitsi_meet_context_group,
jitsi_meet_context_features = session.jitsi_meet_context_features,
};
end
end
-- resource-bind fires on each VirtualHost's own event bus, not globally.
-- Register on every host already active at module load time, and on any host
-- that activates later (e.g. hs256.localhost loads after localhost since
-- VirtualHosts are initialized in config order).
local function register_on_host(host_obj)
if host_obj and host_obj.events then
host_obj.events.add_handler("resource-bind", capture_session_info, 10);
end
end
for _, host_obj in pairs(prosody.hosts) do
register_on_host(host_obj);
end
-- host-activated passes the hostname string directly (not a table).
module:hook_global("host-activated", function(host)
register_on_host(prosody.hosts[host]);
end);
-- /conference.localhost/mod_test_observer is the absolute path for the shared
-- table created by mod_test_observer running on conference.localhost.
local MUC_HOST = module:get_option_string("muc_mapper_domain_base", "localhost");
local MUC_COMPONENT = module:get_option_string("muc_mapper_domain_prefix", "conference") .. "." .. MUC_HOST;
local shared = module:shared("/" .. MUC_COMPONENT .. "/mod_test_observer");
local function urldecode(s)
return (s:gsub("%%(%x%x)", function(hex)
return string.char(tonumber(hex, 16));
end));
end
local function parse_query(q)
local params = {};
for k, v in (q or ""):gmatch("([^&=]+)=([^&]*)") do
params[urldecode(k)] = urldecode(v);
end
return params;
end
module:provides("http", {
default_path = "/test-observer";
route = {
-- GET /test-observer/asap-keys/<sha256hex(kid)>.pem
-- Returns the login RSA public key PEM so mod_auth_token can verify RS256 login tokens.
-- kid must be "test-asap-key" (its SHA256 hex is the filename).
["GET /asap-keys/"..ASAP_KID_SHA256..".pem"] = function()
if not asap_public_key then
return { status_code = 404; body = "key not found" };
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/x-pem-file" };
body = asap_public_key;
};
end;
-- GET /test-observer/system-asap-keys/<sha256hex(kid)>.pem
-- Returns the system RSA public key PEM for mod_muc_end_meeting and similar
-- system HTTP API modules that use prosody_password_public_key_repo_url.
-- kid must be "test-system-asap-key" (its SHA256 hex is the filename).
["GET /system-asap-keys/"..SYSTEM_ASAP_KID_SHA256..".pem"] = function()
if not system_asap_public_key then
return { status_code = 404; body = "key not found" };
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/x-pem-file" };
body = system_asap_public_key;
};
end;
["GET /events"] = function()
local events = shared.events or {};
-- cjson encodes an empty Lua table as {} (object); force array literal.
local body = #events == 0 and "[]" or json.encode(events);
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = body;
};
end;
["DELETE /events"] = function()
-- Replace with a fresh table; mod_test_observer always reads shared.events
-- via the shared reference so it will see the new table immediately.
shared.events = {};
return { status_code = 204 };
end;
-- GET /test-observer/jibri-iqs
-- Returns the list of Jibri IQs that reached the MUC (i.e. passed mod_filter_iq_jibri).
["GET /jibri-iqs"] = function()
local iqs = shared.jibri_iqs or {};
local body = #iqs == 0 and "[]" or json.encode(iqs);
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = body;
};
end;
-- DELETE /test-observer/jibri-iqs
-- Clears the recorded Jibri IQ list. Call before each test.
["DELETE /jibri-iqs"] = function()
shared.jibri_iqs = {};
return { status_code = 204 };
end;
-- GET /test-observer/dial-iqs
-- Returns the list of Rayo dial IQs that reached the MUC (i.e. passed mod_filter_iq_rayo).
["GET /dial-iqs"] = function()
local iqs = shared.dial_iqs or {};
local body = #iqs == 0 and "[]" or json.encode(iqs);
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = body;
};
end;
-- DELETE /test-observer/dial-iqs
-- Clears the recorded Rayo dial IQ list. Call before each test.
["DELETE /dial-iqs"] = function()
shared.dial_iqs = {};
return { status_code = 204 };
end;
-- POST /test-observer/sessions/context
-- Body: { "jid": "node@localhost/resource", "user_id": "...", "features": { "flip": true } }
-- Sets jitsi_meet_context_user / jitsi_meet_context_features on the c2s session so that
-- mod_muc_flip (and other JWT-aware modules) see the same context as they would with a
-- real token — without needing a JWT auth module in the test setup.
["POST /sessions/context"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local full_jid = data.jid;
local user_id = data.user_id;
local features = data.features or {};
if not full_jid then
return { status_code = 400; body = '{"error":"missing jid"}' };
end
local node, host, resource = jid_lib.split(full_jid);
local host_obj = host and prosody.hosts[host];
if not host_obj then
return { status_code = 404; body = '{"error":"host not found"}' };
end
local user_obj = host_obj.sessions and host_obj.sessions[node];
if not user_obj then
return { status_code = 404; body = '{"error":"user not found"}' };
end
local session = user_obj.sessions and user_obj.sessions[resource];
if not session then
return { status_code = 404; body = '{"error":"session not found"}' };
end
if user_id then
session.jitsi_meet_context_user = { id = user_id };
end
session.jitsi_meet_context_features = features;
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = '{"ok":true}';
};
end;
-- GET /test-observer/sessions/features?jid=user@localhost/resource
-- Returns the live jitsi_meet_context_features for the session.
-- Reading live (not a snapshot) captures side-effects from modules such as
-- mod_jitsi_permissions, which may set or update features after resource-bind.
-- Returns 200 {"features": <object|null>} or 404.
["GET /sessions/features"] = function(event)
local params = parse_query(event.request.url.query);
local full_jid = params["jid"];
if not full_jid then
return { status_code = 400; body = '{"error":"missing jid param"}' };
end
local node, host, resource = jid_lib.split(full_jid);
local host_obj = host and prosody.hosts[host];
if not host_obj then
return { status_code = 404; body = '{"error":"host not found"}' };
end
local user_obj = host_obj.sessions and host_obj.sessions[node];
if not user_obj then
return { status_code = 404; body = '{"error":"user not found"}' };
end
local session = user_obj.sessions and user_obj.sessions[resource];
if not session then
return { status_code = 404; body = '{"error":"session not found"}' };
end
local features = session.jitsi_meet_context_features;
local body = features and json.encode({ features = features }) or '{"features":null}';
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = body;
};
end;
-- GET /test-observer/rooms/participants?jid=room@conference.localhost
-- Returns: { participants_details: { userId: fullNick }, kicked_participant_nick?, flip_participant_nick? }
-- Exposes mod_muc_flip's per-room tracking tables for test assertions.
["GET /rooms/participants"] = function(event)
local params = parse_query(event.request.url.query);
local room_jid = params["jid"];
if not room_jid then
return { status_code = 400; body = '{"error":"missing jid param"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
local result = { participants_details = room._data.participants_details or {} };
if room._data.kicked_participant_nick ~= nil then
result.kicked_participant_nick = room._data.kicked_participant_nick;
end
if room._data.flip_participant_nick ~= nil then
result.flip_participant_nick = room._data.flip_participant_nick;
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = json.encode(result);
};
end;
-- POST /test-observer/rooms/hide-display-name
-- Body: { "jid": "room@conference.localhost", "hidden": true|false }
-- Sets room._data.hideDisplayNameForGuests for mod_muc_displayname tests.
["POST /rooms/hide-display-name"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local room_jid = data.jid;
local hidden = data.hidden;
if room_jid == nil or hidden == nil then
return { status_code = 400; body = '{"error":"missing jid or hidden"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
room._data.hideDisplayNameForGuests = hidden;
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = '{"ok":true}';
};
end;
-- POST /test-observer/rooms/breakout-rooms-active
-- Body: { "jid": "room@conference.localhost", "active": true|false }
-- Sets room._data.breakout_rooms_active, which mod_muc_cleanup_backend_services
-- checks to skip the destroy-timer logic when breakout rooms are running.
["POST /rooms/breakout-rooms-active"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local room_jid = data.jid;
local active = data.active;
if room_jid == nil or active == nil then
return { status_code = 400; body = '{"error":"missing jid or active"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
room._data.breakout_rooms_active = active;
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = '{"ok":true}';
};
end;
-- POST /test-observer/rooms/max-occupants
-- Body: { "jid": "room@conference.localhost", "max_occupants": 4 }
-- Sets room._data.max_occupants so per-room limit tests can override the
-- global muc_max_occupants without restarting Prosody.
["POST /rooms/max-occupants"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local room_jid = data.jid;
local max = tonumber(data.max_occupants);
if not room_jid or not max then
return { status_code = 400; body = '{"error":"missing jid or max_occupants"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
room._data.max_occupants = max;
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = '{"ok":true}';
};
end;
-- POST /test-observer/otlp-traces
-- Called by mod_trace's Exporter. Body is the raw OTLP
-- ExportTraceServiceRequest JSON; stored verbatim for test assertions.
["POST /otlp-traces"] = function(event)
local data = json.decode(event.request.body or "{}");
if data then
table.insert(otlp_traces, data);
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = "{}";
};
end;
-- GET /test-observer/otlp-traces
-- Returns every ExportTraceServiceRequest body received so far.
["GET /otlp-traces"] = function()
local body = #otlp_traces == 0 and "[]" or json.encode(otlp_traces);
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = body;
};
end;
-- DELETE /test-observer/otlp-traces
-- Clears the recorded export list. Call before each test.
["DELETE /otlp-traces"] = function()
otlp_traces = {};
return { status_code = 204 };
end;
-- GET /test-observer/access-manager
-- Mock access-manager endpoint called by mod_muc_auth_ban.
-- Returns {"access": true} or {"access": false} based on the current
-- access_manager_state, or a non-200 status to simulate HTTP errors.
-- The real access manager receives `Authorization: Bearer <token>` but
-- this mock ignores it and returns the globally configured response.
["GET /access-manager"] = function()
if access_manager_state.status ~= 200 then
return { status_code = access_manager_state.status; body = "error" };
end
if access_manager_state.non_json then
-- Return a 200 with a non-JSON body to exercise the nil-check
-- bug in mod_muc_auth_ban: json.decode returns nil, and
-- r['access'] would crash if the module doesn't guard for nil.
return {
status_code = 200;
headers = { ["Content-Type"] = "text/plain" };
body = "not-valid-json";
};
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = json.encode({ access = access_manager_state.access });
};
end;
-- POST /test-observer/access-manager
-- Body: { "access": true|false, "status": <http-status-code>, "non_json": true|false }
-- Configures what the mock access manager returns.
-- Call this from tests before connecting the client under test.
-- Call with { "access": true, "status": 200 } to reset to the default.
["POST /access-manager"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
if data.access ~= nil then
access_manager_state.access = data.access;
end
if data.status ~= nil then
access_manager_state.status = tonumber(data.status) or 200;
end
if data.non_json ~= nil then
access_manager_state.non_json = data.non_json;
end
return { status_code = 204 };
end;
-- GET /test-observer/session-info?jid=user@localhost/resource
-- Returns the mod_jitsi_session fields captured at resource-bind time.
["GET /session-info"] = function(event)
local params = parse_query(event.request.url.query);
local jid = params["jid"];
if not jid then
return { status_code = 400; body = '{"error":"missing jid param"}' };
end
local info = session_info[jid];
if not info then
return { status_code = 404; body = '{"error":"session not found"}' };
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = json.encode(info);
};
end;
-- GET /test-observer/session-live?jid=user@localhost/resource
-- Returns the CURRENT values of the JWT-derived fields on the live c2s
-- session, read straight from prosody.full_sessions. Unlike
-- /session-info (a snapshot taken at resource-bind) this reflects
-- changes made after the session was established -- notably the claim
-- refresh performed by mod_auth_token on XEP-0198 stream resumption,
-- which never binds a new resource.
["GET /session-live"] = function(event)
local params = parse_query(event.request.url.query);
local jid = params["jid"];
if not jid then
return { status_code = 400; body = '{"error":"missing jid param"}' };
end
local session = prosody.full_sessions[jid];
if not session then
return { status_code = 404; body = '{"error":"session not found"}' };
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = json.encode({
auth_token = session.auth_token,
jitsi_web_query_room = session.jitsi_web_query_room,
jitsi_web_query_prefix = session.jitsi_web_query_prefix,
jitsi_meet_room = session.jitsi_meet_room,
jitsi_meet_domain = session.jitsi_meet_domain,
jitsi_meet_str_tenant = session.jitsi_meet_str_tenant,
jitsi_meet_context_user = session.jitsi_meet_context_user,
jitsi_meet_context_group = session.jitsi_meet_context_group,
jitsi_meet_context_features = session.jitsi_meet_context_features,
});
};
end;
-- POST /test-observer/rooms/lobby
-- Body: { "jid": "room@conference.localhost", "enabled": true|false }
-- Enables or disables the lobby for the given room by firing the
-- create-lobby-room / destroy-lobby-room global Prosody events.
-- Enabling also sets members-only; disabling unsets it.
-- Response: { ok, lobbyroom } (lobbyroom is nil when disabling).
["POST /rooms/lobby"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local room_jid = data.jid;
local enabled = data.enabled;
if not room_jid then
return { status_code = 400; body = '{"error":"missing jid"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
if enabled then
prosody.events.fire_event('create-lobby-room', { room = room });
else
room:set_members_only(false);
prosody.events.fire_event('destroy-lobby-room', { room = room });
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = json.encode({ ok = true; lobbyroom = room._data.lobbyroom });
};
end;
-- POST /test-observer/rooms/affiliation
-- Body: { "jid": "room@conference.localhost", "occupant_jid": "user@localhost", "affiliation": "member" }
-- Sets the affiliation of occupant_jid in the given room.
["POST /rooms/affiliation"] = function(event)
local data = json.decode(event.request.body or "{}") or {};
local room_jid = data.jid;
local occupant_jid = data.occupant_jid;
local affiliation = data.affiliation;
if not room_jid or not occupant_jid or not affiliation then
return { status_code = 400; body = '{"error":"missing required fields"}' };
end
local room = (shared.rooms or {})[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
room:set_affiliation(true, occupant_jid, affiliation);
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = '{"ok":true}';
};
end;
-- GET /test-observer/rooms/metadata?jid=room@conference.localhost
-- Returns: { jid, metadata } where metadata is room.jitsiMetadata.
["GET /rooms/metadata"] = function(event)
local params = parse_query(event.request.url.query);
local room_jid = params["jid"];
if not room_jid then
return { status_code = 400; body = '{"error":"missing jid param"}' };
end
local rooms = shared.rooms or {};
local room = rooms[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
local encoded, err = json.encode({
jid = room.jid;
metadata = room.jitsiMetadata or {};
});
if not encoded then
return { status_code = 500; body = json.encode({ error = 'encode failed: ' .. tostring(err) }) };
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = encoded;
};
end;
-- GET /test-observer/rooms/audio-translation-requests?jid=room@conference.localhost
-- Returns: { jid, audioTranslationRequests } where the value is the
-- aggregated map stored on room._data by mod_audio_translation_component
-- (omitted when there are no subscriptions).
["GET /rooms/audio-translation-requests"] = function(event)
local params = parse_query(event.request.url.query);
local room_jid = params["jid"];
if not room_jid then
return { status_code = 400; body = '{"error":"missing jid param"}' };
end
local rooms = shared.rooms or {};
local room = rooms[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
local encoded, err = json.encode({
jid = room.jid;
audioTranslationRequests = room._data.audioTranslationRequests;
});
if not encoded then
return { status_code = 500; body = json.encode({ error = 'encode failed: ' .. tostring(err) }) };
end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = encoded;
};
end;
-- GET /test-observer/rooms?jid=room@conference.localhost
-- Returns: { jid, hidden, occupant_count }
["GET /rooms"] = function(event)
local params = parse_query(event.request.url.query);
local room_jid = params["jid"];
if not room_jid then
return { status_code = 400; body = '{"error":"missing jid param"}' };
end
local rooms = shared.rooms or {};
local room = rooms[room_jid];
if not room then
return { status_code = 404; body = '{"error":"room not found"}' };
end
local count = 0;
for _ in room:each_occupant() do count = count + 1; end
return {
status_code = 200;
headers = { ["Content-Type"] = "application/json" };
body = json.encode({
jid = room.jid;
hidden = room:get_hidden();
occupant_count = count;
});
};
end;
};
});
module:log("info", "test_observer_http loaded");

View File

@@ -0,0 +1,116 @@
--- activate under the main muc component
local MIN = module:get_option_number("conference_max_minutes", 5)
local TIMEOUT = MIN * 60
-- Gratis time granted past the limit so people can say their goodbyes and
-- leave on their own instead of being cut off mid-sentence. Clients still see
-- their countdown end at TIMEOUT - they spend the grace period in the visible
-- over-schedule state, which is the cue to wrap up - the room is simply not
-- torn down until it elapses. 0 (the default) destroys the room the moment the
-- limit is reached.
local GRACE = module:get_option_number("conference_grace_seconds", 0)
local DESTROY_AT = TIMEOUT + GRACE
local is_healthcheck_room = module:require "util".is_healthcheck_room
local st = require "util.stanza"
local json = require "cjson.safe"
local array = require "util.array";
module:log('info', "loaded")
local restricted_rooms = array{};
-- The `type` the time-timer feature in jitsi-meet matches on. Sent as a
-- json-message from the room JID so lib-jitsi-meet surfaces it as a
-- NON_PARTICIPANT_MESSAGE_RECEIVED (a server-originated, non-participant
-- message) rather than a participant endpoint message.
local TIME_RESTRICTED_MESSAGE_TYPE = 'time_restricted';
-- Sends the time-timer json-message to a single occupant. `durationSeconds` is
-- the full meeting length and `elapsedSeconds` how far in we are, so every
-- client - first joiner or last - lands on the same countdown.
local function send_time_timer(room, to_jid)
local elapsed = os.time() - (room.time_restricted_created or os.time());
local body, error = json.encode({
type = TIME_RESTRICTED_MESSAGE_TYPE;
durationSeconds = TIMEOUT;
elapsedSeconds = elapsed;
});
if not body then
module:log('error', 'failed to encode time-timer message room:%s error:%s', room.jid, error);
return;
end
-- from = room.jid so the stanza routes through ChatRoom.onMessage in
-- lib-jitsi-meet (which only processes messages whose bare from matches the
-- room) and is emitted as JSON_MESSAGE_RECEIVED -> NON_PARTICIPANT_MESSAGE_RECEIVED.
local stanza = st.message({ from = room.jid; to = to_jid; })
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(body):up();
module:send(stanza);
end
module:hook("muc-room-created", function (event)
local room = event.room
if is_healthcheck_room(room.jid) then
return
end
room.time_restricted_created = os.time();
room.destroy_timer = module:add_timer(DESTROY_AT, function()
if is_healthcheck_room(room.jid) then
return
end
restricted_rooms[room.jid] = true;
-- %g (not %d) so a fractional conference_max_minutes does not crash the
-- timer; for the usual whole-minute configs it renders identically.
room:destroy(nil, string.format('This meeting reached its %g-minute time limit and has ended.', MIN));
module:log('info', "the conference terminated %s", room.jid);
end)
end)
-- Every occupant is told the meeting's limit as soon as they join, so the
-- client holds the full picture from the start and decides for itself when to
-- surface it (`timeTimer.suppressForSeconds` in jitsi-meet delays the visible
-- countdown by a configurable number of seconds). `elapsedSeconds` keeps late
-- joiners in sync with everyone else.
module:hook("muc-occupant-joined", function (event)
local room, occupant = event.room, event.occupant;
if is_healthcheck_room(room.jid) then
return
end
-- Only for rooms this module saw created: without a creation stamp we
-- cannot say how far in the meeting is, and reporting elapsed = 0 for a
-- room that has been running for a while would be worse than saying
-- nothing.
if room.time_restricted_created then
send_time_timer(room, occupant.jid);
end
end);
module:hook("muc-room-pre-create", function(event)
local origin, stanza, room = event.origin, event.stanza, event.room;
if is_healthcheck_room(room.jid) then
return
end
if restricted_rooms[room.jid] then
module:log('info', "the conference id blocked %s", room.jid);
origin.send(st.error_reply(stanza, "cancel", "resource-constraint", nil, module.host));
return true;
end
end, 200);
module:hook('muc-room-destroyed',function(event)
local room = event.room;
if room.destroy_timer then
room.destroy_timer:stop();
room.destroy_timer = nil;
end
end, 1); -- prosody handles it at 0

View File

@@ -0,0 +1,86 @@
-- mod_token_affiliation.lua
--
-- Grants owner affiliation (and thus moderator role) to MUC occupants whose
-- JWT contains moderator/affiliation claims, and member affiliation to all
-- other authenticated occupants.
--
-- JWT claims inspected (context.user.*):
-- affiliation == "owner" | "moderator" | "teacher" → owner
-- moderator == true | "true" → owner
-- Authenticated user with none of the above → member
--
-- The affiliation is written to the room's affiliation list inside
-- muc-occupant-pre-join (before the join completes), so the very first
-- presence broadcast already carries the correct affiliation and role.
-- No filtering or second hook required.
--
-- Originally imported from:
-- https://github.com/jitsi-contrib/prosody-plugins/tree/main/token_affiliation
local LOGLEVEL = module:get_option_string("token_affiliation_log_level", "debug")
local util = module:require 'util';
local is_admin = util.is_admin;
local is_healthcheck_room = util.is_healthcheck_room
module:log(LOGLEVEL, "loaded")
local function affiliation_from_token(session)
if not session or not session.auth_token then
return nil
end
local context_user = session.jitsi_meet_context_user
if context_user then
if context_user["affiliation"] == "owner"
or context_user["affiliation"] == "moderator"
or context_user["affiliation"] == "teacher"
or context_user["moderator"] == "true"
or context_user["moderator"] == true then
return "owner"
end
end
return "member"
end
-- Hook priority -3.5: runs after lobby-bypass plugins such as mod_token_lobby_bypass
-- (priority -3), so any affiliation they grant is visible here and can be upgraded
-- to the correct token-derived value. Runs before the lobby gate (-4) and
-- Prosody's built-in members_only check (-5).
module:hook("muc-occupant-pre-join", function(event)
local room, occupant, session = event.room, event.occupant, event.origin
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
module:log(LOGLEVEL, "skip, %s", occupant.jid)
return
end
-- When lobby is active (members-only) only proceed when the user already
-- has a non-none affiliation (admitted by moderator, or granted by a bypass
-- plugin that ran at priority -3) or is bypassing via the room password.
-- Otherwise let the lobby gate (-4) block this first join attempt.
if room:get_members_only() then
local existing = room:get_affiliation(occupant.bare_jid)
if not existing or existing == 'none' then
local join_el = event.stanza:get_child('x', 'http://jabber.org/protocol/muc')
local join_pwd = join_el and join_el:get_child_text('password', 'http://jabber.org/protocol/muc')
local bypassing_via_password = join_pwd and room:get_password() and join_pwd == room:get_password()
if not bypassing_via_password then
module:log(LOGLEVEL, "skip, lobby active for %s", occupant.bare_jid)
return
end
end
end
local affiliation = affiliation_from_token(session)
if not affiliation then
return
end
room:set_affiliation(true, occupant.bare_jid, affiliation)
if affiliation == "owner" then
occupant.role = "moderator"
end
module:log(LOGLEVEL, "set affiliation=%s for %s", affiliation, occupant.bare_jid)
end, -3.5)

View File

@@ -0,0 +1,200 @@
-- Enforces JWT token room-claim verification at MUC join/create time. Loaded
-- on the MUC component. For each join or room-create, calls
-- token_util:verify_room() to check that the token's room claim matches the
-- target room JID. Admins and domains listed in token_verification_allowlist
-- are exempt. Anonymous users (no token) are allowed through. When
-- token_verification_require_token_for_moderation is set, also blocks room
-- config IQs (e.g. granting moderator status) from unauthenticated users.
-- Records the verified conference on the session (jitsi_meet_verified_room) and
-- answers the 'jitsi-verify-session-rooms' event, so that a session whose JWT
-- claims are refreshed after the join (mod_auth_token, on stream resumption) is
-- re-checked against it.
-- 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, jigasi (sip & transcriber), jibri (recorder & sip)
if allowlist:contains(user_domain)
or allowlist:contains(user_bare_jid)
-- allow main participants in visitor mode
or session.type == 's2sin' 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
local res, err, reason = token_util:verify_room(session, stanza.attr.to);
if not res then
if not err and not reason then
reason = 'Room and token mismatched';
end
module:log('error', 'Token %s not allowed to join: %s err: %s reason: %s',
tostring(session.auth_token), tostring(stanza.attr.to), err, reason);
local response = st.error_reply(stanza, 'cancel', 'not-allowed', reason);
if err then
response:tag(err, { xmlns = 'http://jitsi.org/jitmeet' });
end
session.send(response);
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
-- Remember the conference this session was verified for, so claims that are
-- refreshed later (mod_auth_token, on stream resumption) can be checked
-- against it without going through a join again.
session.jitsi_meet_verified_room = jid_bare(stanza.attr.to);
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);
-- Verifies the token claims currently on a session against the conference it
-- was verified for on join. Fired by mod_auth_token when a connection resuming
-- a hibernating session (XEP-0198) presents a different token, which refreshes
-- the claims of the live session: the hooks above only run at join time, so
-- this applies the same room check to a session that is already in a room.
-- Answering with res=false makes mod_auth_token keep the claims that were
-- verified on join.
module:hook_global('jitsi-verify-session-rooms', function(event)
local session = event.session;
local room_jid = session.jitsi_meet_verified_room;
if not room_jid then
return;
end
-- Being an occupant is deliberately not required: a session that moved into
-- a breakout room has left the main room it joined, and that main room is
-- still the conference its claims have to cover. Only a room that is gone
-- (everyone left while the session was hibernating) is skipped.
if not prosody.hosts[host].modules.muc.get_room_from_jid(room_jid) then
return;
end
local res, err, reason = token_util:verify_room(session, room_jid);
if not res then
measure_fail(1);
return { res = false; room = room_jid; error = err; reason = reason; };
end
measure_success(1);
end);
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,46 @@
local otel = module:require "otel"
local http = require "net.http"
local otlp_endpoint = module:get_option("otlp_endpoint")
if not otlp_endpoint then
module:log("warn", "module disabled: otlp_endpoint unset")
return
end
local exporter = otel.Exporter.new(http.request, otlp_endpoint)
local processor = otel.Processor.new(exporter)
local tracer = otel.Tracer.new(processor, "prosody", "muc")
local function find_tag(tags, name)
for _, tag in ipairs(tags) do
if tag.name == name then
return tag
end
end
return nil
end
module:hook("iq/full", function(event)
local iq_element = event.stanza.tags[1]
if not iq_element then
return
end
if iq_element.name ~= "conference-modify" or iq_element.attr.xmlns ~= "jitsi:colibri2" then
return
end
local traceparent = find_tag(iq_element.tags, "traceparent")
if not traceparent then
return
end
local span = tracer:start_span("colibri.conference-modify", {
trace_id = traceparent.attr.trace_id,
span_id = traceparent.attr.parent_id,
})
span:end_span()
traceparent.attr.parent_id = span.span_id
end, 1)

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,600 @@
--- 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_jibri = util.is_jibri;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
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
-- 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 = {};
-- forward declaration; assigned below. Referenced by a closure registered on the main muc's
-- 'room-metadata-changed' event (see process_host_module(main_muc_component_config, ...) below) so vnodes
-- are refreshed whenever the main room's metadata (e.g. asyncTranscription) changes.
local update_vnodes_for_room;
-- 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,
allowUnauthenticatedAccess = room._data.allowUnauthenticatedAccess ~= nil and tostring(room._data.allowUnauthenticatedAccess) 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
if room.polls and room.polls.count > 0 then
-- polls created in the room that we want to send to the visitor nodes
local data = {
command = "old-polls",
polls = {},
type = 'polls'
};
for i, poll in ipairs(room.polls.order) do
data.polls[i] = {
pollId = poll.pollId,
senderId = poll.senderId,
senderName = poll.senderName,
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
visitors_iq:tag('polls'):text(json_msg_str):up();
end
end
-- Forward the main room's metadata (e.g. asyncTranscription) so visitor nodes/clients can make the
-- same decisions main-room clients do (sent on both 'connect' and 'update', not just 'update').
if room.jitsiMetadata then
local json_metadata, metadata_error = json.encode(room.jitsiMetadata);
if json_metadata then
visitors_iq:tag('metadata', { xmlns = 'jitsi:visitors' }):text(json_metadata):up();
else
module:log('error', 'Error encoding metadata for room:%s error:%s', room.jid, metadata_error);
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 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');
-- let's send message history
local event = {
room = room;
to = conference_service;
next_stanza = function() end; -- muc-get-history should define this iterator
};
module:context(main_muc_component_config):fire_event("muc-get-history", event);
for msg in event.next_stanza, event do
-- the messages stored in history has been stored before domain_mapper and
-- contain the virtual jid for a from
msg.attr.from = room_jid_match_rewrite(msg.attr.from);
room:route_stanza(msg);
end
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)
-- room metadata changed (e.g. asyncTranscription) -- refresh all connected vnodes so their
-- mirrored room and visitor clients stay in sync with the main room. Called via a wrapper closure
-- (rather than passing update_vnodes_for_room directly) since it is only assigned further down in
-- this file, after this hook is registered.
host_module:hook('room-metadata-changed', function(event)
update_vnodes_for_room(event);
end);
-- 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 jibri
if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil or is_jibri(occupant.jid) 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 jicofo and jibri
if is_admin(occupant.bare_jid) or visitors_nodes[room.jid] == nil or visitors_nodes[room.jid].nodes == nil
or is_jibri(occupant.jid) 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, 1); -- prosody handles it at 0
-- 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 jibri
if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil or is_jibri(occupant.jid) 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);
-- forwards a moderation applied by the room to the vnodes. The hook above only
-- handles messages sent by an occupant; a moderation is sent by the room itself,
-- so mod_muc_message_moderation announces it here.
host_module:hook('jitsi-message-moderated', function(event)
local room, stanza = event.room, event.stanza;
if not visitors_nodes[room.jid] or not visitors_nodes[room.jid].nodes then
return;
end
for conference_service in pairs(visitors_nodes[room.jid].nodes) do
local fmuc_msg = st.clone(stanza);
fmuc_msg.attr.to = jid.join(jid.node(room.jid), conference_service);
fmuc_msg.attr.from = room_jid_match_rewrite(room.jid);
module:log('debug', 'Forwarding moderation to %s', fmuc_msg.attr.to);
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);
host_module:hook('poll-created', function (event)
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);
host_module:hook('answer-poll', function (event)
local room, stanza = event.room, event.event.stanza;
if not visitors_nodes[room.jid] then
return;
end
local from = stanza.attr.from;
-- we need to update all vnodes
local vnodes = visitors_nodes[room.jid].nodes;
for conference_service in pairs(vnodes) do
-- skip sending the answer to the node from where it originates
if conference_service ~= from then
send_visitors_iq(conference_service, room, 'update');
end
end
end);
end);
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,788 @@
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 uuid_generate = require 'util.uuid'.generate;
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.moderators
-- 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 a group
or table_find(room._data.moderators, user_id)
or table_find(room._data.moderators, group_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 = uuid_generate();
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; });
if not room._connected_vnodes then
return;
end
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
-- A visitor (allowed via the transcription JWT feature, see mod_filter_iq_rayo's
-- 'jitsi-metadata-allow-moderation' hook) set 'recording' metadata (isTranscribingEnabled) on their
-- vnode's local mirrored room. Apply it to the real room here so jicofo (which only watches the main
-- room's metadata) sees it and starts async transcription.
local recording_metadata_el = visitors_iq:get_child('recording-metadata');
if recording_metadata_el then
if not from_vnode then
module:log('warn', 'Received forged recording_metadata message: %s %s',
stanza, inspect(room._connected_vnodes));
return true; -- stop processing
end
local data, decode_error = json.decode(recording_metadata_el:get_text());
if data then
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
room.jitsiMetadata.recording = data;
processed = true;
module:context(muc_domain_prefix..'.'..muc_domain_base)
:fire_event('room-metadata-changed', { room = room; });
else
module:log('error', 'Failed to decode recording metadata for room:%s error:%s', room.jid, decode_error);
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 = uuid_generate();
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 table_find(room._data.moderators, 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
if room:get_members_only() then
-- if there is a lobby, let's pass it through it will wait for the main participant
return;
end
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, 1); -- prosody handles it at 0
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
if always_visitors_enabled then
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
room.jitsiMetadata.visitorsEnabled = true;
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 main_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,203 @@
-- This opentelemetry library was inspired by https://github.com/yangxikun/opentelemetry-lua,
-- but simplified and adapted to the Prosody ecosystem.
local json = require "util.json"
local array = require "util.array"
local time = require "util.time"
local random = require "util.random"
local hex = require "util.hex"
local m = {}
--------------------------------------------------
-- Util --
--------------------------------------------------
local function random_hex(n)
return hex.to(random.bytes(n))
end
local function to_nanoseconds(t)
return tostring(math.floor(t * 1e9))
end
--------------------------------------------------
-- Attribute --
--------------------------------------------------
local Attribute = {}
m.Attribute = Attribute
function Attribute.string(value)
return {
string_value = value
}
end
--------------------------------------------------
-- Span --
--------------------------------------------------
local SpanStatus = {
UNSET = 0,
OK = 1,
ERROR = 2,
}
local SpanKind = {
UNSPECIFIED = 0,
INTERNAL = 1,
SERVER = 2,
CLIENT = 3,
PRODUCER = 4,
CONSUMER = 5,
}
local Span = {}
Span.__index = Span
m.Span = Span
function Span.new(name, tracer, opts)
local self = {
name = name,
tracer = tracer,
parent_context = opts.context,
trace_id = opts.context and opts.context.trace_id or random_hex(16),
span_id = random_hex(8),
start_time = time.now(),
attributes = array {},
}
return setmetatable(self, Span)
end
function Span:set_attribute(k, v)
table.insert(self.attributes, { key = k, value = v })
return self
end
function Span:end_span()
self.end_time = time.now()
self.tracer.processor:add(self)
end
function Span:encode()
return {
trace_id = self.trace_id,
span_id = self.span_id,
parent_span_id = self.parent_context and self.parent_context.span_id,
name = self.name,
start_time_unix_nano = to_nanoseconds(self.start_time),
end_time_unix_nano = to_nanoseconds(self.end_time),
attributes = self.attributes,
kind = self.kind or SpanKind.INTERNAL,
status = {
code = self.status or SpanStatus.OK
}
}
end
--------------------------------------------------
-- Processor --
--------------------------------------------------
local Processor = {}
Processor.__index = Processor
m.Processor = Processor
function Processor.new(exporter)
local self = {
exporter = exporter
}
return setmetatable(self, Processor)
end
function Processor:add(span)
self.exporter:export({ span })
end
--------------------------------------------------
-- Exporter --
--------------------------------------------------
local Exporter = {}
Exporter.__index = Exporter
m.Exporter = Exporter
function Exporter.new(request_fn, endpoint)
local self = {
request_fn = request_fn,
endpoint = endpoint,
}
return setmetatable(self, Exporter)
end
function Exporter:export(spans)
local body = {
resource_spans = {
{
resource = spans[1].tracer.resource,
scope_spans = {
{
scope = {
name = spans[1].tracer.name,
},
spans = {}
},
},
}
}
}
for _, span in ipairs(spans) do
table.insert(
body.resource_spans[1].scope_spans[1].spans,
span:encode()
)
end
self.request_fn(
self.endpoint, {
method = "POST",
headers = {
["Content-Type"] = "application/json",
},
body = json.encode(body),
},
function(body, code, response)
if code ~= 200 then
module:log("error", json.encode(body))
end
end
)
end
--------------------------------------------------
-- Tracer --
--------------------------------------------------
local Tracer = {}
Tracer.__index = Tracer
m.Tracer = Tracer
function Tracer.new(processor, service, name)
return setmetatable({
processor = processor,
resource = {
attributes = {
{
key = "service.name",
value = Attribute.string(service)
}
}
},
name = name,
}, Tracer)
end
function Tracer:start_span(name, context)
return Span.new(name, self, { context = context })
end
return m

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,140 @@
local basexx = require "basexx";
local M = {}
-- Helper function to encode bytes to base64
function base64_encode(bytes)
return basexx.to_base64(bytes)
end
-- Pure Lua ASN.1 DER encoder (no external dependencies)
local ASN1 = {}
-- Encode ASN.1 length field
function ASN1.encode_length(len)
if len < 128 then
return string.char(len)
elseif len < 256 then
return string.char(0x81, len)
elseif len < 65536 then
return string.char(0x82, math.floor(len / 256), len % 256)
else
local b1 = math.floor(len / 65536)
local b2 = math.floor((len % 65536) / 256)
local b3 = len % 256
return string.char(0x83, b1, b2, b3)
end
end
-- Encode ASN.1 INTEGER
function ASN1.encode_integer(bytes)
-- ASN.1 INTEGER tag is 0x02
-- If the high bit is set, prepend 0x00 to indicate positive number
if bytes:byte(1) >= 0x80 then
bytes = string.char(0x00) .. bytes
end
return string.char(0x02) .. ASN1.encode_length(#bytes) .. bytes
end
-- Encode ASN.1 SEQUENCE
function ASN1.encode_sequence(content)
-- ASN.1 SEQUENCE tag is 0x30
return string.char(0x30) .. ASN1.encode_length(#content) .. content
end
-- Encode ASN.1 BIT STRING
function ASN1.encode_bit_string(content)
-- ASN.1 BIT STRING tag is 0x03
-- First byte indicates number of unused bits (0x00 for byte-aligned)
return string.char(0x03) .. ASN1.encode_length(#content + 1) .. string.char(0x00) .. content
end
-- Encode ASN.1 OBJECT IDENTIFIER
function ASN1.encode_oid(oid_bytes)
-- ASN.1 OID tag is 0x06
return string.char(0x06) .. ASN1.encode_length(#oid_bytes) .. oid_bytes
end
-- Encode ASN.1 NULL
function ASN1.encode_null()
-- ASN.1 NULL tag is 0x05, length 0
return string.char(0x05, 0x00)
end
-- Convert DER to PEM format
function ASN1.der_to_pem(der, label)
label = label or "PUBLIC KEY"
local base64 = base64_encode(der)
-- Break into 64-character lines
local lines = {}
for i = 1, #base64, 64 do
table.insert(lines, base64:sub(i, i + 63))
end
return "-----BEGIN " .. label .. "-----\n" ..
table.concat(lines, "\n") .. "\n" ..
"-----END " .. label .. "-----\n"
end
-- Helper function to decode base64url
function base64url_decode(str)
-- Convert base64url to base64
str = str:gsub('-', '+'):gsub('_', '/')
-- Add padding if needed
local padding = #str % 4
if padding > 0 then
str = str .. string.rep('=', 4 - padding)
end
return basexx.from_base64(str)
end
-- Helper function to convert JWK to PEM format
function M.jwk_to_pem(jwk)
if not jwk.n or not jwk.e then
return nil;
end
-- Decode the modulus (n) and exponent (e) from base64url
local n_bytes = base64url_decode(jwk.n)
local e_bytes = base64url_decode(jwk.e)
-- Build RSA public key structure
-- RSAPublicKey ::= SEQUENCE {
-- modulus INTEGER, -- n
-- publicExponent INTEGER -- e
-- }
local modulus_asn1 = ASN1.encode_integer(n_bytes)
local exponent_asn1 = ASN1.encode_integer(e_bytes)
local rsa_pubkey = ASN1.encode_sequence(modulus_asn1 .. exponent_asn1)
-- Build SubjectPublicKeyInfo structure
-- SubjectPublicKeyInfo ::= SEQUENCE {
-- algorithm AlgorithmIdentifier,
-- subjectPublicKey BIT STRING
-- }
-- RSA OID: 1.2.840.113549.1.1.1 (rsaEncryption)
-- Encoded as: 06 09 2A 86 48 86 F7 0D 01 01 01
local rsa_oid = string.char(0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01)
local rsa_oid_encoded = ASN1.encode_oid(rsa_oid)
-- AlgorithmIdentifier ::= SEQUENCE {
-- algorithm OBJECT IDENTIFIER,
-- parameters NULL
-- }
local algorithm_id = ASN1.encode_sequence(rsa_oid_encoded .. ASN1.encode_null())
-- Wrap the RSA public key in a BIT STRING
local subject_public_key = ASN1.encode_bit_string(rsa_pubkey)
-- Final SubjectPublicKeyInfo
local spki = ASN1.encode_sequence(algorithm_id .. subject_public_key)
-- Convert to PEM format
return ASN1.der_to_pem(spki, "PUBLIC KEY")
end
-- Expose internals for unit testing (harmless in production).
M.ASN1 = ASN1
return M

View File

@@ -0,0 +1,665 @@
-- 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 jwk_to_pem = module:require "token/jwk".jwk_to_pem;
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 get_room_from_jid = main_util.get_room_from_jid;
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
-- counts the tokens with a verified signature that flag their room claim as a
-- Lua pattern
local measure_regex_room_claim = module:measure('regex_room_claim', 'counter');
--- Tells whether a token asks for its room claim to be used as a Lua pattern,
-- which the context.room.regex field marks.
-- @param room_context the context.room value from the token
-- @return true when the room claim is a pattern
local function is_regex_room_claim(room_context)
if type(room_context) ~= 'table' then
return false;
end
return room_context['regex'] == true or room_context['regex'] == 'true';
end
--- Counts the quantifiers ('*', '+', '-' and '?') in a Lua pattern.
-- A quantifier is only special outside a character class, so the contents of
-- a class ('[a-z]') are skipped, and so is the character after a '%' escape.
-- @param pattern the Lua pattern to inspect
-- @return the number of quantifiers in the pattern
local function count_pattern_quantifiers(pattern)
local count = 0;
local index = 1;
local length = #pattern;
local in_class = false;
while index <= length do
local char = pattern:sub(index, index);
if char == '%' then
-- '%' escapes the next character, so skip both
index = index + 2;
else
if in_class then
if char == ']' then
in_class = false;
end
elseif char == '[' then
in_class = true;
elseif char == '*' or char == '+' or char == '-' or char == '?' then
count = count + 1;
end
index = index + 1;
end
end
return count;
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);
--[[
A room claim that is flagged with context.room.regex is used as a Lua
pattern. Lua patterns backtrack, so the cost of a match grows as
(room name length) ^ (number of quantifiers). Both are limited to keep
the cost of one match bounded, because a match blocks Prosody.
Measured worst case with the defaults below is about 70ms; lowering
the quantifier limit to 2 brings that down to about 2ms.
--]]
self.regexEnabled = module:get_option_boolean('token_regex_enabled', true);
self.regexRoomMaxLength = module:get_option_number('token_regex_room_max_length', 128);
self.regexRoomMaxQuantifiers = module:get_option_number('token_regex_max_quantifiers', 3);
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 and self.cacheKeysUrl == nil then
module:log("error", "'app_secret', 'asap_key_server or 'cacheKeysUrl' must be specified");
return nil;
end
-- Set defaults for signature algorithm
if self.signatureAlgorithm == nil then
if self.asapKeyServer ~= nil or self.cacheKeysUrl 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 or self.cacheKeysUrl) 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) or {}) do
-- JWKS format
if k == "keys" and type(v) == "table" then
for _, key in ipairs(v) do
if key.kid then
self.cachedKeys[key.kid] = jwk_to_pem(key);
-- do not clean this key if it already exists
keys_to_delete[key.kid] = nil;
end
end
-- direct PEM mapping (Firebase)
elseif 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 or self.cacheKeysUrl) 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 headerPartEncoded = basexx.from_url64(session.auth_token:sub(1,dotFirst-1));
if not headerPartEncoded then return false, "not-allowed", "Invalid token" end
local header, err = json_safe.decode(headerPartEncoded);
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, "not-authorized", "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
if claims["sub"] == nil then
return false, "not-authorized", "sub claim is missing";
end
session.jitsi_meet_domain = claims["sub"];
session.jitsi_meet_auth_issuer = claims["iss"];
-- 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"]
-- the signature is verified at this point, so report the tokens
-- that ask for their room claim to be used as a pattern
if is_regex_room_claim(claims["context"]["room"]) then
measure_regex_room_claim(1);
module:log('info', 'Room claim is a pattern. tenant:%s iss:%s room:%s',
claims["context"]["tenant"], claims["iss"], claims["room"]);
end
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)
-- extract room name using all chars, except the not allowed ones
local room,_,_ = jid.split(room_address);
if room == nil then
module:log('error', 'Unable to get name of the MUC room ? to: %s', room_address);
return false, 'invalid-room-address', 'Room address is invalid';
end
local room_instance = get_room_from_jid(jid.join(room, self.muc_domain));
if not room_instance then
module:log('info', 'Room does not exists:%s', room);
return false, 'room-does-not-exist', 'Room does not exist';
end
if self.allowEmptyToken and session.auth_token == nil then
if room_instance._data.allowUnauthenticatedAccess == false then
return false, 'authentication-required', 'Authentication required';
end
return true;
end
local auth_room_is_pattern = is_regex_room_claim(session.jitsi_meet_context_room);
local auth_room = session.jitsi_meet_room;
if auth_room then
if type(auth_room) == 'string' then
-- Room names are lowercase. Lowercase a literal room claim so that
-- a token with uppercase letters still matches. Keep a pattern as
-- written: pattern classes are case-sensitive (%d vs %D).
if not auth_room_is_pattern then
auth_room = string.lower(auth_room);
end
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
if auth_room and (room ~= auth_room and not ends_with(room, ']'..auth_room)) and auth_room ~= '*' then
return false, 'room-mismatch', 'Room does not match the room from token';
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 auth_room_is_pattern then
if not self.regexEnabled then
module:log('warn', 'Room claim patterns are disabled, refusing the claim: %s', auth_room);
return false, 'invalid-regex', 'Room claim patterns are not enabled';
end
local match_target = target_room ~= nil and target_room or room_node;
if #match_target > self.regexRoomMaxLength then
module:log('warn', 'Room name is longer than %s, not matching it against a pattern: %s',
self.regexRoomMaxLength, match_target);
return false, 'invalid-regex', 'Room name is too long to match against the room claim';
end
local quantifiers = count_pattern_quantifiers(auth_room);
if quantifiers > self.regexRoomMaxQuantifiers then
module:log('warn', 'Room claim has %s quantifiers, more than the limit of %s: %s',
quantifiers, self.regexRoomMaxQuantifiers, auth_room);
return false, 'invalid-regex', 'Room claim pattern is too complex';
end
local ok, result = pcall(string.match, match_target, auth_room);
if not ok then
return false, 'invalid-regex', 'Room claim is not a valid Lua pattern';
end
room_to_check = result;
else
-- not a regex
room_to_check = auth_room;
end
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, 'room-name-does-not-match', 'Room name cannot be matched to the one from token.';
end
end
if session.jitsi_meet_str_tenant
and string.lower(session.jitsi_meet_str_tenant) ~= session.jitsi_web_query_prefix then
session.jitsi_meet_tenant_mismatch = true;
if room_instance._data.allowUnauthenticatedAccess == false then
return false;
end
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);
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, 'server-missing-config', 'Misconfiguration of server';
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;

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