diff --git a/roles/jitsi/defaults/main.yml b/roles/jitsi/defaults/main.yml index d875acd..48695c9 100644 --- a/roles/jitsi/defaults/main.yml +++ b/roles/jitsi/defaults/main.yml @@ -9,12 +9,20 @@ jitsi_user: jitsi jitsi_web_src_ip: - 0.0.0.0/0 -jitsi_jicofo_git_url: https://github.com/jitsi/jicofo.git -jitsi_jigasi_git_url: https://github.com/jitsi/jigasi.git -jitsi_meet_git_url: https://github.com/jitsi/jitsi-meet.git +jitsi_version: 9584 -# Should ansible handle upgrades, or only initial install ? -jitsi_manage_upgrade: true +jitsi_jicofo_archive_url: https://github.com/jitsi/jicofo/archive/refs/tags/stable/jitsi-meet_{{ jitsi_version }}.tar.gz +jitsi_jicofo_archive_sha256: 0be6e661a962e842704e8d2bdccfd28be21a97664883f458224e39a44679393f + +# Jigasi has no release, nor tags, so use master +jitsi_jigasi_archive_url: https://github.com/jitsi/jigasi/archive/refs/heads/master.tar.gz + +jitsi_meet_archive_url: https://github.com/jitsi/jitsi-meet/archive/refs/tags/stable/jitsi-meet_{{ jitsi_version }}.tar.gz +jitsi_meet_archive_sha256: fe52cd45159af7b9716043aae00a3f37e5874e31801261ec65d0ce9dc02c368f + +jitsi_excalidraw_version: x21 +jitsi_excalidraw_archive_url: https://github.com/jitsi/excalidraw-backend/archive/refs/tags/{{ jitsi_excalidraw_version }}.tar.gz +jitsi_excalidraw_archive_sha256: 7733c33667c7d9d022c1994c66819a57e44ca1c0dc71b3b71d9bcb10a09791f4 # XMPP server to connect to. Default is the same machine jitsi_xmpp_server: "{{ inventory_hostname }}" @@ -149,10 +157,9 @@ jitsi_meet_conf_base: disabled: true giphy: enabled: true - breakoutRooms: - hideAddRoomButton: false - hideAutoAssignButton: true - hideJoinRoomButton: false + whiteboard: + enabled: true + collabServerBaseUrl: 'https://{{ jitsi_domain }}/' jitsi_meet_conf_extra: {} jitsi_meet_conf: "{{ jitsi_meet_conf_base | combine(jitsi_meet_conf_extra, recursive=true) }}" diff --git a/roles/jitsi/files/prosody/modules/luajwtjitsi.lib.lua b/roles/jitsi/files/prosody/modules/luajwtjitsi.lib.lua new file mode 100644 index 0000000..990b1e6 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/luajwtjitsi.lib.lua @@ -0,0 +1,259 @@ +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 + return nil, "Not acceptable by exp ("..tostring(os.time()-body.exp)..")" + end + + if body.nbf and os.time() < body.nbf then + return nil, "Not acceptable by nbf" + end + + if acceptedIssuers ~= nil then + local issClaim = body.iss; + if issClaim == nil then + return nil, "'iss' claim is missing"; + end + if not verify_claim(issClaim, acceptedIssuers) then + return nil, "invalid 'iss' claim"; + end + end + + if acceptedAudiences ~= nil then + local audClaim = body.aud; + if audClaim == nil then + return nil, "'aud' claim is missing"; + end + if not verify_claim(audClaim, acceptedAudiences) then + return nil, "invalid 'aud' claim"; + end + end + + return body +end + +return M diff --git a/roles/jitsi/files/prosody/modules/mod_auth_jitsi-anonymous.lua b/roles/jitsi/files/prosody/modules/mod_auth_jitsi-anonymous.lua new file mode 100644 index 0000000..56dc511 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_auth_jitsi-anonymous.lua @@ -0,0 +1,78 @@ +-- Anonymous authentication with extras: +-- * session resumption +-- Copyright (C) 2021-present 8x8, Inc. + +local generate_uuid = require "util.uuid".generate; +local new_sasl = require "util.sasl".new; +local sasl = require "util.sasl"; +local sessions = prosody.full_sessions; + +-- define auth provider +local provider = {}; + +function provider.test_password(username, password) + return nil, "Password based auth not supported"; +end + +function provider.get_password(username) + return nil; +end + +function provider.set_password(username, password) + return nil, "Set password not supported"; +end + +function provider.user_exists(username) + return nil; +end + +function provider.create_user(username, password) + return nil; +end + +function provider.delete_user(username) + return nil; +end + +function provider.get_sasl_handler(session) + -- Custom session matching so we can resume session even with randomly + -- generated user IDs. + local function get_username(self, message) + if (session.previd ~= nil) then + for _, session1 in pairs(sessions) do + if (session1.resumption_token == session.previd) then + self.username = session1.username; + break; + end + end + else + self.username = message; + end + + return true; + end + + return new_sasl(module.host, { anonymous = get_username }); +end + +module:provides("auth", provider); + +local function anonymous(self, message) + -- Same as the vanilla anonymous auth plugin + local username = generate_uuid(); + + -- This calls the handler created in 'provider.get_sasl_handler(session)' + local result, err, msg = self.profile.anonymous(self, username, self.realm); + + if result == true then + if (self.username == nil) then + -- Session was not resumed + self.username = username; + end + return "success"; + else + return "failure", err, msg; + end +end + +sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous); diff --git a/roles/jitsi/files/prosody/modules/mod_auth_jitsi-shared-secret.lua b/roles/jitsi/files/prosody/modules/mod_auth_jitsi-shared-secret.lua new file mode 100644 index 0000000..65d0fc7 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_auth_jitsi-shared-secret.lua @@ -0,0 +1,65 @@ +-- Authentication with shared secret where the username is ignored +-- Copyright (C) 2023-present 8x8, Inc. + +local new_sasl = require "util.sasl".new; +local saslprep = require "util.encodings".stringprep.saslprep; +local secure_equals = require "util.hashes".equals; + +local shared_secret = module:get_option_string('shared_secret'); +local shared_secret_prev = module:get_option_string('shared_secret_prev'); +if shared_secret == nil then + module:log('error', 'No shared_secret specified. No secret to operate on!'); + return; +end + +module:depends("jitsi_session"); + +-- define auth provider +local provider = {}; + +function provider.test_password(username, password) + password = saslprep(password); + if not password then + return nil, "Password fails SASLprep."; + end + + if secure_equals(password, saslprep(shared_secret)) then + return true; + elseif (shared_secret_prev ~= nil and secure_equals(password, saslprep(shared_secret_prev))) then + module:log("info", "Accepting login using previous shared secret, username=%s", username); + return true; + else + return nil, "Auth failed. Invalid username or password."; + end +end + +function provider.get_password(username) + return shared_secret; +end + +function provider.set_password(username, password) + return nil, "Set password not supported"; +end + +function provider.user_exists(username) + return true; -- all usernames exist +end + +function provider.create_user(username, password) + return nil; +end + +function provider.delete_user(username) + return nil; +end + +function provider.get_sasl_handler(session) + local getpass_authentication_profile = { + plain = function(_, username, realm) + return shared_secret, true; + end + }; + return new_sasl(module.host, getpass_authentication_profile); +end + +module:provides("auth", provider); diff --git a/roles/jitsi/files/prosody/modules/mod_auth_ldap.lua b/roles/jitsi/files/prosody/modules/mod_auth_ldap.lua new file mode 100644 index 0000000..2b496cd --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_auth_ldap.lua @@ -0,0 +1,125 @@ +-- mod_auth_ldap + +local new_sasl = require "util.sasl".new; +local lualdap = require "lualdap"; +local function ldap_filter_escape(s) return (s:gsub("[*()\\%z]", function(c) return ("\\%02x"):format(c:byte()) end)); end + +-- Config options +local ldap_server = module:get_option_string("ldap_server", "localhost"); +local ldap_rootdn = module:get_option_string("ldap_rootdn", ""); +local ldap_password = module:get_option_string("ldap_password", ""); +local ldap_tls = module:get_option_boolean("ldap_tls"); +local ldap_scope = module:get_option_string("ldap_scope", "onelevel"); +local ldap_filter = module:get_option_string("ldap_filter", "(uid=$user)"):gsub("%%s", "$user", 1); +local ldap_base = assert(module:get_option_string("ldap_base"), "ldap_base is a required option for ldap"); +local ldap_mode = module:get_option_string("ldap_mode", "bind"); +local host = ldap_filter_escape(module:get_option_string("realm", module.host)); + +-- Initiate connection +local ld = nil; +module.unload = function() if ld then pcall(ld, ld.close); end end + +function ldap_do_once(method, ...) + if ld == nil then + local err; + ld, err = lualdap.open_simple(ldap_server, ldap_rootdn, ldap_password, ldap_tls); + if not ld then return nil, err, "reconnect"; end + end + + local success, iterator, invariant, initial = pcall(ld[method], ld, ...); + if not success then ld = nil; return nil, iterator, "search"; end + + local success, dn, attr = pcall(iterator, invariant, initial); + if not success then ld = nil; return success, dn, "iter"; end + + return dn, attr, "return"; +end + +function ldap_do(method, retry_count, ...) + local dn, attr, where; + for i=1,1+retry_count do + dn, attr, where = ldap_do_once(method, ...); + if dn or not(attr) then break; end -- nothing or something found + module:log("warn", "LDAP: %s %s (in %s)", tostring(dn), tostring(attr), where); + -- otherwise retry + end + if not dn and attr then + module:log("error", "LDAP: %s", tostring(attr)); + end + return dn, attr; +end + +local function get_user(username) + module:log("debug", "get_user(%q)", username); + return ldap_do("search", 2, { + base = ldap_base; + scope = ldap_scope; + sizelimit = 1; + filter = ldap_filter:gsub("%$(%a+)", { + user = ldap_filter_escape(username); + host = host; + }); + }); +end + +local provider = {}; + +function provider.create_user(username, password) + return nil, "Account creation not available with LDAP."; +end + +function provider.user_exists(username) + return not not get_user(username); +end + +function provider.set_password(username, password) + local dn, attr = get_user(username); + if not dn then return nil, attr end + if attr.userPassword == password then return true end + return ldap_do("modify", 2, dn, { '=', userPassword = password }); +end + +if ldap_mode == "getpasswd" then + function provider.get_password(username) + local dn, attr = get_user(username); + if dn and attr then + return attr.userPassword; + end + end + + function provider.test_password(username, password) + return provider.get_password(username) == password; + end + + function provider.get_sasl_handler() + return new_sasl(module.host, { + plain = function(sasl, username) + local password = provider.get_password(username); + if not password then return "", nil; end + return password, true; + end + }); + end +elseif ldap_mode == "bind" then + local function test_password(userdn, password) + return not not lualdap.open_simple(ldap_server, userdn, password, ldap_tls); + end + + function provider.test_password(username, password) + local dn = get_user(username); + if not dn then return end + return test_password(dn, password) + end + + function provider.get_sasl_handler() + return new_sasl(module.host, { + plain_test = function(sasl, username, password) + return provider.test_password(username, password), true; + end + }); + end +else + module:log("error", "Unsupported ldap_mode %s", tostring(ldap_mode)); +end + +module:provides("auth", provider); diff --git a/roles/jitsi/files/prosody/modules/mod_auth_token.lua b/roles/jitsi/files/prosody/modules/mod_auth_token.lua new file mode 100644 index 0000000..562be90 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_auth_token.lua @@ -0,0 +1,167 @@ +-- Token authentication +-- Copyright (C) 2021-present 8x8, Inc. + +local formdecode = require "util.http".formdecode; +local generate_uuid = require "util.uuid".generate; +local new_sasl = require "util.sasl".new; +local sasl = require "util.sasl"; +local token_util = module:require "token/util".new(module); +local sessions = prosody.full_sessions; + +-- no token configuration +if token_util == nil then + return; +end + +module:depends("jitsi_session"); + +local measure_pre_fetch_fail = module:measure('pre_fetch_fail', 'counter'); +local measure_verify_fail = module:measure('verify_fail', 'counter'); +local measure_success = module:measure('success', 'counter'); +local measure_ban = module:measure('ban', 'counter'); +local measure_post_auth_fail = module:measure('post_auth_fail', 'counter'); + +-- define auth provider +local provider = {}; + +local host = module.host; + +-- Extract 'token' param from URL when session is created +function init_session(event) + local session, request = event.session, event.request; + local query = request.url.query; + + local token = nil; + + -- extract token from Authorization header + if request.headers["authorization"] then + -- assumes the header value starts with "Bearer " + token = request.headers["authorization"]:sub(8,#request.headers["authorization"]) + end + + -- allow override of token via query parameter + if query ~= nil then + local params = formdecode(query); + + -- The following fields are filled in the session, by extracting them + -- from the query and no validation is being done. + -- After validating auth_token will be cleaned in case of error and few + -- other fields will be extracted from the token and set in the session + + if query and params.token then + token = params.token; + end + end + + -- in either case set auth_token in the session + session.auth_token = token; +end + +module:hook_global("bosh-session", init_session); +module:hook_global("websocket-session", init_session); + +function provider.test_password(username, password) + return nil, "Password based auth not supported"; +end + +function provider.get_password(username) + return nil; +end + +function provider.set_password(username, password) + return nil, "Set password not supported"; +end + +function provider.user_exists(username) + return nil; +end + +function provider.create_user(username, password) + return nil; +end + +function provider.delete_user(username) + return nil; +end + +function provider.get_sasl_handler(session) + + local function get_username_from_token(self, message) + + -- retrieve custom public key from server and save it on the session + local pre_event_result = prosody.events.fire_event("pre-jitsi-authentication-fetch-key", session); + if pre_event_result ~= nil and pre_event_result.res == false then + module:log("warn", + "Error verifying token on pre authentication stage:%s, reason:%s", pre_event_result.error, pre_event_result.reason); + session.auth_token = nil; + measure_pre_fetch_fail(1); + return pre_event_result.res, pre_event_result.error, pre_event_result.reason; + end + + local res, error, reason = token_util:process_and_verify_token(session); + if res == false then + module:log("warn", + "Error verifying token err:%s, reason:%s tenant:%s room:%s", + error, reason, session.jitsi_web_query_prefix, session.jitsi_web_query_room); + session.auth_token = nil; + measure_verify_fail(1); + return res, error, reason; + end + + local shouldAllow = prosody.events.fire_event("jitsi-access-ban-check", session); + if shouldAllow == false then + module:log("warn", "user is banned") + measure_ban(1); + return false, "not-allowed", "user is banned"; + end + + local customUsername = prosody.events.fire_event("pre-jitsi-authentication", session); + if customUsername then + self.username = customUsername; + elseif session.previd ~= nil then + for _, session1 in pairs(sessions) do + if (session1.resumption_token == session.previd) then + self.username = session1.username; + break; + end + end + else + self.username = message; + end + + local post_event_result = prosody.events.fire_event("post-jitsi-authentication", session); + if post_event_result ~= nil and post_event_result.res == false then + module:log("warn", + "Error verifying token on post authentication stage :%s, reason:%s", post_event_result.error, post_event_result.reason); + session.auth_token = nil; + measure_post_auth_fail(1); + return post_event_result.res, post_event_result.error, post_event_result.reason; + end + + measure_success(1); + return res; + end + + return new_sasl(host, { anonymous = get_username_from_token }); +end + +module:provides("auth", provider); + +local function anonymous(self, message) + + local username = generate_uuid(); + + -- This calls the handler created in 'provider.get_sasl_handler(session)' + local result, err, msg = self.profile.anonymous(self, username, self.realm); + + if result == true then + if (self.username == nil) then + self.username = username; + end + return "success"; + else + return "failure", err, msg; + end + end + +sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous); diff --git a/roles/jitsi/files/prosody/modules/mod_av_moderation.lua b/roles/jitsi/files/prosody/modules/mod_av_moderation.lua new file mode 100644 index 0000000..4dce74e --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_av_moderation.lua @@ -0,0 +1,6 @@ +local avmoderation_component = module:get_option_string('av_moderation_component', 'avmoderation.'..module.host); + +-- Advertise AV Moderation so client can pick up the address and use it +module:add_identity('component', 'av_moderation', avmoderation_component); + +module:depends("jitsi_session"); diff --git a/roles/jitsi/files/prosody/modules/mod_av_moderation_component.lua b/roles/jitsi/files/prosody/modules/mod_av_moderation_component.lua new file mode 100644 index 0000000..efa61a4 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_av_moderation_component.lua @@ -0,0 +1,331 @@ +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 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 + +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 = 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); + body_json.whitelists = room.av_moderation; + if removed then + body_json.removed = true; + end + body_json.mediaType = mediaType; + 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 + +-- receives messages from clients to the component sending A/V moderation enable/disable commands or adding +-- jids to the whitelist +function on_message(event) + local session = event.origin; + + -- Check the type of the incoming stanza to avoid loops: + if event.stanza.attr.type == 'error' then + return; -- We do not want to reply to these, so leave. + end + + if not session or not session.jitsi_web_query_room then + return false; + end + + local moderation_command = event.stanza:get_child('av_moderation'); + + if moderation_command then + -- get room name with tenant and find room + local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix); + + if not room then + module:log('warn', 'No room found found for %s/%s', + session.jitsi_web_query_prefix, session.jitsi_web_query_room); + return false; + end + + -- check that the participant requesting is a moderator and is an occupant in the room + local from = event.stanza.attr.from; + local occupant = room:get_occupant_by_real_jid(from); + if not occupant then + module:log('warn', 'No occupant %s found for %s', from, room.jid); + return false; + end + if occupant.role ~= 'moderator' then + module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid); + return false; + end + + local mediaType = moderation_command.attr.mediaType; + if mediaType then + if mediaType ~= 'audio' and mediaType ~= 'video' 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 + if not room.av_moderation then + room.av_moderation = {}; + room.av_moderation_actors = {}; + end + room.av_moderation[mediaType] = array{}; + room.av_moderation_actors[mediaType] = occupant.nick; + 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; + + -- clears room.av_moderation if empty + 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); + 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) then + return; + end + + if room.av_moderation then + for _,mediaType in pairs({'audio', 'video'}) 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 moderator send the whitelist + if room_occupant.nick == occupant.nick and room_occupant.role == 'moderator' then + notify_whitelist_change(room_occupant.jid, false, room); + end + end + end +end + +-- when a occupant was granted moderator we need to update him with the whitelist +function occupant_affiliation_changed(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 + if event.actor and event.affiliation == 'owner' and event.room.av_moderation then + local room = event.room; + -- event.jid is the bare jid of participant + for _, occupant in room:each_occupant() do + if occupant.bare_jid == event.jid then + notify_whitelist_change(occupant.jid, false, room); + 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); diff --git a/roles/jitsi/files/prosody/modules/mod_certs_s2soutinjection.lua b/roles/jitsi/files/prosody/modules/mod_certs_s2soutinjection.lua new file mode 100644 index 0000000..7de57d2 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_certs_s2soutinjection.lua @@ -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); diff --git a/roles/jitsi/files/prosody/modules/mod_client_proxy.lua b/roles/jitsi/files/prosody/modules/mod_client_proxy.lua new file mode 100644 index 0000000..93ca767 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_client_proxy.lua @@ -0,0 +1,202 @@ +if module:get_host_type() ~= "component" then + error("proxy_component should be loaded as component", 0); +end + +local jid_split = require "util.jid".split; +local jid_bare = require "util.jid".bare; +local jid_prep = require "util.jid".prep; +local st = require "util.stanza"; +local array = require "util.array"; + +local target_address = module:get_option_string("target_address"); + +sessions = array{}; +local sessions = sessions; + +local function handle_target_presence(stanza) + local type = stanza.attr.type; + module:log("debug", "received presence from destination: %s", type) + local _, _, resource = jid_split(stanza.attr.from); + if type == "error" then + -- drop all known sessions + for k in pairs(sessions) do + sessions[k] = nil + end + module:log( + "debug", + "received error presence, dropping all target sessions", + resource + ) + elseif type == "unavailable" then + for k in pairs(sessions) do + if sessions[k] == resource then + sessions[k] = nil + module:log( + "debug", + "dropped target session: %s", + resource + ) + break + end + end + elseif not type then + -- available + local found = false; + for k in pairs(sessions) do + if sessions[k] == resource then + found = true; + break + end + end + if not found then + module:log( + "debug", + "registered new target session: %s", + resource + ) + sessions:push(resource) + end + end +end + +local function handle_from_target(stanza) + local type = stanza.attr.type + module:log( + "debug", + "non-presence stanza from target: name = %s, type = %s", + stanza.name, + type + ) + if stanza.name == "iq" then + if type == "error" or type == "result" then + -- de-NAT message + local _, _, denatted_to_unprepped = jid_split(stanza.attr.to); + local denatted_to = jid_prep(denatted_to_unprepped); + if not denatted_to then + module:log( + "debug", + "cannot de-NAT stanza, invalid to: %s", + denatted_to_unprepped + ) + return + end + local denatted_from = module:get_host(); + + module:log( + "debug", + "de-NAT-ed stanza: from: %s -> %s, to: %s -> %s", + stanza.attr.from, + denatted_from, + stanza.attr.to, + denatted_to + ) + + stanza.attr.from = denatted_from + stanza.attr.to = denatted_to + + module:send(stanza) + else + -- FIXME: we 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) diff --git a/roles/jitsi/files/prosody/modules/mod_conference_duration.lua b/roles/jitsi/files/prosody/modules/mod_conference_duration.lua new file mode 100644 index 0000000..43f4511 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_conference_duration.lua @@ -0,0 +1,48 @@ +local it = require "util.iterators"; +local process_host_module = module:require "util".process_host_module; + +local main_muc_component_config = module:get_option_string('main_muc'); +if main_muc_component_config == nil then + module:log('error', 'lobby not enabled missing main_muc config'); + return ; +end + +-- Returns the meeting created timestamp form data. +function getMeetingCreatedTSConfig(room) + return { + name = "muc#roominfo_created_timestamp"; + type = "text-single"; + label = "The meeting created_timestamp."; + value = room.created_timestamp or ""; + }; +end + +function occupant_joined(event) + local room = event.room; + local occupant = event.occupant; + + local participant_count = it.count(room:each_occupant()); + + if participant_count > 1 then + if room.created_timestamp == nil then + room.created_timestamp = string.format('%i', os.time() * 1000); -- Lua provides UTC time in seconds, so convert to milliseconds + end + end +end + +process_host_module(main_muc_component_config, function(host_module, host) + -- add meeting Id to the disco info requests to the room + host_module:hook("muc-disco#info", function(event) + table.insert(event.form, getMeetingCreatedTSConfig(event.room)); + end); + + -- Marks the created timestamp in the room object + host_module:hook("muc-occupant-joined", occupant_joined, -1); +end); + +-- DEPRECATED and will be removed, giving time for mobile clients to update +local conference_duration_component + = module:get_option_string("conference_duration_component", "conferenceduration."..module.host); +if conference_duration_component then + module:add_identity("component", "conference_duration", conference_duration_component); +end diff --git a/roles/jitsi/files/prosody/modules/mod_conference_duration_component.lua b/roles/jitsi/files/prosody/modules/mod_conference_duration_component.lua new file mode 100644 index 0000000..d643dc2 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_conference_duration_component.lua @@ -0,0 +1,47 @@ +-- DEPRECATED and will be removed, giving time for mobile clients to update +local st = require "util.stanza"; +local socket = require "socket"; +local json = require 'cjson.safe'; +local it = require "util.iterators"; +local process_host_module = module:require "util".process_host_module; + +-- we use async to detect Prosody 0.10 and earlier +local have_async = pcall(require, "util.async"); +if not have_async then + module:log("warn", "conference duration will not work with Prosody version 0.10 or less."); + return; +end + +local muc_component_host = module:get_option_string("muc_component"); +if muc_component_host == nil then + module:log("error", "No muc_component specified. No muc to operate on!"); + return; +end + +module:log("info", "Starting conference duration timer for %s", muc_component_host); + +function occupant_joined(event) + local room = event.room; + local occupant = event.occupant; + + local participant_count = it.count(room:each_occupant()); + + if participant_count > 1 then + local body_json = {}; + body_json.type = 'conference_duration'; + body_json.created_timestamp = room.created_timestamp; + + local stanza = st.message({ + from = module.host; + to = occupant.jid; + }) + :tag("json-message", {xmlns='http://jitsi.org/jitmeet'}) + :text(json.encode(body_json)):up(); + + room:route_stanza(stanza); + end +end + +process_host_module(muc_component_host, function(host_module, host) + host_module:hook("muc-occupant-joined", occupant_joined, -1); +end); diff --git a/roles/jitsi/files/prosody/modules/mod_debug_traceback.lua b/roles/jitsi/files/prosody/modules/mod_debug_traceback.lua new file mode 100644 index 0000000..5c021b5 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_debug_traceback.lua @@ -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 diff --git a/roles/jitsi/files/prosody/modules/mod_end_conference.lua b/roles/jitsi/files/prosody/modules/mod_end_conference.lua new file mode 100644 index 0000000..5ebda32 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_end_conference.lua @@ -0,0 +1,86 @@ +-- This module is added under the main virtual host domain +-- +-- VirtualHost "jitmeet.example.com" +-- modules_enabled = { +-- "end_conference" +-- } +-- end_conference_component = "endconference.jitmeet.example.com" +-- +-- Component "endconference.jitmeet.example.com" "end_conference" +-- muc_component = muc.jitmeet.example.com +-- +local get_room_by_name_and_subdomain = module:require 'util'.get_room_by_name_and_subdomain; + +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 +local end_conference_component = module:get_option_string('end_conference_component'); +if end_conference_component then + -- Advertise end conference so client can pick up the address and use it + module:add_identity('component', 'end_conference', end_conference_component); + 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 + +module:log('info', 'Starting end_conference for %s', muc_component_host); + +-- receives messages from clients to the component to end a conference +function on_message(event) + local session = event.origin; + + -- Check the type of the incoming stanza to avoid loops: + if event.stanza.attr.type == 'error' then + return; -- We do not want to reply to these, so leave. + end + + if not session or not session.jitsi_web_query_room then + return false; + end + + local moderation_command = event.stanza:get_child('end_conference'); + + if moderation_command then + -- get room name with tenant and find room + local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix); + + if not room then + module:log('warn', 'No room found found for %s/%s', + session.jitsi_web_query_prefix, session.jitsi_web_query_room); + return false; + end + + -- check that the participant requesting is a moderator and is an occupant in the room + local from = event.stanza.attr.from; + local occupant = room:get_occupant_by_real_jid(from); + if not occupant then + module:log('warn', 'No occupant %s found for %s', from, room.jid); + return false; + end + if occupant.role ~= 'moderator' then + module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid); + return false; + end + + -- destroy the room + room:destroy(nil, END_CONFERENCE_REASON); + module:log('info', 'Room %s destroyed by occupant %s', room.jid, from); + return true; + end + + -- return error + return false +end + + +-- we will receive messages from the clients +module:hook('message/host', on_message); diff --git a/roles/jitsi/files/prosody/modules/mod_external_services.lua b/roles/jitsi/files/prosody/modules/mod_external_services.lua new file mode 100644 index 0000000..84c157f --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_external_services.lua @@ -0,0 +1,232 @@ + +local dt = require "util.datetime"; +local base64 = require "util.encodings".base64; +local hashes = require "util.hashes"; +local st = require "util.stanza"; +local jid = require "util.jid"; +local array = require "util.array"; +local set = require "util.set"; + +local default_host = module:get_option_string("external_service_host", module.host); +local default_port = module:get_option_number("external_service_port"); +local default_secret = module:get_option_string("external_service_secret"); +local default_ttl = module:get_option_number("external_service_ttl", 86400); + +local configured_services = module:get_option_array("external_services", {}); + +local access = module:get_option_set("external_service_access", {}); + +-- https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 +local function behave_turn_rest_credentials(srv, item, secret) + local ttl = default_ttl; + if type(item.ttl) == "number" then + ttl = item.ttl; + end + local expires = srv.expires or os.time() + ttl; + local username; + if type(item.username) == "string" then + username = string.format("%d:%s", expires, item.username); + else + username = string.format("%d", expires); + end + srv.username = username; + srv.password = base64.encode(hashes.hmac_sha1(secret, srv.username)); +end + +local algorithms = { + turn = behave_turn_rest_credentials; +} + +-- filter config into well-defined service records +local function prepare(item) + if type(item) ~= "table" then + module:log("error", "Service definition is not a table: %q", item); + return nil; + end + + local srv = { + type = nil; + transport = nil; + host = default_host; + port = default_port; + username = nil; + password = nil; + restricted = nil; + expires = nil; + }; + + if type(item.type) == "string" then + srv.type = item.type; + else + module:log("error", "Service missing mandatory 'type' field: %q", item); + return nil; + end + if type(item.transport) == "string" then + srv.transport = item.transport; + end + if type(item.host) == "string" then + srv.host = item.host; + end + if type(item.port) == "number" then + srv.port = item.port; + end + if type(item.username) == "string" then + srv.username = item.username; + end + if type(item.password) == "string" then + srv.password = item.password; + srv.restricted = true; + end + if item.restricted == true then + srv.restricted = true; + end + if type(item.expires) == "number" then + srv.expires = item.expires; + elseif type(item.ttl) == "number" then + srv.expires = os.time() + item.ttl; + end + if (item.secret == true and default_secret) or type(item.secret) == "string" then + local secret_cb = item.credentials_cb or algorithms[item.algorithm] or algorithms[srv.type]; + local secret = item.secret; + if secret == true then + secret = default_secret; + end + if secret_cb then + secret_cb(srv, item, secret); + srv.restricted = true; + end + end + return srv; +end + +function module.load() + -- Trigger errors on startup + local services = configured_services / prepare; + if #services == 0 then + module:log("warn", "No services configured or all had errors"); + end +end + +-- Ensure only valid items are added in events +local services_mt = { + __index = getmetatable(array()).__index; + __newindex = function (self, i, v) + rawset(self, i, assert(prepare(v), "Invalid service entry added")); + end; +} + +function get_services() + local extras = module:get_host_items("external_service"); + local services = ( configured_services + extras ) / prepare; + + setmetatable(services, services_mt); + + return services; +end + +function services_xml(services, name, namespace) + local reply = st.stanza(name or "services", { xmlns = namespace or "urn:xmpp:extdisco:2" }); + + for _, srv in ipairs(services) do + reply:tag("service", { + 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; + }):up(); + end + + return reply; +end + +local function handle_services(event) + local origin, stanza = event.origin, event.stanza; + local action = stanza.tags[1]; + + local user_bare = jid.bare(stanza.attr.from); + local user_host = jid.host(user_bare); + if not ((access:empty() and origin.type == "c2s") or access:contains(user_bare) or access:contains(user_host)) then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end + + local services = get_services(); + + local requested_type = action.attr.type; + if requested_type then + services:filter(function(item) + return item.type == requested_type; + end); + end + + module:fire_event("external_service/services", { + origin = origin; + stanza = stanza; + requested_type = requested_type; + services = services; + }); + + local reply = st.reply(stanza):add_child(services_xml(services, action.name, action.attr.xmlns)); + + origin.send(reply); + return true; +end + +local function handle_credentials(event) + local origin, stanza = event.origin, event.stanza; + local action = stanza.tags[1]; + + if origin.type ~= "c2s" then + origin.send(st.error_reply(stanza, "auth", "forbidden", "The 'port' and 'type' attributes are required.")); + return true; + end + + local services = get_services(); + services:filter(function (item) + return item.restricted; + end) + + local requested_credentials = set.new(); + for service in action:childtags("service") do + if not service.attr.type or not service.attr.host then + origin.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + end + + requested_credentials:add(string.format("%s:%s:%d", service.attr.type, service.attr.host, + tonumber(service.attr.port) or 0)); + end + + module:fire_event("external_service/credentials", { + origin = origin; + stanza = stanza; + requested_credentials = requested_credentials; + services = services; + }); + + services:filter(function (srv) + local port_key = string.format("%s:%s:%d", srv.type, srv.host, srv.port or 0); + local portless_key = string.format("%s:%s:%d", srv.type, srv.host, 0); + return requested_credentials:contains(port_key) or requested_credentials:contains(portless_key); + end); + + local reply = st.reply(stanza):add_child(services_xml(services, action.name, action.attr.xmlns)); + + origin.send(reply); + return true; +end + +-- XEP-0215 v0.7 +module:add_feature("urn:xmpp:extdisco:2"); +module:hook("iq-get/host/urn:xmpp:extdisco:2:services", handle_services); +module:hook("iq-get/host/urn:xmpp:extdisco:2:credentials", handle_credentials); + +-- COMPAT XEP-0215 v0.6 +-- Those still on the old version gets to deal with undefined attributes until they upgrade. +module:add_feature("urn:xmpp:extdisco:1"); +module:hook("iq-get/host/urn:xmpp:extdisco:1:services", handle_services); +module:hook("iq-get/host/urn:xmpp:extdisco:1:credentials", handle_credentials); diff --git a/roles/jitsi/files/prosody/modules/mod_filter_iq_jibri.lua b/roles/jitsi/files/prosody/modules/mod_filter_iq_jibri.lua new file mode 100644 index 0000000..b56cf54 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_filter_iq_jibri.lua @@ -0,0 +1,27 @@ +local st = require "util.stanza"; +local is_feature_allowed = module:require "util".is_feature_allowed; + +-- 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; + + if jibri.attr.action == 'start' then + if token == nil + or not is_feature_allowed(session.jitsi_meet_context_features, + (jibri.attr.recording_mode == 'file' and 'recording' or 'livestreaming') + ) then + module:log("info", + "Filtering jibri start recording, stanza:%s", tostring(stanza)); + session.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end + end + end + end +end); diff --git a/roles/jitsi/files/prosody/modules/mod_filter_iq_rayo.lua b/roles/jitsi/files/prosody/modules/mod_filter_iq_rayo.lua new file mode 100644 index 0000000..86bd0cc --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_filter_iq_rayo.lua @@ -0,0 +1,261 @@ +local new_throttle = require "util.throttle".create; +local st = require "util.stanza"; + +local token_util = module:require "token/util".new(module); +local util = module:require 'util'; +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 is_healthcheck_room = util.is_healthcheck_room; +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; + +-- no token configuration but required +if token_util == nil then + module:log("error", "no token configuration but it is required"); + return; +end + +local um_is_admin = require 'core.usermanager'.is_admin; +local function is_admin(jid) + return um_is_admin(jid, module.host); +end + +-- The maximum number of simultaneous calls, +-- and also the maximum number of new calls per minute that a session is allowed to create. +local limit_outgoing_calls; +local function load_config() + limit_outgoing_calls = module:get_option_number("max_number_outgoing_calls", -1); +end +load_config(); + +-- Header names to use to push extra data extracted from token, if any +local OUT_INITIATOR_USER_ATTR_NAME = "X-outbound-call-initiator-user"; +local OUT_INITIATOR_GROUP_ATTR_NAME = "X-outbound-call-initiator-group"; +local OUTGOING_CALLS_THROTTLE_INTERVAL = 60; -- if max_number_outgoing_calls is enabled it will be + -- the max number of outgoing calls a user can try for a minute + +-- filters rayo iq in case of requested from not jwt authenticated sessions +-- or if the session has features in user context and it doesn't mention +-- feature "outbound-call" to be enabled +module:hook("pre-iq/full", function(event) + local stanza = event.stanza; + if stanza.name == "iq" then + local dial = stanza:get_child('dial', 'urn:xmpp:rayo:1'); + if dial then + local session = event.origin; + local token = session.auth_token; + + -- find header with attr name 'JvbRoomName' and extract its value + local headerName = 'JvbRoomName'; + local roomName; + for _, child in ipairs(dial.tags) do + if (child.name == 'header' + and child.attr.name == headerName) then + roomName = child.attr.value; + break; + end + end + + local feature = dial.attr.to == 'jitsi_meet_transcribe' and 'transcription' or 'outbound-call'; + local is_session_allowed = is_feature_allowed(session.jitsi_meet_context_features, feature); + + -- if current user is not allowed, but was granted moderation by a user + -- that is allowed by its features we want to allow it + local is_granting_session_allowed = false; + if (session.granted_jitsi_meet_context_features) then + is_granting_session_allowed = is_feature_allowed(session.granted_jitsi_meet_context_features, feature); + end + + if (token == nil + or roomName == nil + or not token_util:verify_room(session, room_jid_match_rewrite(roomName)) + or not (is_session_allowed or is_granting_session_allowed)) + then + module:log("warn", "Filtering stanza dial, stanza:%s", tostring(stanza)); + session.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end + + -- we get current user_id or group, or the one from the granted one + -- so guests and the user that granted rights are sharing same limit, as guest can be without token + local user_id, group_id = nil, session.jitsi_meet_context_group; + if session.jitsi_meet_context_user then + user_id = session.jitsi_meet_context_user["id"]; + else + user_id = session.granted_jitsi_meet_context_user_id; + group_id = session.granted_jitsi_meet_context_group_id; + end + + -- now lets check any limits if configured + if limit_outgoing_calls > 0 then + if not session.dial_out_throttle then + -- module:log("debug", "Enabling dial-out throttle session=%s.", session); + session.dial_out_throttle = new_throttle(limit_outgoing_calls, OUTGOING_CALLS_THROTTLE_INTERVAL); + end + + if not session.dial_out_throttle:poll(1) -- we first check the throttle so we can mark one incoming dial for the balance + or get_concurrent_outgoing_count(user_id, group_id) >= limit_outgoing_calls + then + module:log("warn", + "Filtering stanza dial, stanza:%s, outgoing calls limit reached", tostring(stanza)); + measure_drop(1); + session.send(st.error_reply(stanza, "cancel", "resource-constraint")); + return true; + end + end + + -- now lets insert token information if any + if session and user_id then + -- First remove any 'header' element if it already + -- exists, so it cannot be spoofed by a client + stanza:maptags( + function(tag) + if tag.name == "header" + and (tag.attr.name == OUT_INITIATOR_USER_ATTR_NAME + or tag.attr.name == OUT_INITIATOR_GROUP_ATTR_NAME) then + return nil + end + return tag + end + ) + + local dial = stanza:get_child('dial', 'urn:xmpp:rayo:1'); + -- adds initiator user id from token + dial:tag("header", { + xmlns = "urn:xmpp:rayo:1", + name = OUT_INITIATOR_USER_ATTR_NAME, + 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 = session.jitsi_meet_context_group }); + dial:up(); + end + end + end + end +end); + +--- 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_set_affiliation(event) + local actor, affiliation, jid, previous_affiliation, room + = event.actor, event.affiliation, event.jid, event.previous_affiliation, event.room; + local actor_session = sessions[actor]; + + if is_admin(jid) or is_healthcheck_room(room.jid) or not actor or not previous_affiliation + or not actor_session or not actor_session.jitsi_meet_context_features then + return; + end + + local occupant; + for _, o in room:each_occupant() do + if o.bare_jid == jid then + occupant = o; + end + end + + if not occupant then + return; + end + + local occupant_session = sessions[occupant.jid]; + if not occupant_session then + return; + end + + if previous_affiliation == 'none' and affiliation == 'owner' then + occupant_session.granted_jitsi_meet_context_features = actor_session.jitsi_meet_context_features; + occupant_session.granted_jitsi_meet_context_user_id = actor_session.jitsi_meet_context_user["id"]; + occupant_session.granted_jitsi_meet_context_group_id = actor_session.jitsi_meet_context_group; + elseif previous_affiliation == 'owner' and ( affiliation == 'member' or affiliation == 'none' ) then + occupant_session.granted_jitsi_meet_context_features = nil; + occupant_session.granted_jitsi_meet_context_user_id = nil; + occupant_session.granted_jitsi_meet_context_group_id = nil; + end +end + +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_host); + host_module:hook("muc-pre-set-affiliation", process_set_affiliation); +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); diff --git a/roles/jitsi/files/prosody/modules/mod_firewall/actions.lib.lua b/roles/jitsi/files/prosody/modules/mod_firewall/actions.lib.lua new file mode 100644 index 0000000..97ac873 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_firewall/actions.lib.lua @@ -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; diff --git a/roles/jitsi/files/prosody/modules/mod_firewall/conditions.lib.lua b/roles/jitsi/files/prosody/modules/mod_firewall/conditions.lib.lua new file mode 100644 index 0000000..e7cfb0e --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_firewall/conditions.lib.lua @@ -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; diff --git a/roles/jitsi/files/prosody/modules/mod_firewall/definitions.lib.lua b/roles/jitsi/files/prosody/modules/mod_firewall/definitions.lib.lua new file mode 100644 index 0000000..a35ba80 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_firewall/definitions.lib.lua @@ -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:/', 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; diff --git a/roles/jitsi/files/prosody/modules/mod_firewall/marks.lib.lua b/roles/jitsi/files/prosody/modules/mod_firewall/marks.lib.lua new file mode 100644 index 0000000..3c9bbb0 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_firewall/marks.lib.lua @@ -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); diff --git a/roles/jitsi/files/prosody/modules/mod_firewall/mod_firewall.lua b/roles/jitsi/files/prosody/modules/mod_firewall/mod_firewall.lua new file mode 100644 index 0000000..9af541e --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_firewall/mod_firewall.lua @@ -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 = ""; + 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: +-- +-- +-- function handler() +-- +-- if then +-- +-- 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 ""); + 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 ]], [[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 diff --git a/roles/jitsi/files/prosody/modules/mod_firewall/test.lib.lua b/roles/jitsi/files/prosody/modules/mod_firewall/test.lib.lua new file mode 100644 index 0000000..a72b021 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_firewall/test.lib.lua @@ -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(""); + 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 diff --git a/roles/jitsi/files/prosody/modules/mod_fmuc.lua b/roles/jitsi/files/prosody/modules/mod_fmuc.lua new file mode 100644 index 0000000..496c514 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_fmuc.lua @@ -0,0 +1,623 @@ +--- 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 util = module:require 'util'; +local ends_with = util.ends_with; +local is_vpaas = util.is_vpaas; +local room_jid_match_rewrite = util.room_jid_match_rewrite; +local get_room_from_jid = util.get_room_from_jid; +local get_focus_occupant = util.get_focus_occupant; +local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite; +local presence_check_status = util.presence_check_status; + +-- 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'; + +-- 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 um_is_admin = require 'core.usermanager'.is_admin; +local function is_admin(jid) + return um_is_admin(jid, module.host); +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); + + if prosody.hosts[host] and not is_admin(occupant.bare_jid) then + 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 + 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('jitsi_participant_raisedHand'); + + -- 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 + +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 + +-- 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 + module:log('info', 'No focus found for %s', room.jid); + 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 +end); + +-- forward visitor presences to jicofo +-- detects raise hand in visitors presence, this is request for promotion +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('jitsi_participant_raisedHand'); + -- a promotion detected let's send it to main prosody + if raiseHand then + local user_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; + + if room._data.moderator_id then + if room._data.moderator_id == user_id then + is_moderator = true; + end + elseif session.auth_token then + -- non-vpass and having a token is considered a moderator + is_moderator = true; + 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, + 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 + + 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 + module:log('warn', 'No room found %s', room_jid); + return; + end + + local request_promotion = visitors_iq:get_child('promotion-response'); + if not request_promotion then + return; + end + + -- respond with successful receiving the iq + origin.send(st.iq({ + type = 'result'; + from = stanza.attr.to; + to = stanza.attr.from; + id = stanza.attr.id + })); + + local req_jid = request_promotion.attr.jid; + -- now let's find the occupant and forward the response + local occupant = room:get_occupant_by_real_jid(req_jid); + + if occupant then + stanza.attr.to = occupant.jid; + stanza.attr.from = room.jid; + room:route_to_occupant(occupant, stanza); + return true; + end +end + +--process a host module directly if loaded or hooks to wait for its load +function process_host_module(name, callback) + local function process_host(host) + if host == name then + callback(module:context(host), host); + end + end + + if prosody.hosts[name] == nil then + module:log('debug', 'No host/component found, will wait for it: %s', name) + + -- when a host or component is added + prosody.events.add_handler('host-activated', process_host); + else + process_host(name); + end +end +-- if the message received ends with the main domain, these are system messages +-- for visitors, let's correct the room name there +local function message_handler(event) + local origin, stanza = event.origin, event.stanza; + + if ends_with(stanza.attr.from, main_domain) then + stanza.attr.from = stanza.attr.from:sub(1, -(main_domain:len() + 1))..local_domain; + end +end + +process_host_module(local_domain, function(host_module, host) + host_module:hook('iq/host', stanza_handler, 10); + host_module:hook('message/full', message_handler); +end); + +-- only live chat is supported for visitors +module:hook('muc-occupant-groupchat', function(event) + local occupant, room, stanza = event.occupant, event.room, event.stanza; + local from = stanza.attr.from; + local occupant_host; + + -- if there is no occupant this is a message from main, probably coming from other vnode + if occupant then + occupant_host = jid.host(occupant.bare_jid); + + -- we manage nick only for visitors + if occupant_host ~= main_domain then + -- add to message stanza display name for the visitor + -- remove existing nick to avoid forgery + stanza:remove_children('nick', NICK_NS); + local nick_element = occupant:get_presence():get_child('nick', NICK_NS); + if nick_element then + stanza:add_child(nick_element); + else + stanza:tag('nick', { xmlns = NICK_NS }) + :text('anonymous'):up(); + end + end + + stanza.attr.from = occupant.nick; + else + stanza.attr.from = jid.join(jid.node(from), module.host); + end + + -- let's send it to main chat and rest of visitors here + for _, o in room:each_occupant() do + -- filter remote occupants + if jid.host(o.bare_jid) == local_domain then + room:route_to_occupant(o, stanza) + end + end + + -- send to main participants only messages from local occupants (skip from remote vnodes) + if occupant and occupant_host == local_domain then + local main_message = st.clone(stanza); + main_message.attr.to = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain); + -- make sure we fix the from to be the real jid + main_message.attr.from = room_jid_match_rewrite(stanza.attr.from); + module:send(main_message); + end + stanza.attr.from = from; -- something prosody does internally + + return true; +end, 55); -- prosody check for visitor's chat is prio 50, we want to override it + +module:hook('muc-private-message', function(event) + -- private messaging is forbidden + event.origin.send(st.error_reply(event.stanza, 'auth', 'forbidden', + 'Private messaging is disabled on visitor nodes')); + return true; +end, 10); + +-- 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 connect stanzas +local function iq_from_main_handler(event) + local origin, stanza = event.origin, event.stanza; + + if stanza.name ~= 'iq' then + return; + end + + if stanza.attr.type == 'result' and sent_iq_cache:get(stanza.attr.id) then + sent_iq_cache:set(stanza.attr.id, nil); + return true; + end + + if stanza.attr.type ~= 'set' then + return; + end + + local visitors_iq = event.stanza:get_child('visitors', 'jitsi:visitors'); + if not visitors_iq then + return; + end + + if stanza.attr.from ~= main_domain then + module:log('warn', 'not from main prosody, ignore! %s', stanza); + return true; + end + + local room_jid = visitors_iq.attr.room; + local room = get_room_from_jid(room_jid_match_rewrite(room_jid)); + + if not room then + module:log('warn', 'No room found %s', room_jid); + 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 + origin.send(st.iq({ + type = 'result'; + from = stanza.attr.to; + to = stanza.attr.from; + id = stanza.attr.id + })); + + if process_disconnect then + 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 + + -- 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; + room._data.moderator_id = node.attr.moderatorId; + local createdTimestamp = node.attr.createdTimestamp; + room.created_timestamp = createdTimestamp and tonumber(createdTimestamp) or nil; + + if node.attr.lobby == 'true' then + room._main_room_lobby_enabled = true; + elseif node.attr.lobby == 'false' then + room._main_room_lobby_enabled = false; + end + + if fire_jicofo_unlock then + -- everything is connected allow participants to join + module:fire_event('jicofo-unlock-room', { room = room; fmuc_fired = true; }); + end + + return true; +end +module:hook('iq/host', iq_from_main_handler, 10); + +-- Filters presences (if detected) that are with destination the main prosody +function filter_stanza(stanza, session) + if (stanza.name == 'presence' or stanza.name == 'message') and session.type ~= 'c2s' then + -- we clone it so we do not affect broadcast using same stanza, sending it to clients + local f_st = st.clone(stanza); + f_st.skipMapping = true; + return f_st; + elseif stanza.name == 'presence' and session.type == 'c2s' and jid.node(stanza.attr.to) == 'focus' then + local x = stanza:get_child('x', 'http://jabber.org/protocol/muc#user'); + if presence_check_status(x, '110') then + return stanza; -- no filter + end + + -- we want to filter presences to jicofo for the main participants, skipping visitors + -- no point of having them, but if it is the one of the first to be sent + -- when first visitor is joining can produce the 'No hosts[from_host]' error as we + -- rewrite the from, but we need to not do it to be able to filter it later for the s2s + if jid.host(room_jid_match_rewrite(stanza.attr.from)) ~= local_muc_domain then + return nil; -- returning nil filters the stanza + end + end + return stanza; -- no filter +end +function filter_session(session) + -- domain mapper is filtering on default priority 0, and we need it before that + filters.add_filter(session, 'stanzas/out', filter_stanza, 2); +end + +filters.add_filter_hook(filter_session); + +function route_s2s_stanza(event) + local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza; + + if to_host ~= main_domain then + return; -- continue with hook listeners + end + + if stanza.name == 'message' then + if jid.resource(stanza.attr.to) then + -- there is no point of delivering messages to main participants individually + return true; -- drop it + end + return; + end + + if stanza.name == 'presence' then + -- we want to leave only unavailable presences to go to main node + -- all other presences from jicofo or the main participants there is no point to go to the main node + -- they are anyway not handled + if stanza.attr.type ~= 'unavailable' then + return true; -- drop it + end + return; + end +end + +-- routing to sessions in mod_s2s is -1 and -10, we want to hook before that to make sure to is correct +-- or if we want to filter that stanza +module:hook("route/remote", route_s2s_stanza, 10); diff --git a/roles/jitsi/files/prosody/modules/mod_jibri_session.lua b/roles/jitsi/files/prosody/modules/mod_jibri_session.lua new file mode 100644 index 0000000..5fb0f58 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_jibri_session.lua @@ -0,0 +1,69 @@ +local json = require 'cjson'; + +local util = module:require 'util'; +local room_jid_match_rewrite = util.room_jid_match_rewrite; +local get_room_from_jid = util.get_room_from_jid; + +-- This needs to be attached to the main virtual host and the virtual host where jicofo is connected and authenticated. +-- The first pass is the iq coming from the client where we get the creator and attach it to the app_data. +-- The second pass is jicofo approving that and inviting jibri where we attach the session_id information to app_data +local function attachJibriSessionId(event) +local stanza = event.stanza; + if stanza.name == "iq" then + local jibri = stanza:get_child('jibri', 'http://jitsi.org/protocol/jibri'); + if jibri then + if jibri.attr.action == 'start' then + + local update_app_data = false; + local app_data = jibri.attr.app_data; + if app_data then + app_data = json.decode(app_data); + else + app_data = {}; + end + if app_data.file_recording_metadata == nil then + app_data.file_recording_metadata = {}; + end + + if jibri.attr.room then + local jibri_room = jibri.attr.room; + jibri_room = room_jid_match_rewrite(jibri_room) + local room = get_room_from_jid(jibri_room); + if room then + local conference_details = {}; + conference_details["session_id"] = room._data.meetingId; + app_data.file_recording_metadata.conference_details = conference_details; + update_app_data = true; + end + else + -- no room is because the iq received by the initiator in the room + local session = event.origin; + -- if a token is provided, add data to app_data + if session ~= nil then + local initiator = {}; + + if session.jitsi_meet_context_user ~= nil then + initiator.id = session.jitsi_meet_context_user.id; + end + if session.jitsi_meet_context_group ~= nil then + initiator.group = session.jitsi_meet_context_group; + end + + 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); diff --git a/roles/jitsi/files/prosody/modules/mod_jiconop.lua b/roles/jitsi/files/prosody/modules/mod_jiconop.lua new file mode 100644 index 0000000..0e0bcad --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_jiconop.lua @@ -0,0 +1,70 @@ +local st = require "util.stanza"; +local ext_services = module:depends("external_services"); +local get_services = ext_services.get_services; +local services_xml = ext_services.services_xml; + +-- Jitsi Connection Optimization +-- gathers needed information and pushes it with a message to clients +-- this way we skip 4 request responses during every client setup + +local 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 = {}; + for _,identity in ipairs(module:get_host_items("identity")) do + local identity_s = identity.category.."\0"..identity.type; + if not done[identity_s] then + query:tag("identity", identity):up(); + done[identity_s] = true; + end + end + + query_cache = query; + end + + local query = st.clone(query_cache); + + -- check whether room has lobby enabled and display name is required for those trying to join + local lobby_muc_component_config = module:get_option_string('lobby_muc'); + module:context(lobby_muc_component_config):fire_event('host-disco-info-node', + {origin = session; reply = query; node = 'lobbyrooms';}); + + -- will add a rename feature for breakout rooms. + local breakout_rooms_muc_component_config = module:get_option_string('breakout_rooms_muc'); + if breakout_rooms_muc_component_config then + module:context(breakout_rooms_muc_component_config):fire_event('host-disco-info-node', + {origin = session; reply = query; node = 'breakout_rooms';}); + end + + local stanza = st.message({ + from = module.host; + to = session.full_jid; }); + stanza:add_child(query):up(); + + --- get turnservers and credentials + stanza:add_child(services_xml(get_services())); + + session.send(stanza); +end); diff --git a/roles/jitsi/files/prosody/modules/mod_jitsi_session.lua b/roles/jitsi/files/prosody/modules/mod_jitsi_session.lua new file mode 100644 index 0000000..8d281c4 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_jitsi_session.lua @@ -0,0 +1,30 @@ +-- Jitsi session information +-- Copyright (C) 2021-present 8x8, Inc. +module:set_global(); + +local formdecode = require "util.http".formdecode; + +-- Extract the following parameters from the URL and set them in the session: +-- * previd: for session resumption +function init_session(event) + local session, request = event.session, event.request; + local query = request.url.query; + + if query ~= nil then + local params = formdecode(query); + + -- previd is used together with https://modules.prosody.im/mod_smacks.html + -- the param is used to find resumed session and re-use anonymous(random) user id + session.previd = query and params.previd or nil; + + -- customusername can be used with combination with "pre-jitsi-authentication" event to pre-set a known jid to a session + session.customusername = query and params.customusername or nil; + + -- The room name and optional prefix from the web query + session.jitsi_web_query_room = params.room; + session.jitsi_web_query_prefix = params.prefix or ""; + end +end + +module:hook_global("bosh-session", init_session, 1); +module:hook_global("websocket-session", init_session, 1); diff --git a/roles/jitsi/files/prosody/modules/mod_limits_exception.lua b/roles/jitsi/files/prosody/modules/mod_limits_exception.lua new file mode 100644 index 0000000..fe780b1 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_limits_exception.lua @@ -0,0 +1,32 @@ +-- we use async to detect Prosody 0.10 and earlier +local have_async = pcall(require, 'util.async'); + +if not have_async then + return; +end + +local unlimited_jids = module:get_option_inherited_set("unlimited_jids", {}); + +-- rises the limit of the stanza size for the unlimited jids, default is 10MB +local unlimited_stanza_size_limit = module:get_option_number("unlimited_size", 10*1024*1024); + +if unlimited_jids:empty() then + return; +end + +module:hook("authentication-success", function (event) + local session = event.session; + local jid = session.username .. "@" .. session.host; + if unlimited_jids:contains(jid) then + if session.conn and session.conn.setlimit then + session.conn:setlimit(0); + elseif session.throttle then + session.throttle = nil; + end + + if unlimited_stanza_size_limit and session.stream.set_stanza_size_limit then + module:log('info', 'Setting stanza size limits for %s to %s', jid, unlimited_stanza_size_limit) + session.stream:set_stanza_size_limit(unlimited_stanza_size_limit); + end + end +end); diff --git a/roles/jitsi/files/prosody/modules/mod_log_ringbuffer.lua b/roles/jitsi/files/prosody/modules/mod_log_ringbuffer.lua new file mode 100644 index 0000000..1521f10 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_log_ringbuffer.lua @@ -0,0 +1,120 @@ +module:set_global(); + +local loggingmanager = require "core.loggingmanager"; +local format = require "util.format".format; +local pposix = require "util.pposix"; +local rb = require "util.ringbuffer"; +local queue = require "util.queue"; + +local default_timestamp = "%b %d %H:%M:%S "; +local max_chunk_size = module:get_option_number("log_ringbuffer_chunk_size", 16384); + +local os_date = os.date; + +local default_filename_template = "{paths.data}/ringbuffer-logs-{pid}-{count}.log"; +local render_filename = require "util.interpolation".new("%b{}", function (s) return s; end, { + yyyymmdd = function (t) + return os_date("%Y%m%d", t); + end; + hhmmss = function (t) + return os_date("%H%M%S", t); + end; +}); + +local dump_count = 0; + +local function dump_buffer(dump, filename) + dump_count = dump_count + 1; + local f, err = io.open(filename, "a+"); + if not f then + module:log("error", "Unable to open output file: %s", err); + return; + end + f:write(("-- Dumping log buffer at %s --\n"):format(os_date(default_timestamp))); + dump(f); + f:write("-- End of dump --\n\n"); + f:close(); +end + +local function get_filename(filename_template) + filename_template = filename_template or default_filename_template; + return render_filename(filename_template, { + paths = prosody.paths; + pid = pposix.getpid(); + count = dump_count; + time = os.time(); + }); +end + +local function new_buffer(config) + local write, dump; + + if config.lines then + local buffer = queue.new(config.lines, true); + function write(line) + buffer:push(line); + end + function dump(f) + -- COMPAT w/0.11 - update to use :consume() + for line in buffer.pop, buffer do + f:write(line); + end + end + else + local buffer_size = config.size or 100*1024; + local buffer = rb.new(buffer_size); + function write(line) + if not buffer:write(line) then + if #line > buffer_size then + buffer:discard(buffer_size); + buffer:write(line:sub(-buffer_size)); + else + buffer:discard(#line); + buffer:write(line); + end + end + end + function dump(f) + local bytes_remaining = buffer:length(); + while bytes_remaining > 0 do + local chunk_size = math.min(bytes_remaining, max_chunk_size); + local chunk = buffer:read(chunk_size); + if not chunk then + return; + end + f:write(chunk); + bytes_remaining = bytes_remaining - chunk_size; + end + end + end + return write, dump; +end + +local function ringbuffer_log_sink_maker(sink_config) + local write, dump = new_buffer(sink_config); + + local timestamps = sink_config.timestamps; + + if timestamps == true or timestamps == nil then + timestamps = default_timestamp; -- Default format + elseif timestamps then + timestamps = timestamps .. " "; + end + + local function handler() + dump_buffer(dump, sink_config.filename or get_filename(sink_config.filename_template)); + end + + if sink_config.signal then + require "util.signal".signal(sink_config.signal, handler); + elseif sink_config.event then + module:hook_global(sink_config.event, handler); + end + + return function (name, level, message, ...) + local line = format("%s%s\t%s\t%s\n", timestamps and os_date(timestamps) or "", name, level, format(message, ...)); + write(line); + end; +end + +loggingmanager.register_sink_type("ringbuffer", ringbuffer_log_sink_maker); diff --git a/roles/jitsi/files/prosody/modules/mod_measure_message_count.lua b/roles/jitsi/files/prosody/modules/mod_measure_message_count.lua new file mode 100644 index 0000000..e27c58e --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_measure_message_count.lua @@ -0,0 +1,166 @@ +-- Measure the number of messages used in a meeting. Sends amplitude event. +-- Needs to be activated under the muc component where the limit needs to be applied (main muc and breakout muc) +-- Copyright (C) 2023-present 8x8, Inc. + +local jid = require 'util.jid'; +local http = require 'net.http'; +local cjson_safe = require 'cjson.safe' + +local amplitude_endpoint = module:get_option_string('amplitude_endpoint', 'https://api2.amplitude.com/2/httpapi'); +local amplitude_api_key = module:get_option_string('amplitude_api_key'); + +if not amplitude_api_key then + module:log("warn", "No 'amplitude_api_key' option set, disabling amplitude reporting"); + return +end + +local muc_domain_base = module:get_option_string('muc_mapper_domain_base'); +local isBreakoutRoom = module.host == 'breakout.' .. muc_domain_base; + +local util = module:require 'util'; +local is_healthcheck_room = util.is_healthcheck_room; +local extract_subdomain = util.extract_subdomain; + +module:log('info', 'Loading measure message count'); + +local shard_name = module:context(muc_domain_base):get_option_string('shard_name'); +local region_name = module:context(muc_domain_base):get_option_string('region_name'); +local release_number = module:context(muc_domain_base):get_option_string('release_number'); +local http_headers = { + ['User-Agent'] = 'Prosody ('..prosody.version..'; '..prosody.platform..')', + ['Content-Type'] = 'application/json' +}; + +local inspect = require "inspect" + +function table.clone(t) + return {table.unpack(t)} +end + +local function event_cb(content_, code_, response_, request_) + if code_ == 200 or code_ == 204 then + module:log('debug', 'URL Callback: Code %s, Content %s, Request (host %s, path %s, body %s), Response: %s', + code_, content_, request_.host, request_.path, inspect(request_.body), inspect(response_)); + else + module:log('warn', 'URL Callback non successful: Code %s, Content %s, Request (%s), Response: %s', + code_, content_, inspect(request_), inspect(response_)); + end +end + +function send_event(room) + local user_properties = { + shard_name = shard_name; + region_name = region_name; + release_number = release_number; + }; + + local node = jid.split(room.jid); + local subdomain, room_name = extract_subdomain(node); + user_properties.tenant = subdomain or '/'; + user_properties.conference_name = room_name or node; + + local event_properties = { + messages_count = room._muc_messages_count or 0; + polls_count = room._muc_polls_count or 0; + tenant_mismatch = room.jitsi_meet_tenant_mismatch or false; + }; + + if room.created_timestamp then + event_properties.duration = (os.time() * 1000 - room.created_timestamp) / 1000; + end + + local event = { + api_key = amplitude_api_key; + events = { + { + user_id = room._data.meetingId; + device_id = room._data.meetingId; + event_type = 'conference_ended'; + event_properties = event_properties; + user_properties = user_properties; + } + }; + }; + + local request = http.request(amplitude_endpoint, { + headers = http_headers, + method = "POST", + body = cjson_safe.encode(event) + }, event_cb); +end + +function on_message(event) + local stanza = event.stanza; + local body = stanza:get_child('body'); + + if not body then + -- we ignore messages without body - lobby, polls ... + return; + end + + local session = event.origin; + if not session or not session.jitsi_web_query_room then + return; + end + + -- get room name with tenant and find room. + local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix); + if not room then + module:log('warn', 'No room found found for %s/%s', + session.jitsi_web_query_prefix, session.jitsi_web_query_room); + return; + end + + if not room._muc_messages_count then + room._muc_messages_count = 0; + end + + room._muc_messages_count = room._muc_messages_count + 1; +end + +-- Conference ended, send stats +function room_destroyed(event) + local room, session = event.room, event.origin; + + if is_healthcheck_room(room.jid) then + return; + end + + if isBreakoutRoom then + return; + end + send_event(room); +end + +function poll_created(event) + local session = event.event.origin; + + -- get room name with tenant and find room. + local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix); + if not room then + module:log('warn', 'No room found found for %s/%s', + session.jitsi_web_query_prefix, session.jitsi_web_query_room); + return false; + end + + if not room._muc_polls_count then + room._muc_polls_count = 0; + end + + room._muc_polls_count = room._muc_polls_count + 1; +end + +module:hook('message/full', on_message); -- private messages +module:hook('message/bare', on_message); -- room messages + +module:hook('muc-room-destroyed', room_destroyed, -1); +module:hook("muc-occupant-left", function(event) + local occupant, room = event.occupant, event.room; + local session = event.origin; + + if session and session.jitsi_meet_tenant_mismatch then + room.jitsi_meet_tenant_mismatch = true; + end +end); + +module:hook('poll-created', poll_created); diff --git a/roles/jitsi/files/prosody/modules/mod_measure_stanza_counts.lua b/roles/jitsi/files/prosody/modules/mod_measure_stanza_counts.lua new file mode 100644 index 0000000..6c17c67 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_measure_stanza_counts.lua @@ -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); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_allowners.lua b/roles/jitsi/files/prosody/modules/mod_muc_allowners.lua new file mode 100644 index 0000000..b8df9ff --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_allowners.lua @@ -0,0 +1,154 @@ +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 um_is_admin = require "core.usermanager".is_admin; +local util = module:require "util"; +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 presence_check_status = util.presence_check_status; +local MUC_NS = 'http://jabber.org/protocol/muc'; + +local disable_revoke_owners; + +local function load_config() + disable_revoke_owners = module:get_option_boolean("allowners_disable_revoke_owners", false); +end +load_config(); + +local function is_admin(_jid) + return um_is_admin(_jid, module.host); +end + +-- 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:hook("muc-occupant-pre-join", function (event) + local room, occupant = event.room, event.occupant; + + if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then + return; + end + + local moderated, room_name, subdomain = is_moderated(room.jid); + if moderated then + local session = event.origin; + local token = session.auth_token; + + if not token then + module:log('debug', 'skip allowners for non-auth user subdomain:%s room_name:%s', subdomain, room_name); + return; + end + + if not (room_name == session.jitsi_meet_room or session.jitsi_meet_room == '*') then + module:log('debug', 'skip allowners for auth user and non matching room name: %s, jwt room name: %s', + room_name, session.jitsi_meet_room); + return; + end + + if session.jitsi_meet_domain ~= '*' and subdomain ~= session.jitsi_meet_domain then + module:log('debug', 'skip allowners for auth user and non matching room subdomain: %s, jwt subdomain: %s', + subdomain, session.jitsi_meet_domain); + return; + end + end + + -- mark this participant that it will be promoted and is currently joining + joining_moderator_participants[occupant.bare_jid] = true; +end, 2); + +module:hook("muc-occupant-joined", function (event) + local room, occupant = event.room, event.occupant; + + local promote_to_moderator = joining_moderator_participants[occupant.bare_jid]; + -- clear it + joining_moderator_participants[occupant.bare_jid] = nil; + + if promote_to_moderator ~= nil then + room:set_affiliation(true, occupant.bare_jid, "owner"); + end +end, 2); + +module:hook_global('config-reloaded', load_config); + +-- Filters self-presences to a jid that exist in joining_participants array +-- We want to filter those presences where we send first `participant` and just after it `moderator` +function filter_stanza(stanza) + -- when joining_moderator_participants is empty there is nothing to filter + if next(joining_moderator_participants) == nil + or not stanza.attr + or not stanza.attr.to + or stanza.name ~= "presence" then + return stanza; + end + + -- we want to filter presences only on this host for allowners and skip anything like lobby etc. + local host_from = jid_host(stanza.attr.from); + if host_from ~= module.host then + return stanza; + end + + local bare_to = jid_bare(stanza.attr.to); + if stanza:get_error() and joining_moderator_participants[bare_to] then + -- pre-join succeeded but joined did not so we need to clear cache + joining_moderator_participants[bare_to] = nil; + return stanza; + end + + local muc_x = stanza:get_child('x', MUC_NS..'#user'); + if not muc_x then + return stanza; + end + + if joining_moderator_participants[bare_to] and presence_check_status(muc_x, '110') then + -- skip the local presence for participant + return nil; + end + + -- skip sending the 'participant' presences to all other people in the room + for item in muc_x:childtags('item') do + if joining_moderator_participants[jid_bare(item.attr.jid)] then + return nil; + end + end + + return stanza; +end +function filter_session(session) + -- domain mapper is filtering on default priority 0, and we need it after that + filters.add_filter(session, 'stanzas/out', filter_stanza, -1); +end + +-- enable filtering presences +filters.add_filter_hook(filter_session); + +-- filters any attempt to revoke owner rights on non moderated rooms +function filter_admin_set_query(event) + local origin, stanza = event.origin, event.stanza; + local room_jid = jid_bare(stanza.attr.to); + local room = get_room_from_jid(room_jid); + + local item = stanza.tags[1].tags[1]; + local _aff = item.attr.affiliation; + + -- if it is a moderated room we skip it + if room and is_moderated(room.jid) then + return nil; + end + + -- any revoking is disabled, everyone should be owners + if _aff == 'none' or _aff == 'outcast' or _aff == 'member' then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end +end + +if not disable_revoke_owners then + -- default prosody priority for handling these is -2 + module:hook("iq-set/bare/http://jabber.org/protocol/muc#admin:query", filter_admin_set_query, 5); + module:hook("iq-set/host/http://jabber.org/protocol/muc#admin:query", filter_admin_set_query, 5); +end diff --git a/roles/jitsi/files/prosody/modules/mod_muc_auth_ban.lua b/roles/jitsi/files/prosody/modules/mod_muc_auth_ban.lua new file mode 100644 index 0000000..4dac9d6 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_auth_ban.lua @@ -0,0 +1,88 @@ +-- Can be used to ban users based on external http service +-- Copyright (C) 2023-present 8x8, Inc. + +local ACCESS_MANAGER_URL = module:get_option_string("muc_prosody_jitsi_access_manager_url"); +if not ACCESS_MANAGER_URL then + module:log("warn", "No 'muc_prosody_jitsi_access_manager_url' option set, disabling module"); + return +end + +local json = require "cjson.safe"; +local http = require "net.http"; +local inspect = require 'inspect'; + +local ban_check_count = module:measure("muc_auth_ban_check", "rate") +local ban_check_users_banned_count = module:measure("muc_auth_ban_users_banned", "rate") + +-- we will cache banned tokens to avoid extra requests +-- on destroying session, websocket retries 2 more times before giving up +local cache = require "util.cache".new(100); + +local CACHE_DURATION = 5*60; -- 5 mins + +local cache_timer = module:add_timer(CACHE_DURATION, function() + for k, v in cache:items() do + if socket.gettime() > v + CACHE_DURATION then + cache:set(k, nil); + end + end + + if cache:count() > 0 then + -- rescheduling the timer + return CACHE_DURATION; + end + + -- skipping return value stops the timer +end); + +local function shouldAllow(session) + local token = session.auth_token; + + if token ~= nil then + -- module:log("debug", "Checking whether user should be banned ") + + -- cached tokens are banned + if cache:get(token) then + return false; + end + + -- TODO: do this only for enabled customers + ban_check_count(); + local function cb(content, code, response, request) + if code == 200 then + + local r = json.decode(content) + if r['access'] ~= nil and r['access'] == false then + module:log("info", "User is banned room:%s tenant:%s user_id:%s group:%s", + session.jitsi_meet_room, session.jitsi_web_query_prefix, + inspect(session.jitsi_meet_context_user), session.jitsi_meet_context_group); + + ban_check_users_banned_count(); + + session:close(); + + -- if the cache is empty and the timer is not running reschedule it + if cache:count() == 0 then + cache_timer:reschedule(CACHE_DURATION); + end + + cache:set(token, socket.gettime()); + end + end + end + + local request_headers = {} + request_headers['Authorization'] = 'Bearer ' .. token; + + http.request(ACCESS_MANAGER_URL, { + headers = request_headers, + method = "GET", + }, cb); + + return true; + end +end + +prosody.events.add_handler("jitsi-access-ban-check", function(session) + return shouldAllow(session) +end) diff --git a/roles/jitsi/files/prosody/modules/mod_muc_breakout_rooms.lua b/roles/jitsi/files/prosody/modules/mod_muc_breakout_rooms.lua new file mode 100644 index 0000000..c3d2850 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_breakout_rooms.lua @@ -0,0 +1,635 @@ +-- This module is added under the main virtual host domain +-- It needs a breakout rooms muc component +-- +-- VirtualHost "jitmeet.example.com" +-- modules_enabled = { +-- "muc_breakout_rooms" +-- } +-- breakout_rooms_muc = "breakout.jitmeet.example.com" +-- main_muc = "muc.jitmeet.example.com" +-- +-- Component "breakout.jitmeet.example.com" "muc" +-- restrict_room_creation = true +-- storage = "memory" +-- admins = { "focusUser@auth.jitmeet.example.com" } +-- muc_room_locking = false +-- muc_room_default_public_jids = true +-- + +module:depends('room_destroy'); + +-- we use async to detect Prosody 0.10 and earlier +local have_async = pcall(require, 'util.async'); + +if not have_async then + module:log('warn', 'Breakout rooms will not work with Prosody version 0.10 or less.'); + return; +end + +local jid_node = require 'util.jid'.node; +local jid_host = require 'util.jid'.host; +local jid_split = require 'util.jid'.split; +local json = require 'cjson.safe'; +local st = require 'util.stanza'; +local uuid_gen = require 'util.uuid'.generate; + +local util = module:require 'util'; +local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite; +local is_healthcheck_room = util.is_healthcheck_room; +local process_host_module = util.process_host_module; + +local BREAKOUT_ROOMS_IDENTITY_TYPE = 'breakout_rooms'; +-- Available breakout room functionality +local RENAME_FEATURE = 'http://jitsi.org/protocol/breakout_rooms#rename'; +-- only send at most this often updates on breakout rooms to avoid flooding. +local BROADCAST_ROOMS_INTERVAL = .3; +-- close conference after this amount of seconds if all leave. +local ROOMS_TTL_IF_ALL_LEFT = 5; +local JSON_TYPE_ADD_BREAKOUT_ROOM = 'features/breakout-rooms/add'; +local JSON_TYPE_MOVE_TO_ROOM_REQUEST = 'features/breakout-rooms/move-to-room'; +local JSON_TYPE_REMOVE_BREAKOUT_ROOM = 'features/breakout-rooms/remove'; +local JSON_TYPE_RENAME_BREAKOUT_ROOM = 'features/breakout-rooms/rename'; +local JSON_TYPE_UPDATE_BREAKOUT_ROOMS = 'features/breakout-rooms/update'; + +local main_muc_component_config = module:get_option_string('main_muc'); +if main_muc_component_config == nil then + module:log('error', 'breakout rooms not enabled missing main_muc config'); + return ; +end +local breakout_rooms_muc_component_config = module:get_option_string('breakout_rooms_muc', 'breakout.'..module.host); + +module:depends('jitsi_session'); + +local breakout_rooms_muc_service; +local main_muc_service; + +-- Maps a breakout room jid to the main room jid +local main_rooms_map = {}; + +-- Utility functions + +function get_main_room_jid(room_jid) + local _, host = jid_split(room_jid); + + return + host == main_muc_component_config + and room_jid + or main_rooms_map[room_jid]; +end + +function get_main_room(room_jid) + local main_room_jid = get_main_room_jid(room_jid); + + return main_muc_service.get_room_from_jid(main_room_jid), main_room_jid; +end + +function get_room_from_jid(room_jid) + local host = jid_host(room_jid); + + return + host == main_muc_component_config + and main_muc_service.get_room_from_jid(room_jid) + or breakout_rooms_muc_service.get_room_from_jid(room_jid); +end + +function send_json_msg(to_jid, json_msg) + local stanza = st.message({ from = breakout_rooms_muc_component_config; to = to_jid; }) + :tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_msg):up(); + module:send(stanza); +end + +function get_participants(room) + local participants = {}; + + if room then + for room_nick, occupant in room:each_occupant() do + -- Filter focus as we keep it as a hidden participant + if jid_node(occupant.jid) ~= 'focus' then + local display_name = occupant:get_presence():get_child_text( + 'nick', 'http://jabber.org/protocol/nick'); + local real_nick = internal_room_jid_match_rewrite(room_nick); + participants[real_nick] = { + jid = occupant.jid, + role = occupant.role, + displayName = display_name + }; + end + end + end + + return participants; +end + +function broadcast_breakout_rooms(room_jid) + local main_room = get_main_room(room_jid); + + if not main_room or main_room.broadcast_timer then + return; + end + + -- Only send each BROADCAST_ROOMS_INTERVAL seconds to prevent flooding of messages. + main_room.broadcast_timer = module:add_timer(BROADCAST_ROOMS_INTERVAL, function() + local main_room, main_room_jid = get_main_room(room_jid); + + if not main_room then + return; + end + + main_room.broadcast_timer = nil; + + local real_jid = internal_room_jid_match_rewrite(main_room_jid); + local real_node = jid_node(real_jid); + local rooms = { + [real_node] = { + isMainRoom = true, + id = real_node, + jid = real_jid, + name = main_room._data.subject, + participants = get_participants(main_room) + }; + } + + for breakout_room_jid, subject in pairs(main_room._data.breakout_rooms or {}) do + local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid); + local breakout_room_node = jid_node(breakout_room_jid) + + rooms[breakout_room_node] = { + id = breakout_room_node, + jid = breakout_room_jid, + name = subject, + participants = {} + } + + -- The room may not physically exist yet. + if breakout_room then + rooms[breakout_room_node].participants = get_participants(breakout_room); + end + end + + local json_msg, error = json.encode({ + type = BREAKOUT_ROOMS_IDENTITY_TYPE, + event = JSON_TYPE_UPDATE_BREAKOUT_ROOMS, + roomCounter = main_room._data.breakout_rooms_counter, + rooms = rooms + }); + + if not json_msg then + module:log('error', 'not broadcasting breakout room information room:%s error:%s', main_room_jid, error); + return; + end + + for _, occupant in main_room:each_occupant() do + if jid_node(occupant.jid) ~= 'focus' then + send_json_msg(occupant.jid, json_msg) + end + end + + for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do + local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid); + if room then + for _, occupant in room:each_occupant() do + if jid_node(occupant.jid) ~= 'focus' then + send_json_msg(occupant.jid, json_msg) + end + end + end + end + end); +end + + +-- Managing breakout rooms + +function create_breakout_room(room_jid, subject) + local main_room, main_room_jid = get_main_room(room_jid); + 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(room_jid, message) + local main_room, main_room_jid = get_main_room(room_jid); + + if room_jid == main_room_jid then + 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(room_jid, name) + local main_room, main_room_jid = get_main_room(room_jid); + + if room_jid == main_room_jid then + return; + end + + if main_room then + if main_room._data.breakout_rooms then + main_room._data.breakout_rooms[room_jid] = name; + local breakout_room = breakout_rooms_muc_service.get_room_from_jid(room_jid); + + if breakout_room then + breakout_room:set_subject(breakout_room.jid, name); + end + + end + main_room:save(true); + broadcast_breakout_rooms(main_room_jid); + end +end + +-- Handling events + +function on_message(event) + local session = event.origin; + + -- Check the type of the incoming stanza to avoid loops: + if event.stanza.attr.type == 'error' then + return; -- We do not want to reply to these, so leave. + end + + if not session or not session.jitsi_web_query_room then + return false; + end + + local message = event.stanza:get_child(BREAKOUT_ROOMS_IDENTITY_TYPE); + + if not message then + return false; + end + + -- get room name with tenant and find room + local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix); + + if not room then + module:log('warn', 'No room found for %s/%s', + session.jitsi_web_query_prefix, session.jitsi_web_query_room); + return false; + end + + -- check that the participant requesting is a moderator and is an occupant in the room + local from = event.stanza.attr.from; + local occupant = room:get_occupant_by_real_jid(from); + + if not occupant then + -- Check if the participant is in any breakout room. + for breakout_room_jid in pairs(room._data.breakout_rooms or {}) do + local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid); + if breakout_room then + occupant = breakout_room:get_occupant_by_real_jid(from); + if occupant then + break; + end + end + end + if not occupant then + module:log('warn', 'No occupant %s found for %s', from, room.jid); + return false; + end + end + + if occupant.role ~= 'moderator' then + module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid); + return false; + end + + if message.attr.type == JSON_TYPE_ADD_BREAKOUT_ROOM then + create_breakout_room(room.jid, message.attr.subject); + return true; + elseif message.attr.type == JSON_TYPE_REMOVE_BREAKOUT_ROOM then + destroy_breakout_room(message.attr.breakoutRoomJid); + return true; + elseif message.attr.type == JSON_TYPE_RENAME_BREAKOUT_ROOM then + rename_breakout_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; + + 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); + end + + send_json_msg(participant_jid, json_msg) + return true; + end + + -- return error. + return false; +end + +function on_breakout_room_pre_create(event) + local breakout_room = event.room; + local main_room, main_room_jid = get_main_room(breakout_room.jid); + + -- Only allow existent breakout rooms to be started. + -- Authorisation of breakout rooms is done by their random uuid name + if main_room and main_room._data.breakout_rooms and main_room._data.breakout_rooms[breakout_room.jid] then + breakout_room:set_subject(breakout_room.jid, main_room._data.breakout_rooms[breakout_room.jid]); + else + module:log('debug', 'Invalid breakout room %s will not be created.', breakout_room.jid); + breakout_room:destroy(main_room_jid, 'Breakout room is invalid.'); + return true; + end +end + +function on_occupant_joined(event) + local room = event.room; + + if is_healthcheck_room(room.jid) then + return; + end + + local main_room, main_room_jid = get_main_room(room.jid); + + if main_room and main_room._data.breakout_rooms_active then + if jid_node(event.occupant.jid) ~= 'focus' then + broadcast_breakout_rooms(main_room_jid); + end + + -- Prevent closing all rooms if a participant has joined (see on_occupant_left). + if main_room.close_timer then + main_room.close_timer:stop(); + main_room.close_timer = nil; + end + end +end + +function exist_occupants_in_room(room) + if not room then + return false; + end + for _, occupant in room:each_occupant() do + if jid_node(occupant.jid) ~= 'focus' then + return true; + end + end + + return false; +end + +function exist_occupants_in_rooms(main_room) + if exist_occupants_in_room(main_room) then + return true; + end + for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do + local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid); + if exist_occupants_in_room(room) then + return true; + end + end + + return false; +end + +function on_occupant_left(event) + local room_jid = event.room.jid; + + if is_healthcheck_room(room_jid) then + return; + end + + local main_room, main_room_jid = get_main_room(room_jid); + + if not main_room then + return; + end + + if main_room._data.breakout_rooms_active and jid_node(event.occupant.jid) ~= 'focus' then + broadcast_breakout_rooms(main_room_jid); + end + + -- Close the conference if all left for good. + if main_room._data.breakout_rooms_active and not main_room.close_timer and not exist_occupants_in_rooms(main_room) then + main_room.close_timer = module:add_timer(ROOMS_TTL_IF_ALL_LEFT, function() + -- we need to look up again the room as till the timer is fired, the room maybe already destroyed/recreated + -- and we will have the old instance + local main_room, main_room_jid = get_main_room(room_jid); + if main_room and main_room.close_timer then + prosody.events.fire_event("maybe-destroy-room", { + room = main_room; + reason = 'All occupants left.'; + caller = module:get_name(); + }); + end + end); + end +end + +-- Stop other modules from destroying room if breakout rooms not empty +function handle_maybe_destroy_main_room(event) + local main_room = event.room; + local caller = event.caller; + + if caller == module:get_name() then + -- we were the one that requested the deletion. Do not override. + return nil; -- stop room destruction + end + + -- deletion was requested by another module. Check for break room occupants. + for breakout_room_jid, _ in pairs(main_room._data.breakout_rooms or {}) do + local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid); + if breakout_room and breakout_room:has_occupant() then + module:log('info', 'Suppressing room destroy. Breakout room still occupied %s', breakout_room_jid); + return true; -- stop room destruction + end + end +end + +module:hook_global("maybe-destroy-room", handle_maybe_destroy_main_room) + + +function on_main_room_destroyed(event) + local main_room = event.room; + + if is_healthcheck_room(main_room.jid) then + return; + end + + for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do + destroy_breakout_room(breakout_room_jid, event.reason) + end +end + + +-- Module operations + +-- operates on already loaded breakout rooms muc module +function process_breakout_rooms_muc_loaded(breakout_rooms_muc, host_module) + module:log('debug', 'Breakout rooms muc loaded'); + + -- Advertise the breakout rooms component so clients can pick up the address and use it + module:add_identity('component', BREAKOUT_ROOMS_IDENTITY_TYPE, breakout_rooms_muc_component_config); + + -- Tag the disco#info response with available features of breakout rooms. + host_module:hook('host-disco-info-node', function (event) + local session, reply, node = event.origin, event.reply, event.node; + if node == BREAKOUT_ROOMS_IDENTITY_TYPE and session.jitsi_web_query_room then + reply:tag('feature', { var = RENAME_FEATURE }):up(); + end + event.exists = true; + end); + + breakout_rooms_muc_service = breakout_rooms_muc; + module:log("info", "Hook to muc events on %s", breakout_rooms_muc_component_config); + host_module:hook('message/host', on_message); + host_module:hook('muc-occupant-joined', on_occupant_joined); + host_module:hook('muc-occupant-left', on_occupant_left); + host_module:hook('muc-room-pre-create', on_breakout_room_pre_create); + + host_module:hook('muc-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'] = 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 = main_room_jid; + }); + end); + + local room_mt = breakout_rooms_muc_service.room_mt; + + room_mt.get_members_only = function(room) + local main_room = get_main_room(room.jid); + + if not main_room then + module:log('error', 'No main room (%s)!', room.jid); + return false; + end + + return main_room.get_members_only(main_room) + end + + -- we base affiliations (roles) in breakout rooms muc component to be based on the roles in the main muc + room_mt.get_affiliation = function(room, jid) + local main_room, _ = get_main_room(room.jid); + + if not main_room then + module:log('error', 'No main room(%s) for %s!', room.jid, jid); + return 'none'; + end + + -- moderators in main room are moderators here + local role = main_room.get_affiliation(main_room, jid); + if role then + return role; + end + + return 'none'; + end +end + +-- process or waits to process the breakout rooms muc component +process_host_module(breakout_rooms_muc_component_config, function(host_module, host) + module:log('info', 'Breakout rooms component created %s', host); + + local muc_module = prosody.hosts[host].modules.muc; + + if muc_module then + process_breakout_rooms_muc_loaded(muc_module, host_module); + else + module:log('debug', 'Will wait for muc to be available'); + prosody.hosts[host].events.add_handler('module-loaded', function(event) + if (event.module == 'muc') then + process_breakout_rooms_muc_loaded(prosody.hosts[host].modules.muc, host_module); + end + end); + end +end); + +-- operates on already loaded main muc module +function process_main_muc_loaded(main_muc, host_module) + module:log('debug', 'Main muc loaded'); + + main_muc_service = main_muc; + module:log("info", "Hook to muc events on %s", main_muc_component_config); + host_module:hook('muc-occupant-joined', on_occupant_joined); + host_module:hook('muc-occupant-left', on_occupant_left); + host_module:hook('muc-room-destroyed', on_main_room_destroyed); +end + +-- process or waits to process the main muc component +process_host_module(main_muc_component_config, function(host_module, host) + local muc_module = prosody.hosts[host].modules.muc; + + if muc_module then + process_main_muc_loaded(muc_module, host_module); + else + module:log('debug', 'Will wait for muc to be available'); + prosody.hosts[host].events.add_handler('module-loaded', function(event) + if (event.module == 'muc') then + process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module); + end + end); + end +end); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_call.lua b/roles/jitsi/files/prosody/modules/mod_muc_call.lua new file mode 100644 index 0000000..c989d90 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_call.lua @@ -0,0 +1,125 @@ +local jid = require "util.jid" +local extract_subdomain = module:require "util".extract_subdomain; + +-- Options and configuration +local poltergeist_component = module:get_option_string( + "poltergeist_component", + module.host +); +local muc_domain_base = module:get_option_string("muc_mapper_domain_base"); +if not muc_domain_base then + module:log( + "warn", + "No 'muc_domain_base' option set, unable to send call events." + ); + return +end + +-- Status strings that trigger call events. +local calling_status = "calling" +local busy_status = "busy" +local rejected_status = "rejected" +local connected_status = "connected" +local expired_status = "expired" + +-- url_from_room_jid will determine the url for a conference +-- provided a room jid. It is required that muc domain mapping +-- is enabled and configured. There are two url formats that are supported. +-- The following urls are examples of the supported formats. +-- https://meet.jit.si/jitsi/ProductiveMeeting +-- https://meet.jit.si/MoreProductiveMeeting +-- The urls are derived from portions of the room jid. +local function url_from_room_jid(room_jid) + local node, _, _ = jid.split(room_jid) + if not node then return nil end + + local target_subdomain, target_node = extract_subdomain(node); + + if not(target_node or target_subdomain) then + return "https://"..muc_domain_base.."/"..node + else + return "https://"..muc_domain_base.."/"..target_subdomain.."/"..target_node + end +end + +-- Listening for all muc presences stanza events. If a presence stanza is from +-- a poltergeist then it will be further processed to determine if a call +-- event should be triggered. Call events are triggered by status strings +-- the status strings supported are: +-- ------------------------- +-- Status | Event Type +-- _________________________ +-- "calling" | INVITE +-- "busy" | CANCEL +-- "rejected" | CANCEL +-- "connected" | CANCEL +module:hook( + "muc-broadcast-presence", + function (event) + -- Detect if the presence is for a poltergeist or not. + -- FIX ME: luacheck warning 581 + -- not (x == y)' can be replaced by 'x ~= y' (if neither side is a table or NaN) + if not (jid.bare(event.occupant.jid) == poltergeist_component) then + return + end + + -- A presence stanza is needed in order to trigger any calls. + if not event.stanza then + return + end + + local call_id = event.stanza:get_child_text("call_id") + if not call_id then + module:log("info", "A call id was not provided in the status.") + return + end + + local invite = function() + local url = assert(url_from_room_jid(event.stanza.attr.from)) + module:fire_event('jitsi-call-invite', { stanza = event.stanza; url = url; call_id = call_id; }); + end + + local cancel = function() + local url = assert(url_from_room_jid(event.stanza.attr.from)) + local status = event.stanza:get_child_text("status") + module:fire_event('jitsi-call-cancel', { + stanza = event.stanza; + url = url; + reason = string.lower(status); + call_id = call_id; + }); + end + + -- If for any reason call_cancel is set to true then a cancel + -- is sent regardless of the rest of the presence info. + local should_cancel = event.stanza:get_child_text("call_cancel") + if should_cancel == "true" then + cancel() + return + end + + local missed = function() + cancel() + module:fire_event('jitsi-call-missed', { stanza = event.stanza; call_id = call_id; }); + end + + -- All other call flow actions will require a status. + if event.stanza:get_child_text("status") == nil then + return + end + + local switch = function(status) + case = { + [calling_status] = function() invite() end, + [busy_status] = function() cancel() end, + [rejected_status] = function() missed() end, + [expired_status] = function() missed() end, + [connected_status] = function() cancel() end + } + if case[status] then case[status]() end + end + + switch(event.stanza:get_child_text("status")) + end, + -101 +); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_census.lua b/roles/jitsi/files/prosody/modules/mod_muc_census.lua new file mode 100644 index 0000000..88f66a9 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_census.lua @@ -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": "", +-- "participants": <# participants>, +-- "created_time": , +-- }, +-- ... +-- ] +-- } +-- +-- to activate, add "muc_census" to the modules_enabled table in prosody.cfg.lua +-- +-- warning: this module is unprotected and intended for server admin use only. +-- when enabled, make sure to secure the endpoint at the web server or via +-- network filters + +local jid = require "util.jid"; +local json = require 'cjson.safe'; +local iterators = require "util.iterators"; +local util = module:require "util"; +local is_healthcheck_room = util.is_healthcheck_room; + +local have_async = pcall(require, "util.async"); +if not have_async then + module:log("error", "requires a version of Prosody with util.async"); + return; +end + +local async_handler_wrapper = module:require "util".async_handler_wrapper; + +local tostring = tostring; + +-- required parameter for custom muc component prefix, defaults to "conference" +local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference"); + +local leaked_rooms = 0; + +--- handles request to get number of participants in all rooms +-- @return GET response +function handle_get_room_census(event) + local host_session = prosody.hosts[muc_domain_prefix .. "." .. tostring(module.host)] + if not host_session or not host_session.modules.muc then + return { status_code = 400; } + end + + room_data = {} + leaked_rooms = 0; + for room in host_session.modules.muc.each_room() do + if not is_healthcheck_room(room.jid) then + local occupants = room._occupants; + local participant_count = 0; + local missing_connections_count = 0; + + if occupants then + for _, o in room:each_occupant() do + participant_count = participant_count + 1; + + -- let's check whether that occupant has connection in the full_sessions of prosody + -- attempt to detect leaked occupants/rooms. + if prosody.full_sessions[o.jid] == nil then + missing_connections_count = missing_connections_count + 1; + end + end + participant_count = participant_count - 1; -- subtract focus + end + + local leaked = false; + if participant_count > 0 and missing_connections_count == participant_count then + leaked = true; + leaked_rooms = leaked_rooms + 1; + end + + table.insert(room_data, { + room_name = room.jid; + participants = participant_count; + created_time = room.created_timestamp; + leaked = leaked; + }); + end + end + + census_resp = json.encode({ + room_census = room_data; + }); + return { status_code = 200; body = census_resp } +end + +function module.load() + module:depends("http"); + module:provides("http", { + default_path = "/"; + route = { + ["GET room-census"] = function (event) return async_handler_wrapper(event,handle_get_room_census) end; + }; + }); +end + +-- we calculate the stats on the configured interval (60 seconds by default) +local measure_leaked_rooms = module:measure('leaked_rooms', 'amount'); +module:hook_global('stats-update', function () + measure_leaked_rooms(leaked_rooms); +end); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_domain_mapper.lua b/roles/jitsi/files/prosody/modules/mod_muc_domain_mapper.lua new file mode 100644 index 0000000..c9c7a53 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_domain_mapper.lua @@ -0,0 +1,107 @@ +-- Maps MUC JIDs like room1@muc.foo.example.com to JIDs like [foo]room1@muc.example.com +-- Must be loaded on the client host in Prosody + +-- It is recommended to set muc_mapper_domain_base to the main domain being served (example.com) + +local filters = require "util.filters"; + +local muc_domain_base = module:get_option_string("muc_mapper_domain_base"); +if not muc_domain_base then + module:log("warn", "No 'muc_mapper_domain_base' option set, disabling muc_mapper plugin inactive"); + return +end + +local log_not_allowed_errors = module:get_option_boolean('muc_mapper_log_not_allowed_errors', false); + +local util = module:require "util"; +local room_jid_match_rewrite = util.room_jid_match_rewrite; +local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite; + +-- We must filter stanzas in order to hook in to all incoming and outgoing messaging which skips the stanza routers +function filter_stanza(stanza, session) + if stanza.skipMapping then + return stanza; + end + + if stanza.name == "message" or stanza.name == "iq" or stanza.name == "presence" then + -- module:log("debug", "Filtering stanza type %s to %s from %s",stanza.name,stanza.attr.to,stanza.attr.from); + if stanza.name == "iq" then + local conf = stanza:get_child('conference') + if conf then + -- module:log("debug", "Filtering stanza conference %s to %s from %s",conf.attr.room,stanza.attr.to,stanza.attr.from); + conf.attr.room = room_jid_match_rewrite(conf.attr.room, stanza) + end + end + if stanza.attr.to then + stanza.attr.to = room_jid_match_rewrite(stanza.attr.to, stanza) + end + if stanza.attr.from then + stanza.attr.from = internal_room_jid_match_rewrite(stanza.attr.from, stanza) + end + + if log_not_allowed_errors and stanza.name == 'presence' and stanza.attr.type == 'error' then + local error = stanza:get_child('error'); + if error and error.attr.type == 'cancel' + and error:get_child('not-allowed', 'urn:ietf:params:xml:ns:xmpp-stanzas') + and not session.jitsi_not_allowed_logged then + session.jitsi_not_allowed_logged = true; + session.log('error', 'Not allowed presence %s', stanza); + end + end + end + return stanza; +end + +function filter_session(session) + -- module:log("warn", "Session filters applied"); + filters.add_filter(session, "stanzas/out", filter_stanza); +end + +function module.load() + if module.reloading then + module:log("debug", "Reloading MUC mapper!"); + else + module:log("debug", "First load of MUC mapper!"); + end + filters.add_filter_hook(filter_session); +end + +function module.unload() + filters.remove_filter_hook(filter_session); +end + + +local function outgoing_stanza_rewriter(event) + local stanza = event.stanza; + if stanza.attr.to then + stanza.attr.to = room_jid_match_rewrite(stanza.attr.to, stanza) + end +end + +local function incoming_stanza_rewriter(event) + local stanza = event.stanza; + if stanza.attr.from then + stanza.attr.from = internal_room_jid_match_rewrite(stanza.attr.from, stanza) + end +end + +-- The stanza rewriters helper functions are attached for all stanza router hooks +local function hook_all_stanzas(handler, host_module, event_prefix) + for _, stanza_type in ipairs({ "message", "presence", "iq" }) do + for _, jid_type in ipairs({ "host", "bare", "full" }) do + host_module:hook((event_prefix or "")..stanza_type.."/"..jid_type, handler); + end + end +end + +function add_host(host) + module:log("info", "Loading mod_muc_domain_mapper for host %s!", host); + local host_module = module:context(host); + hook_all_stanzas(incoming_stanza_rewriter, host_module); + hook_all_stanzas(outgoing_stanza_rewriter, host_module, "pre-"); +end + +prosody.events.add_handler("host-activated", add_host); +for host in pairs(prosody.hosts) do + add_host(host); +end diff --git a/roles/jitsi/files/prosody/modules/mod_muc_end_meeting.lua b/roles/jitsi/files/prosody/modules/mod_muc_end_meeting.lua new file mode 100644 index 0000000..87b68a4 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_end_meeting.lua @@ -0,0 +1,113 @@ +-- A global module which can be used as http endpoint to end meetings. The provided token +--- in the request is verified whether it has the right to do so. +-- Copyright (C) 2023-present 8x8, Inc. + +module:set_global(); + +local util = module:require "util"; +local async_handler_wrapper = util.async_handler_wrapper; +local room_jid_match_rewrite = util.room_jid_match_rewrite; +local get_room_from_jid = util.get_room_from_jid; +local starts_with = util.starts_with; + +local neturl = require "net.url"; +local parse = neturl.parseQuery; + +-- will be initialized once the main virtual host module is initialized +local token_util; + +local muc_domain_base = module:get_option_string("muc_mapper_domain_base"); + +local asapKeyServer = module:get_option_string("prosody_password_public_key_repo_url", ""); + +local event_count = module:measure("muc_end_meeting_rate", "rate") +local event_count_success = module:measure("muc_end_meeting_success", "rate") + +function verify_token(token) + if token == nil then + module:log("warn", "no token provided"); + return false; + end + + local session = {}; + session.auth_token = token; + local verified, reason, msg = token_util:process_and_verify_token(session); + if not verified then + module:log("warn", "not a valid token %s %s", tostring(reason), tostring(msg)); + return false; + end + return true; +end + +function handle_terminate_meeting (event) + module:log("info", "Request for terminate meeting received: reqid %s", event.request.headers["request_id"]) + event_count() + if not event.request.url.query then + return { status_code = 400 }; + end + local params = parse(event.request.url.query); + local conference = params["conference"]; + local room_jid; + + if conference then + room_jid = room_jid_match_rewrite(conference) + else + module:log('warn', "conference param was not provided") + return { status_code = 400 }; + end + + -- verify access + local token = event.request.headers["authorization"] + if not token then + module:log("error", "Authorization header was not provided for conference %s", conference) + return { status_code = 401 }; + end + if starts_with(token, 'Bearer ') then + token = token:sub(8, #token) + else + module:log("error", "Authorization header is invalid") + return { status_code = 401 }; + end + + if not verify_token(token, room_jid) then + return { status_code = 401 }; + end + + local room = get_room_from_jid(room_jid); + if not room then + module:log("warn", "Room not found") + return { status_code = 404 }; + else + module:log("info", "Destroy room jid %s", room.jid) + room:destroy(nil, "The meeting has been terminated") + end + event_count_success() + return { status_code = 200 }; +end + + +-- module API called on virtual host added, passing the host module +function module.add_host(host_module) + if host_module.host == muc_domain_base then + -- the main virtual host + module:log("info", "Initialize token_util using %s", host_module.host) + + token_util = module:require "token/util".new(host_module); + + if asapKeyServer then + -- init token util with our asap keyserver + token_util:set_asap_key_server(asapKeyServer) + end + + module:log("info", "Adding http handler for /end-meeting on %s", host_module.host); + host_module:depends("http"); + host_module:provides("http", { + default_path = "/"; + route = { + ["POST end-meeting"] = function(event) + return async_handler_wrapper(event, handle_terminate_meeting) + end; + }; + }); + end +end diff --git a/roles/jitsi/files/prosody/modules/mod_muc_filter_access.lua b/roles/jitsi/files/prosody/modules/mod_muc_filter_access.lua new file mode 100644 index 0000000..09fefdc --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_filter_access.lua @@ -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 diff --git a/roles/jitsi/files/prosody/modules/mod_muc_flip.lua b/roles/jitsi/files/prosody/modules/mod_muc_flip.lua new file mode 100644 index 0000000..e59cfaa --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_flip.lua @@ -0,0 +1,211 @@ +-- 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_healthcheck_room = oss_util.is_healthcheck_room; +local process_host_module = oss_util.process_host_module; +local um_is_admin = require "core.usermanager".is_admin; +local inspect = require('inspect'); +local jid_bare = require "util.jid".bare; +local jid = require "util.jid"; +local MUC_NS = "http://jabber.org/protocol/muc"; + +local lobby_host; +local lobby_muc_service; + +local lobby_muc_component_config = 'lobby.' .. module:get_option_string("muc_mapper_domain_base"); +if lobby_muc_component_config == nil then + module:log('error', 'lobby not enabled missing lobby_muc config'); + return ; +end + +local function is_admin(occupant_jid) + return um_is_admin(occupant_jid, module.host); +end + +local function remove_flip_tag(stanza) + stanza:maptags(function(tag) + if tag and tag.name == "flip_device" then + -- module:log("debug", "Removing %s tag from presence stanza!", tag.name); + return nil; + else + return tag; + end + end) +end + +-- Make user that switch devices bypass lobby or password. +-- A user is considered to join from another device if the +-- id from jwt is the same as another occupant and the presence +-- stanza has flip_device tag +module:hook("muc-occupant-pre-join", function(event) + local room, occupant = event.room, event.occupant; + local session = event.origin; + local stanza = event.stanza; + if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then + return ; + end + local flip_device_tag = stanza:get_child("flip_device"); + if session.jitsi_meet_context_user and session.jitsi_meet_context_user.id then + local participants = room._data.participants_details or {}; + local id = session.jitsi_meet_context_user.id; + local first_device_occ_nick = participants[id]; + if flip_device_tag then + if first_device_occ_nick and session.jitsi_meet_context_features.flip and (session.jitsi_meet_context_features.flip == true or session.jitsi_meet_context_features.flip == "true") then + room._data.kicked_participant_nick = first_device_occ_nick; + room._data.flip_participant_nick = occupant.nick; + -- allow participant from flip device to bypass Lobby + local occupant_jid = stanza.attr.from; + local affiliation = room:get_affiliation(occupant_jid); + if not affiliation or affiliation == 'none' or affiliation == 'member' then + -- module:log("debug", "Bypass lobby invitee %s", occupant_jid) + occupant.role = "participant"; + room:set_affiliation(true, jid_bare(occupant_jid), "member") + room:save_occupant(occupant); + end + + if room:get_password() then + -- bypass password on the flip device + local join = stanza:get_child("x", MUC_NS); + if not join then + join = stanza:tag("x", { xmlns = MUC_NS }); + end + local password = join:get_child("password", MUC_NS); + if password then + join:maptags( + function(tag) + for k, v in pairs(tag) do + if k == "name" and v == "password" then + return nil + end + end + return tag + end); + end + join:tag("password", { xmlns = MUC_NS }):text(room:get_password()); + end + elseif not session.jitsi_meet_context_features.flip or session.jitsi_meet_context_features.flip == false or session.jitsi_meet_context_features.flip == "false" then + module:log("warn", "Flip device tag present without jwt permission") + --remove flip_device tag if somebody wants to abuse this feature + remove_flip_tag(stanza) + else + module:log("warn", "Flip device tag present without user from different device") + --remove flip_device tag if somebody wants to abuse this feature + remove_flip_tag(stanza) + end + end + -- update authenticated participant list + participants[id] = occupant.nick; + room._data.participants_details = participants + -- module:log("debug", "current details list %s", inspect(participants)) + else + if flip_device_tag then + module:log("warn", "Flip device tag present for a guest user") + -- remove flip_device tag because a guest want to do a sneaky join + remove_flip_tag(stanza) + end + end +end) + +-- Kick participant from the the first device from the main room and lobby if applies +-- and transfer role from the previous participant, this will take care of the grant +-- moderation case +module:hook("muc-occupant-joined", function(event) + local room, occupant = event.room, event.occupant; + if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then + return; + end + + if room._data.flip_participant_nick and occupant.nick == room._data.flip_participant_nick then + -- make joining participant from flip device have the same role and affiliation as for the previous device + local kicked_occupant = room:get_occupant_by_nick(room._data.kicked_participant_nick); + + if not kicked_occupant then + module:log("info", "Kick participant not found, nick %s from main room jid %s", + room._data.kicked_participant_nick, room.jid) + return; + end + + local initial_affiliation = room:get_affiliation(kicked_occupant.jid) or "member"; + -- module:log("debug", "Transfer affiliation %s to occupant jid %s", initial_affiliation, occupant.jid) + room:set_affiliation(true, occupant.bare_jid, initial_affiliation) + if initial_affiliation == "owner" then + event.occupant.role = "moderator"; + elseif initial_affiliation == "member" then + event.occupant.role = "participant"; + end + -- Kick participant from the first device from the main room + local kicked_participant_node_jid = jid.split(kicked_occupant.jid); + module:log("info", "Kick participant jid %s nick %s from main room jid %s", kicked_occupant.jid, room._data.kicked_participant_nick, room.jid) + room:set_role(true, room._data.kicked_participant_nick, 'none') + room:save_occupant(occupant); + -- Kick participant from the first device from the lobby room + if room._data.lobbyroom then + local lobby_room_jid = room._data.lobbyroom; + local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid) + for _, occupant in lobby_room:each_occupant() do + local node = jid.split(occupant.jid); + if kicked_participant_node_jid == node then + module:log("info", "Kick participant from lobby %s", occupant.jid) + lobby_room:set_role(true, occupant.nick, 'none') + end + end + end + event.room._data.flip_participant_nick = nil + event.room._data.kicked_participant_nick = nil; + end +end,-2) + +-- Update the local table after a participant leaves +module:hook("muc-occupant-left", function(event) + local occupant = event.occupant; + local session = event.origin; + if is_healthcheck_room(event.room.jid) or is_admin(occupant.bare_jid) then + return ; + end + if session and session.jitsi_meet_context_user and session.jitsi_meet_context_user.id then + local id = session.jitsi_meet_context_user.id + local participants = event.room._data.participants_details or {}; + local occupant_left_nick = participants[id] + if occupant_left_nick == occupant.nick then + participants[id] = nil + event.room._data.participants_details = participants + end + end +end) + +-- Add a flip_device tag on the unavailable presence from the kicked participant in order to silent the notifications +module:hook('muc-broadcast-presence', function(event) + local kicked_participant_nick = event.room._data.kicked_participant_nick + local stanza = event.stanza; + if kicked_participant_nick and stanza.attr.from == kicked_participant_nick and stanza.attr.type == 'unavailable' then + -- module:log("debug", "Add flip_device tag for presence unavailable from occupant nick %s", kicked_participant_nick) + stanza:tag("flip_device"):up(); + end +end) + +function process_lobby_muc_loaded(lobby_muc, host_module) + module:log('info', 'Lobby muc loaded'); + lobby_muc_service = lobby_muc; + lobby_host = module:context(host_module); +end + +-- process or waits to process the lobby muc component +process_host_module(lobby_muc_component_config, function(host_module, host) + -- lobby muc component created + module:log('info', 'Lobby component loaded %s', host); + + local muc_module = prosody.hosts[host].modules.muc; + if muc_module then + process_lobby_muc_loaded(muc_module, host_module); + else + module:log('debug', 'Will wait for muc to be available'); + prosody.hosts[host].events.add_handler('module-loaded', function(event) + if (event.module == 'muc') then + process_lobby_muc_loaded(prosody.hosts[host].modules.muc, host_module); + end + end); + end +end); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_hide_all.lua b/roles/jitsi/files/prosody/modules/mod_muc_hide_all.lua new file mode 100644 index 0000000..4f59b1c --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_hide_all.lua @@ -0,0 +1,6 @@ +-- This module makes all MUCs in Prosody unavailable on disco#items query +-- Copyright (C) 2023-present 8x8, Inc. + +module:hook("muc-room-pre-create", function(event) + event.room:set_hidden(true); +end, -1); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_jigasi_invite.lua b/roles/jitsi/files/prosody/modules/mod_muc_jigasi_invite.lua new file mode 100644 index 0000000..0920c71 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_jigasi_invite.lua @@ -0,0 +1,191 @@ +-- A http endpoint to invite jigasi to a meeting via http endpoint +-- jwt is used to validate access +-- Copyright (C) 2023-present 8x8, Inc. + +local jid_split = require "util.jid".split; +local hashes = require "util.hashes"; +local random = require "util.random"; +local st = require("util.stanza"); +local json = require 'cjson.safe'; +local util = module:require "util"; +local async_handler_wrapper = util.async_handler_wrapper; +local process_host_module = util.process_host_module; + +local muc_domain_base = module:get_option_string("muc_mapper_domain_base"); + +-- This module chooses jigasi from the brewery room, so it needs information for the configured brewery +local muc_domain = module:get_option_string("muc_internal_domain_base", 'internal.auth.' .. muc_domain_base); + +local jigasi_brewery_room_jid = module:get_option_string("muc_jigasi_brewery_jid", 'jigasibrewery@' .. muc_domain); + +local jigasi_bare_jid = module:get_option_string("muc_jigasi_jid", "jigasi@auth." .. muc_domain_base); +local focus_jid = module:get_option_string("muc_jicofo_brewery_jid", jigasi_brewery_room_jid .. "/focus"); + +local main_muc_service; +local JSON_CONTENT_TYPE = "application/json"; + +local event_count = module:measure("muc_invite_jigasi_rate", "rate") +local event_count_success = module:measure("muc_invite_jigasi_success", "rate") +local ASAP_KEY_SERVER = module:get_option_string("prosody_password_public_key_repo_url", ""); +local token_util = module:require "token/util".new(module); +if ASAP_KEY_SERVER then + -- init token util with our asap keyserver + token_util:set_asap_key_server(ASAP_KEY_SERVER) +end + +local function invite_jigasi(conference, phone_no) + local jigasi_brewery_room = main_muc_service.get_room_from_jid(jigasi_brewery_room_jid); + if not jigasi_brewery_room then + module:log("error", "Jigasi brewery room not found") + return 404, 'Brewery room was not found' + end + module:log("info", "Invite jigasi from %s to join conference %s and outbound phone_no %s", jigasi_brewery_room.jid, conference, phone_no) + + --select least stressed Jigasi + local least_stressed_value = math.huge; + local least_stressed_jigasi_jid; + for occupant_jid, occupant in jigasi_brewery_room:each_occupant() do + local _, _, resource = jid_split(occupant_jid); + if resource ~= 'focus' then + local occ = occupant:get_presence(); + local stats_child = occ:get_child("stats", "http://jitsi.org/protocol/colibri") + + local is_sip_jigasi = true; + for stats_tag in stats_child:children() do + if stats_tag.attr.name == 'supports_sip' and stats_tag.attr.value == 'false' then + is_sip_jigasi = false; + end + end + + if is_sip_jigasi then + for stats_tag in stats_child:children() do + if stats_tag.attr.name == 'stress_level' then + local stress_level = tonumber(stats_tag.attr.value); + module:log("debug", "Stressed level %s %s ", stress_level, occupant_jid) + if stress_level < least_stressed_value then + least_stressed_jigasi_jid = occupant_jid + least_stressed_value = stress_level + end + end + end + end + end + end + module:log("debug", "Least stressed jigasi selected jid %s value %s", least_stressed_jigasi_jid, least_stressed_value) + if not least_stressed_jigasi_jid then + module:log("error", "Cannot invite jigasi from room %s", jigasi_brewery_room.jid) + return 404, 'Jigasi not found' + end + + -- invite Jigasi to join the conference + local _, _, jigasi_res = jid_split(least_stressed_jigasi_jid) + local jigasi_full_jid = jigasi_bare_jid .. "/" .. jigasi_res; + local stanza_id = hashes.sha256(random.bytes(8), true); + + local invite_jigasi_stanza = st.iq({ xmlns = "jabber:client", type = "set", to = jigasi_full_jid, from = focus_jid, id = stanza_id }) + :tag("dial", { xmlns = "urn:xmpp:rayo:1", from = "fromnumber", to = phone_no }) + :tag("header", { xmlns = "urn:xmpp:rayo:1", name = "JvbRoomName", value = conference }) + + module:log("debug", "Invite jigasi stanza %s", invite_jigasi_stanza) + jigasi_brewery_room:route_stanza(invite_jigasi_stanza); + return 200 +end + +local function is_token_valid(token) + if token == nil then + module:log("warn", "no token provided"); + return false; + end + + local session = {}; + session.auth_token = token; + local verified, reason, msg = token_util:process_and_verify_token(session); + if not verified then + module:log("warn", "not a valid token %s %s", tostring(reason), tostring(msg)); + return false; + end + return true; +end + +local function handle_jigasi_invite(event) + module:log("debug", "Request for invite jigasi received: reqId %s", event.request.headers["request_id"]) + event_count() + local request = event.request; + -- verify access + local token = event.request.headers["authorization"] + if not token then + module:log("error", "Authorization header was not provided for conference %s", conference) + return { status_code = 401 }; + end + if util.starts_with(token, 'Bearer ') then + token = token:sub(8, #token) + else + module:log("error", "Authorization header is invalid") + return { status_code = 401 }; + end + if not is_token_valid(token) then + return { status_code = 401 }; + end + + -- verify payload + if request.headers.content_type ~= JSON_CONTENT_TYPE + or (not request.body or #request.body == 0) then + module:log("warn", "Wrong content type: %s or missing payload", request.headers.content_type); + return { status_code = 400; } + end + local payload, error = json.decode(request.body); + + if not payload then + module:log('error', 'Cannot decode json error:%s', error); + return { status_code = 400; } + end + + local conference = payload["conference"]; + local phone_no = payload["phoneNo"]; + if not conference then + module:log("warn", "Missing conference param") + return { status_code = 400; } + end + if not phone_no then + module:log("warn", "Missing phone no param") + return { status_code = 400; } + end + + --invite jigasi + local status_code, error_msg = invite_jigasi(conference, phone_no) + + if not error_msg then + event_count_success() + return { status_code = 200 } + else + return { status_code = status_code, body = json.encode({ error = error_msg }) } + end +end + +module:log("info", "Adding http handler for /invite-jigasi on %s", module.host); +module:depends("http"); +module:provides("http", { + default_path = "/"; + route = { + ["POST invite-jigasi"] = function(event) + return async_handler_wrapper(event, handle_jigasi_invite) + end; + }; +}); + +process_host_module(muc_domain, function(_, host) + local muc_module = prosody.hosts[host].modules.muc; + if muc_module then + main_muc_service = muc_module; + module:log('info', 'Found main_muc_service: %s', main_muc_service); + else + module:log('info', 'Will wait for muc to be available'); + prosody.hosts[host].events.add_handler('module-loaded', function(event) + if (event.module == 'muc') then + main_muc_service = prosody.hosts[host].modules.muc; + module:log('info', 'Found(on loaded) main_muc_service: %s', main_muc_service); + end + end); + end +end); + diff --git a/roles/jitsi/files/prosody/modules/mod_muc_kick_jigasi.lua b/roles/jitsi/files/prosody/modules/mod_muc_kick_jigasi.lua new file mode 100644 index 0000000..735ea0f --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_kick_jigasi.lua @@ -0,0 +1,158 @@ +-- http endpoint to kick participants, access is based on provided jwt token +-- the correct jigasi we fined based on the display name and the number provided +-- Copyright (C) 2023-present 8x8, Inc. + +local util = module:require "util"; +local async_handler_wrapper = util.async_handler_wrapper; +local is_sip_jigasi = util.is_sip_jigasi; +local starts_with = util.starts_with; +local formdecode = require "util.http".formdecode; +local urlencode = require "util.http".urlencode; +local jid = require "util.jid"; +local json = require 'cjson.safe'; + +local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference"); + +local muc_domain_base = module:get_option_string("muc_mapper_domain_base"); +if not muc_domain_base then + module:log("warn", "No 'muc_domain_base' option set, disabling kick check endpoint."); + return ; +end + +local json_content_type = "application/json"; + +local token_util = module:require "token/util".new(module); + +local asapKeyServer = module:get_option_string('prosody_password_public_key_repo_url', ''); +if asapKeyServer == '' then + module:log('warn', 'No "prosody_password_public_key_repo_url" option set, disabling kick endpoint.'); + return ; +end + +token_util:set_asap_key_server(asapKeyServer); + +--- Verifies the token +-- @param token the token we received +-- @param room_address the full room address jid +-- @return true if values are ok or false otherwise +function verify_token(token, room_address) + + if token == nil then + module:log("warn", "no token provided for %s", room_address); + return false; + end + + local session = {}; + session.auth_token = token; + local verified, reason, msg = token_util:process_and_verify_token(session); + if not verified then + module:log("warn", "not a valid token %s %s for %s", tostring(reason), tostring(msg), room_address); + return false; + end + + return true; +end + + +-- Validates the request by checking for required url param room and +-- validates the token provided with the request +-- @param request - The request to validate. +-- @return [error_code, room] +local function validate_and_get_room(request) + if not request.url.query then + module:log("warn", "No query"); + return 400, nil; + end + + local params = formdecode(request.url.query); + local room_name = urlencode(params.room) or ""; + local subdomain = urlencode(params.prefix) or ""; + + if not room_name then + module:log("warn", "Missing room param for %s", room_name); + return 400, nil; + end + + local room_address = jid.join(room_name, muc_domain_prefix.."."..muc_domain_base); + + if subdomain and subdomain ~= "" then + room_address = "["..subdomain.."]"..room_address; + end + + -- verify access + local token = request.headers["authorization"] + + if token and starts_with(token,'Bearer ') then + token = token:sub(8,#token) + end + + if not verify_token(token, room_address) then + return 403, nil; + end + + local room = get_room_from_jid(room_address); + + if not room then + module:log("warn", "No room found for %s", room_address); + return 404, nil; + else + return 200, room; + end +end + +function handle_kick_participant (event) + local request = event.request; + if request.headers.content_type ~= json_content_type + or (not request.body or #request.body == 0) then + module:log("warn", "Wrong content type: %s", request.headers.content_type); + return { status_code = 400; } + end + + local params, error = json.decode(request.body); + if not params then + module:log("warn", "Missing params error:%s", error); + return { status_code = 400; } + end + + local number = params["number"]; + + if not number then + module:log("warn", "Missing number param"); + return { status_code = 400; }; + end + + local error_code, room = validate_and_get_room(request); + + if error_code and error_code ~= 200 then + module:log("error", "Error validating %s", error_code); + return { error_code = 400; } + end + + if not room then + return { status_code = 404; } + end + + for _, occupant in room:each_occupant() do + local pr = occupant:get_presence(); + local displayName = pr:get_child_text( + 'nick', 'http://jabber.org/protocol/nick'); + + if is_sip_jigasi(pr) and displayName and starts_with(displayName, number) 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 + +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; + }; +}); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_limit_messages.lua b/roles/jitsi/files/prosody/modules/mod_muc_limit_messages.lua new file mode 100644 index 0000000..89230ba --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_limit_messages.lua @@ -0,0 +1,103 @@ +-- A module to limit the number of messages in a meeting +-- Needs to be activated under the muc component where the limit needs to be applied +-- Copyright (C) 2023-present 8x8, Inc. + +local id = require 'util.id'; +local st = require 'util.stanza'; + +local get_room_by_name_and_subdomain = module:require 'util'.get_room_by_name_and_subdomain; + +local count; +local check_token; + +local function load_config() + count = module:get_option_number('muc_limit_messages_count'); + check_token = module:get_option_boolean('muc_limit_messages_check_token', false); +end +load_config(); + +if not count then + module:log('warn', "No 'muc_limit_messages_count' option set, disabling module"); + return +end + +module:log('info', 'Loaded muc limits for %s, limit:%s, will check for authenticated users:%s', + module.host, count, check_token); + +local error_text = 'The message limit for the room has been reached. Messaging is now disabled.'; + +function on_message(event) + local stanza = event.stanza; + local body = stanza:get_child('body'); + -- we ignore any non groupchat message without a body + if not body then + if stanza.attr.type ~= 'groupchat' then -- lobby messages + return; + else + -- we want to pass through only polls answers + local json_data = stanza:get_child_text('json-message', 'http://jitsi.org/jitmeet'); + if json_data and string.find(json_data, 'answer-poll', 1, true) then + return; + end + end + end + + local session = event.origin; + if not session or not session.jitsi_web_query_room then + -- if this is a message from visitor, pass it through. Limits are applied in the visitor node. + if event.origin.type == 's2sin' then + return; + end + + return false; + end + + -- get room name with tenant and find room + local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix); + if not room then + module:log('warn', 'No room found found for %s/%s', + session.jitsi_web_query_prefix, session.jitsi_web_query_room); + return false; + end + + if check_token and session.auth_token then + -- there is an authenticated participant drop all limits + room._muc_messages_limit = false; + end + + if room._muc_messages_limit == false then + -- no limits for this room, just skip + return; + end + + if not room._muc_messages_limit_count then + room._muc_messages_limit_count = 0; + end + + room._muc_messages_limit_count = room._muc_messages_limit_count + 1; + + -- on the first message above the limit we set the limit and we send an announcement to the room + if room._muc_messages_limit_count == count + 1 then + module:log('warn', 'Room message limit reached: %s', room.jid); + + -- send a message to the room + local announcement = st.message({ from = room.jid, type = 'groupchat', id = id.medium(), }) + :tag('body'):text(error_text); + room:broadcast_message(announcement); + + room._muc_messages_limit = true; + end + + if room._muc_messages_limit == true then + -- return error to the sender of this message + event.origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', error_text)); + return true; + end +end + +-- handle messages sent in the component +-- 'message/host' is used for breakout rooms +module:hook('message/full', on_message); -- private messages +module:hook('message/bare', on_message); -- room messages + +module:hook_global('config-reloaded', load_config); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_lobby_rooms.lua b/roles/jitsi/files/prosody/modules/mod_muc_lobby_rooms.lua new file mode 100644 index 0000000..92d95e1 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_lobby_rooms.lua @@ -0,0 +1,660 @@ +-- This module added under the main virtual host domain +-- It needs a lobby muc component +-- +-- VirtualHost "jitmeet.example.com" +-- modules_enabled = { +-- "muc_lobby_rooms" +-- } +-- lobby_muc = "lobby.jitmeet.example.com" +-- main_muc = "conference.jitmeet.example.com" +-- +-- Component "lobby.jitmeet.example.com" "muc" +-- storage = "memory" +-- muc_room_cache_size = 1000 +-- restrict_room_creation = true +-- muc_room_locking = false +-- muc_room_default_public_jids = true +-- +-- we use async to detect Prosody 0.10 and earlier +local have_async = pcall(require, 'util.async'); + +if not have_async then + module:log('warn', 'Lobby rooms will not work with Prosody version 0.10 or less.'); + return; +end + +module:depends("jitsi_session"); + +local jid_split = require 'util.jid'.split; +local jid_bare = require 'util.jid'.bare; +local jid_prep = require "util.jid".prep; +local jid_resource = require "util.jid".resource; +local resourceprep = require "util.encodings".stringprep.resourceprep; +local json = require 'cjson.safe'; +local filters = require 'util.filters'; +local st = require 'util.stanza'; +local muc_util = module:require "muc/util"; +local valid_affiliations = muc_util.valid_affiliations; +local MUC_NS = 'http://jabber.org/protocol/muc'; +local MUC_USER_NS = 'http://jabber.org/protocol/muc#user'; +local DISCO_INFO_NS = 'http://jabber.org/protocol/disco#info'; +local DISPLAY_NAME_REQUIRED_FEATURE = 'http://jitsi.org/protocol/lobbyrooms#displayname_required'; +local LOBBY_IDENTITY_TYPE = 'lobbyrooms'; +local NOTIFY_JSON_MESSAGE_TYPE = 'lobby-notify'; +local NOTIFY_LOBBY_ENABLED = 'LOBBY-ENABLED'; +local NOTIFY_LOBBY_ACCESS_GRANTED = 'LOBBY-ACCESS-GRANTED'; +local NOTIFY_LOBBY_ACCESS_DENIED = 'LOBBY-ACCESS-DENIED'; + +local util = module:require "util"; +local ends_with = util.ends_with; +local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain; +local is_healthcheck_room = util.is_healthcheck_room; +local presence_check_status = util.presence_check_status; +local process_host_module = util.process_host_module; + +local main_muc_component_config = module:get_option_string('main_muc'); +if main_muc_component_config == nil then + module:log('error', 'lobby not enabled missing main_muc config'); + return ; +end +local lobby_muc_component_config = module:get_option_string('lobby_muc'); +if lobby_muc_component_config == nil then + module:log('error', 'lobby not enabled missing lobby_muc config'); + return ; +end + +local whitelist; +local check_display_name_required; +local function load_config() + whitelist = module:get_option_set('muc_lobby_whitelist', {}); + check_display_name_required + = module:get_option_boolean('muc_lobby_check_display_name_required', true); +end +load_config(); + +local lobby_muc_service; +local main_muc_service; + +function broadcast_json_msg(room, from, json_msg) + json_msg.type = NOTIFY_JSON_MESSAGE_TYPE; + + local occupant = room:get_occupant_by_real_jid(from); + if occupant then + local json_msg_str, error = json.encode(json_msg); + + if not json_msg_str then + module:log('error', 'Error broadcasting message room:%s', room.jid, error); + return; + end + + room:broadcast_message( + st.message({ type = 'groupchat', from = occupant.nick }) + :tag('json-message', {xmlns='http://jitsi.org/jitmeet'}) + :text(json_msg_str):up()); + end +end + +-- Sends a json message notifying for lobby enabled/disable +-- the message from is the actor that did the operation +function notify_lobby_enabled(room, actor, value) + broadcast_json_msg(room, actor, { + event = NOTIFY_LOBBY_ENABLED, + value = value + }); +end + +-- Sends a json message notifying that the jid was granted/denied access in lobby +-- the message from is the actor that did the operation +function notify_lobby_access(room, actor, jid, display_name, granted) + local notify_json = { + value = jid, + name = display_name + }; + if granted then + notify_json.event = NOTIFY_LOBBY_ACCESS_GRANTED; + else + notify_json.event = NOTIFY_LOBBY_ACCESS_DENIED; + end + + broadcast_json_msg(room, actor, notify_json); +end + +function filter_stanza(stanza) + if not stanza.attr or not stanza.attr.from or not main_muc_service or not lobby_muc_service then + return stanza; + end + -- Allow self-presence (code=110) + local node, from_domain = jid_split(stanza.attr.from); + + if from_domain == lobby_muc_component_config then + if stanza.name == 'presence' then + local muc_x = stanza:get_child('x', MUC_NS..'#user'); + if not muc_x or presence_check_status(muc_x, '110') then + return stanza; + end + + local lobby_room_jid = jid_bare(stanza.attr.from); + local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid); + if not lobby_room then + module:log('warn', 'No lobby room found %s', lobby_room_jid); + return stanza; + end + + -- check is an owner, only owners can receive the presence + -- do not forward presence of owners (other than unavailable) + local room = main_muc_service.get_room_from_jid(jid_bare(node .. '@' .. main_muc_component_config)); + local item = muc_x:get_child('item'); + if not room + or stanza.attr.type == 'unavailable' + or (room.get_affiliation(room, stanza.attr.to) == 'owner' + and room.get_affiliation(room, item.attr.jid) ~= 'owner') then + return stanza; + end + + local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner'; + local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from); + if not from_occupant then + if is_to_moderator then + return stanza; + end + + module:log('warn', 'No lobby occupant found %s', stanza.attr.from); + return nil; + end + + local from_real_jid; + for real_jid in from_occupant:each_session() do + from_real_jid = real_jid; + end + + if is_to_moderator and lobby_room:get_affiliation(from_real_jid) ~= 'owner' then + return stanza; + end + elseif stanza.name == 'iq' and stanza:get_child('query', DISCO_INFO_NS) then + -- allow disco info from the lobby component + return stanza; + elseif stanza.name == 'message' then + -- allow messages to or from moderator + local lobby_room_jid = jid_bare(stanza.attr.from); + local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid); + + if not lobby_room then + module:log('warn', 'No lobby room found %s', stanza.attr.from); + return nil; + end + + local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner'; + local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from); + + local from_real_jid; + if from_occupant then + for real_jid in from_occupant:each_session() do + from_real_jid = real_jid; + end + end + + local is_from_moderator = lobby_room:get_affiliation(from_real_jid) == 'owner'; + + if is_to_moderator or is_from_moderator then + return stanza; + end + return nil; + end + + return nil; + else + return stanza; + end +end +function filter_session(session) + -- domain mapper is filtering on default priority 0, and we need it after that + filters.add_filter(session, 'stanzas/out', filter_stanza, -1); +end + +-- actor can be null if called from backend (another module using hook create-lobby-room) +function attach_lobby_room(room, actor) + local node = jid_split(room.jid); + local lobby_room_jid = node .. '@' .. lobby_muc_component_config; + if not lobby_muc_service.get_room_from_jid(lobby_room_jid) then + local new_room = lobby_muc_service.create_room(lobby_room_jid); + -- set persistent the lobby room to avoid it to be destroyed + -- there are cases like when selecting new moderator after the current one leaves + -- which can leave the room with no occupants and it will be destroyed and we want to + -- avoid lobby destroy while it is enabled + new_room:set_persistent(true); + module:log("info","Lobby room jid = %s created from:%s", lobby_room_jid, actor); + new_room.main_room = room; + room._data.lobbyroom = new_room.jid; + room:save(true); + return true + end + return false +end + +-- destroys lobby room for the supplied main room +function destroy_lobby_room(room, newjid, message) + if not message then + message = 'Lobby room closed.'; + end + if lobby_muc_service and room and room._data.lobbyroom then + local lobby_room_obj = lobby_muc_service.get_room_from_jid(room._data.lobbyroom); + if lobby_room_obj then + lobby_room_obj:set_persistent(false); + lobby_room_obj:destroy(newjid, message); + end + room._data.lobbyroom = nil; + end +end + +-- This is a copy of the function(handle_admin_query_set_command) from prosody 12 (d7857ef7843a) +function handle_admin_query_set_command_item(self, origin, stanza, item) + if not item then + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + return true; + end + if item.attr.jid then -- Validate provided JID + item.attr.jid = jid_prep(item.attr.jid); + if not item.attr.jid then + origin.send(st.error_reply(stanza, "modify", "jid-malformed")); + return true; + elseif jid_resource(item.attr.jid) then + origin.send(st.error_reply(stanza, "modify", "jid-malformed", "Bare JID expected, got full JID")); + return true; + end + end + if item.attr.nick then -- Validate provided nick + item.attr.nick = resourceprep(item.attr.nick); + if not item.attr.nick then + origin.send(st.error_reply(stanza, "modify", "jid-malformed", "invalid nickname")); + return true; + end + end + if not item.attr.jid and item.attr.nick then + -- COMPAT Workaround for Miranda sending 'nick' instead of 'jid' when changing affiliation + local occupant = self:get_occupant_by_nick(self.jid.."/"..item.attr.nick); + if occupant then item.attr.jid = occupant.bare_jid; end + elseif item.attr.role and not item.attr.nick and item.attr.jid then + -- Role changes should use nick, but we have a JID so pull the nick from that + local nick = self:get_occupant_jid(item.attr.jid); + if nick then item.attr.nick = jid_resource(nick); end + end + local actor = stanza.attr.from; + local reason = item:get_child_text("reason"); + local success, errtype, err + if item.attr.affiliation and item.attr.jid and not item.attr.role then + local registration_data; + if item.attr.nick then + local room_nick = self.jid.."/"..item.attr.nick; + local existing_occupant = self:get_occupant_by_nick(room_nick); + if existing_occupant and existing_occupant.bare_jid ~= item.attr.jid then + module:log("debug", "Existing occupant for %s: %s does not match %s", room_nick, existing_occupant.bare_jid, item.attr.jid); + self:set_role(true, room_nick, nil, "This nickname is reserved"); + end + module:log("debug", "Reserving %s for %s (%s)", item.attr.nick, item.attr.jid, item.attr.affiliation); + registration_data = { reserved_nickname = item.attr.nick }; + end + success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, reason, registration_data); + elseif item.attr.role and item.attr.nick and not item.attr.affiliation then + success, errtype, err = self:set_role(actor, self.jid.."/"..item.attr.nick, item.attr.role, reason); + else + success, errtype, err = nil, "cancel", "bad-request"; + end + self:save(true); + if not success then + origin.send(st.error_reply(stanza, errtype, err)); + else + origin.send(st.reply(stanza)); + end +end + +-- this is extracted from prosody to handle multiple invites +function handle_mediated_invite(room, origin, stanza, payload, host_module) + local invitee = jid_prep(payload.attr.to); + if not invitee then + origin.send(st.error_reply(stanza, "cancel", "jid-malformed")); + return true; + elseif host_module:fire_event("muc-pre-invite", {room = room, origin = origin, stanza = stanza}) then + return true; + end + local invite = muc_util.filter_muc_x(st.clone(stanza)); + invite.attr.from = room.jid; + invite.attr.to = invitee; + invite:tag('x', { xmlns = MUC_USER_NS }) + :tag('invite', {from = stanza.attr.from;}) + :tag('reason'):text(payload:get_child_text("reason")):up() + :up() + :up(); + if not host_module:fire_event("muc-invite", {room = room, stanza = invite, origin = origin, incoming = stanza}) then + local join = invite:get_child('x', MUC_USER_NS); + -- make sure we filter password added by any module + if join then + local password = join:get_child('password'); + if password then + join:maptags( + function(tag) + for k, v in pairs(tag) do + if k == 'name' and v == 'password' then + return nil + end + end + return tag + end + ); + end + end + room:route_stanza(invite); + end + return true; +end + +local prosody_overrides = { + -- handle multiple items at once + handle_admin_query_set_command = function(self, origin, stanza) + for i=1,#stanza.tags[1] do + if handle_admin_query_set_command_item(self, origin, stanza, stanza.tags[1].tags[i]) then + return true; + end + end + return true; + end, + -- this is extracted from prosody to handle multiple invites + handle_message_to_room = function(room, origin, stanza, host_module) + local type = stanza.attr.type; + if type == nil or type == "normal" then + local x = stanza:get_child("x", MUC_USER_NS); + if x then + local handled = false; + for _, payload in pairs(x.tags) do + if payload ~= nil and payload.name == "invite" and payload.attr.to then + handled = true; + handle_mediated_invite(room, origin, stanza, payload, host_module) + end + end + return handled; + end + end + end +}; + +-- operates on already loaded lobby muc module +function process_lobby_muc_loaded(lobby_muc, host_module) + module:log('debug', 'Lobby muc loaded'); + lobby_muc_service = lobby_muc; + + -- enable filtering presences in the lobby muc rooms + filters.add_filter_hook(filter_session); + + -- Advertise lobbyrooms support on main domain so client can pick up the address and use it + module:add_identity('component', LOBBY_IDENTITY_TYPE, lobby_muc_component_config); + + -- Tag the disco#info response with a feature that display name is required + -- when the conference name from the web request has a lobby enabled. + host_module:hook('host-disco-info-node', function (event) + local session, reply, node = event.origin, event.reply, event.node; + if node == LOBBY_IDENTITY_TYPE + and session.jitsi_web_query_room + and check_display_name_required then + local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix); + + if room and room._data.lobbyroom then + reply:tag('feature', { var = DISPLAY_NAME_REQUIRED_FEATURE }):up(); + end + end + event.exists = true; + end); + + local room_mt = lobby_muc_service.room_mt; + -- we base affiliations (roles) in lobby muc component to be based on the roles in the main muc + room_mt.get_affiliation = function(room, jid) + if not room.main_room then + module:log('error', 'No main room(%s) for %s!', room.jid, jid); + return 'none'; + end + + -- moderators in main room are moderators here + local role = room.main_room.get_affiliation(room.main_room, jid); + if role then + return role; + end + + return 'none'; + end + + -- listens for kicks in lobby room, 307 is the status for kick according to xep-0045 + host_module:hook('muc-broadcast-presence', function (event) + local actor, occupant, room, x = event.actor, event.occupant, event.room, event.x; + if presence_check_status(x, '307') then + local display_name = occupant:get_presence():get_child_text( + 'nick', 'http://jabber.org/protocol/nick'); + -- we need to notify in the main room + notify_lobby_access(room.main_room, actor, occupant.nick, display_name, false); + end + end); +end + +-- process or waits to process the lobby muc component +process_host_module(lobby_muc_component_config, function(host_module, host) + -- lobby muc component created + module:log('info', 'Lobby component loaded %s', host); + + local muc_module = prosody.hosts[host].modules.muc; + if muc_module then + process_lobby_muc_loaded(muc_module, host_module); + else + module:log('debug', 'Will wait for muc to be available'); + prosody.hosts[host].events.add_handler('module-loaded', function(event) + if (event.module == 'muc') then + process_lobby_muc_loaded(prosody.hosts[host].modules.muc, host_module); + end + end); + end +end); + +-- process or waits to process the main muc component +process_host_module(main_muc_component_config, function(host_module, host) + main_muc_service = prosody.hosts[host].modules.muc; + + -- hooks when lobby is enabled to create its room, only done here or by admin + host_module:hook('muc-config-submitted', function(event) + local actor, room = event.actor, event.room; + local actor_node = jid_split(actor); + if actor_node == 'focus' then + return; + end + local members_only = event.fields['muc#roomconfig_membersonly'] and true or nil; + if members_only then + local lobby_created = attach_lobby_room(room, actor); + if lobby_created then + module:fire_event('jitsi-lobby-enabled', { room = room; }); + event.status_codes['104'] = true; + notify_lobby_enabled(room, actor, true); + end + elseif room._data.lobbyroom then + destroy_lobby_room(room, room.jid); + module:fire_event('jitsi-lobby-disabled', { room = room; }); + notify_lobby_enabled(room, actor, false); + end + end); + host_module:hook('muc-room-destroyed',function(event) + local room = event.room; + if room._data.lobbyroom then + destroy_lobby_room(room, nil); + end + end); + host_module:hook('muc-disco#info', function (event) + local room = event.room; + if (room._data.lobbyroom and room:get_members_only()) then + table.insert(event.form, { + name = 'muc#roominfo_lobbyroom'; + label = 'Lobby room jid'; + value = ''; + }); + event.formdata['muc#roominfo_lobbyroom'] = room._data.lobbyroom; + end + end); + + host_module:hook('muc-occupant-pre-join', function (event) + local occupant, room, stanza = event.occupant, event.room, event.stanza; + + if is_healthcheck_room(room.jid) or not room:get_members_only() or ends_with(occupant.nick, '/focus') then + return; + end + + local join = stanza:get_child('x', MUC_NS); + if not join then + return; + end + + local invitee = event.stanza.attr.from; + local invitee_bare_jid = jid_bare(invitee); + local _, invitee_domain = jid_split(invitee); + local whitelistJoin = false; + + -- whitelist participants + if whitelist:contains(invitee_domain) or whitelist:contains(invitee_bare_jid) then + whitelistJoin = true; + end + + local password = join:get_child_text('password', MUC_NS); + if password and room:get_password() and password == room:get_password() then + whitelistJoin = true; + end + + if whitelistJoin then + local affiliation = room:get_affiliation(invitee); + -- if it was already set to be whitelisted member + if not affiliation or affiliation == 'none' or affiliation == 'member' then + occupant.role = 'participant'; + room:set_affiliation(true, invitee_bare_jid, 'member'); + room:save_occupant(occupant); + + return; + end + elseif room:get_password() then + local affiliation = room:get_affiliation(invitee); + -- if pre-approved and password is set for the room, add the password to allow joining + if affiliation == 'member' and not password then + join:tag('password', { xmlns = MUC_NS }):text(room:get_password()); + end + end + + -- Check for display name if missing return an error + local displayName = stanza:get_child_text('nick', 'http://jabber.org/protocol/nick'); + if (not displayName or #displayName == 0) and not room._data.lobby_skip_display_name_check then + local reply = st.error_reply(stanza, 'modify', 'not-acceptable'); + reply.tags[1].attr.code = '406'; + reply:tag('displayname-required', { xmlns = 'http://jitsi.org/jitmeet', lobby = 'true' }):up():up(); + + event.origin.send(reply:tag('x', {xmlns = MUC_NS})); + return true; + end + + -- we want to add the custom lobbyroom field to fill in the lobby room jid + local invitee = event.stanza.attr.from; + local affiliation = room:get_affiliation(invitee); + if not affiliation or affiliation == 'none' then + local reply = st.error_reply(stanza, 'auth', 'registration-required'); + reply.tags[1].attr.code = '407'; + if room._data.lobby_extra_reason then + reply:tag(room._data.lobby_extra_reason, { xmlns = 'http://jitsi.org/jitmeet' }):up(); + end + reply:tag('lobbyroom', { xmlns = 'http://jitsi.org/jitmeet' }):text(room._data.lobbyroom):up():up(); + + -- TODO: Drop this tag at some point (when all mobile clients and jigasi are updated), as this violates the rfc + reply:tag('lobbyroom'):text(room._data.lobbyroom):up(); + + event.origin.send(reply:tag('x', {xmlns = MUC_NS})); + return true; + end + end, -4); -- the default hook on members_only module is on -5 + + -- listens for invites for participants to join the main room + host_module:hook('muc-invite', function(event) + local room, stanza = event.room, event.stanza; + local invitee = stanza.attr.to; + local from = stanza:get_child('x', MUC_USER_NS) + :get_child('invite').attr.from; + + if lobby_muc_service and room._data.lobbyroom then + local lobby_room_obj = lobby_muc_service.get_room_from_jid(room._data.lobbyroom); + if lobby_room_obj then + local occupant = lobby_room_obj:get_occupant_by_real_jid(invitee); + if occupant then + local display_name = occupant:get_presence():get_child_text( + 'nick', 'http://jabber.org/protocol/nick'); + + notify_lobby_access(room, from, occupant.nick, display_name, true); + end + end + end + end); + + -- listen for admin set + for event_name, method in pairs { + -- Normal room interactions + ["iq-set/bare/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ; + ["message/bare"] = "handle_message_to_room" ; + -- Host room + ["iq-set/host/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ; + ["message/host"] = "handle_message_to_room" ; + } do + host_module:hook(event_name, function (event) + local origin, stanza = event.origin, event.stanza; + local room_jid = jid_bare(stanza.attr.to); + local room = get_room_from_jid(room_jid); + + if room then + return prosody_overrides[method](room, origin, stanza, host_module); + end + end, 1) -- make sure we handle it before prosody that uses priority -2 for this + end +end); + +function handle_create_lobby(event) + local room = event.room; + + -- since this is called by backend rather than triggered by UI, we need to handle a few additional things: + -- 1. Make sure existing participants are already members or they will get kicked out when set_members_only(true) + -- 2. Trigger a 104 (config change) status message so UI state is properly updated for existing users + + -- make sure all existing occupants are members + for _, occupant in room:each_occupant() do + local affiliation = room:get_affiliation(occupant.bare_jid); + if valid_affiliations[affiliation or "none"] < valid_affiliations.member then + room:set_affiliation(true, occupant.bare_jid, 'member'); + end + end + -- Now it is safe to set the room to members only + room:set_members_only(true); + room._data.lobby_extra_reason = event.reason; + room._data.lobby_skip_display_name_check = event.skip_display_name_check; + + -- Trigger a presence with 104 so existing participants retrieves new muc#roomconfig + room:broadcast_message( + st.message({ type='groupchat', from=room.jid }) + :tag('x', { xmlns = MUC_USER_NS }) + :tag('status', { code='104' }) + ); + + -- Attach the lobby room. + attach_lobby_room(room); +end + +function handle_destroy_lobby(event) + local room = event.room; + + -- since this is called by backend rather than triggered by UI, we need to + -- trigger a 104 (config change) status message so UI state is properly updated for existing users (and jicofo) + destroy_lobby_room(room, event.newjid, event.message); + + -- Trigger a presence with 104 so existing participants retrieves new muc#roomconfig + room:broadcast_message( + st.message({ type='groupchat', from=room.jid }) + :tag('x', { xmlns = MUC_USER_NS }) + :tag('status', { code='104' }) + ); +end + +module:hook_global('config-reloaded', load_config); +module:hook_global('create-lobby-room', handle_create_lobby); +module:hook_global('destroy-lobby-room', handle_destroy_lobby); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_max_occupants.lua b/roles/jitsi/files/prosody/modules/mod_muc_max_occupants.lua new file mode 100644 index 0000000..63a0897 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_max_occupants.lua @@ -0,0 +1,72 @@ +-- MUC Max Occupants +-- Configuring muc_max_occupants will set a limit of the maximum number +-- of participants that will be able to join in a room. +-- Participants in muc_access_whitelist will not be counted for the +-- max occupants value (values are jids like recorder@jitsi.meeet.example.com). +-- This module is configured under the muc component that is used for jitsi-meet +local split_jid = require "util.jid".split; +local st = require "util.stanza"; +local it = require "util.iterators"; +local is_healthcheck_room = module:require "util".is_healthcheck_room; + +local whitelist = module:get_option_set("muc_access_whitelist"); +local MAX_OCCUPANTS = module:get_option_number("muc_max_occupants", -1); + +local function count_keys(t) + return it.count(it.keys(t)); +end + +local function check_for_max_occupants(event) + local room, origin, stanza = event.room, event.origin, event.stanza; + local user, domain, res = split_jid(stanza.attr.from); + + if is_healthcheck_room(room.jid) then + return; + end + + --no user object means no way to check for max occupants + if user == nil then + return + end + -- If we're a whitelisted user joining the room, don't bother checking the max + -- occupants. + if whitelist and (whitelist:contains(domain) or whitelist:contains(user..'@'..domain)) then + return; + end + + if room and not room._jid_nick[stanza.attr.from] then + local max_occupants_by_room = event.room._data.max_occupants; + local count = count_keys(room._occupants); + -- if no of occupants limit is set per room basis use + -- that settings otherwise use the global one + local slots = max_occupants_by_room or MAX_OCCUPANTS; + + -- If there is no whitelist, just check the count. + if not whitelist and count >= slots then + module:log("info", "Attempt to enter a maxed out MUC"); + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + return true; + end + + -- TODO: Are Prosody hooks atomic, or is this a race condition? + -- For each person in the room that's not on the whitelist, subtract one + -- from the count. + for _, occupant in room:each_occupant() do + user, domain, res = split_jid(occupant.bare_jid); + if not whitelist or (not whitelist:contains(domain) and not whitelist:contains(user..'@'..domain)) then + slots = slots - 1 + end + end + + -- If the room is full (<0 slots left), error out. + if slots <= 0 then + module:log("info", "Attempt to enter a maxed out MUC"); + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + return true; + end + end +end + +if MAX_OCCUPANTS > 0 then + module:hook("muc-occupant-pre-join", check_for_max_occupants, 10); +end diff --git a/roles/jitsi/files/prosody/modules/mod_muc_meeting_id.lua b/roles/jitsi/files/prosody/modules/mod_muc_meeting_id.lua new file mode 100644 index 0000000..00eecac --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_meeting_id.lua @@ -0,0 +1,133 @@ +local queue = require "util.queue"; +local uuid_gen = require "util.uuid".generate; +local main_util = module:require "util"; +local ends_with = main_util.ends_with; +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 um_is_admin = require 'core.usermanager'.is_admin; +local function is_admin(jid) + return um_is_admin(jid, module.host); +end + +local QUEUE_MAX_SIZE = 500; + +-- Module that 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) + +-- 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); + +--- 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 room, stanza = event.room, event.stanza; + + -- we skip processing only if jicofo_lock is set to false + if room._data.jicofo_lock == false or is_healthcheck_room(room.jid) then + return; + end + + local occupant = event.occupant; + if ends_with(occupant.nick, '/focus') then + module:fire_event('jicofo-unlock-room', { room = room; }); + else + room._data.jicofo_lock = true; + if not room.pre_join_queue then + room.pre_join_queue = queue.new(QUEUE_MAX_SIZE); + end + + if not room.pre_join_queue:push(event) then + module:log('error', 'Error enqueuing occupant event for: %s', occupant.nick); + return true; + end + module:log('debug', 'Occupant pushed to prejoin queue %s', occupant.nick); + + -- stop processing + return true; + end +end, 8); -- just after the rate limit + +function handle_jicofo_unlock(event) + local room = event.room; + + room._data.jicofo_lock = false; + if not room.pre_join_queue then + return; + end + + -- and now let's handle all pre_join_queue events + for _, ev in room.pre_join_queue:items() do + -- if the connection was closed while waiting in the queue, ignore + if ev.origin.conn then + module:log('debug', 'Occupant processed from queue %s', ev.occupant.nick); + room:handle_normal_presence(ev.origin, ev.stanza); + end + end + room.pre_join_queue = nil; +end + +module:hook('jicofo-unlock-room', handle_jicofo_unlock); + +-- make sure we remove nick if someone is sending it with a message to protect +-- forgery of display name +module:hook("muc-occupant-groupchat", function(event) + event.stanza:remove_children('nick', 'http://jabber.org/protocol/nick'); +end, 45); -- prosody check is prio 50, we want to run after it diff --git a/roles/jitsi/files/prosody/modules/mod_muc_password_check.lua b/roles/jitsi/files/prosody/modules/mod_muc_password_check.lua new file mode 100644 index 0000000..7a3c540 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_password_check.lua @@ -0,0 +1,186 @@ +local inspect = require "inspect"; +local formdecode = require "util.http".formdecode; +local urlencode = require "util.http".urlencode; +local jid = require "util.jid"; +local json = require 'cjson.safe'; +local util = module:require "util"; +local async_handler_wrapper = util.async_handler_wrapper; +local starts_with = util.starts_with; +local process_host_module = util.process_host_module; +local token_util = module:require "token/util".new(module); + +-- option to enable/disable room API token verifications +local enableTokenVerification += module:get_option_boolean("enable_password_token_verification", true); + +local muc_domain_base = module:get_option_string("muc_mapper_domain_base"); +if not muc_domain_base then + module:log("warn", "No 'muc_domain_base' option set, disabling password check endpoint."); + return ; +end +local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference"); + +local json_content_type = "application/json"; + +--- Verifies the token +-- @param token the token we received +-- @param room_address the full room address jid +-- @return true if values are ok or false otherwise +function verify_token(token, room_address) + if not enableTokenVerification then + return true; + end + + -- if enableTokenVerification is enabled and we do not have token + -- stop here, cause the main virtual host can have guest access enabled + -- (allowEmptyToken = true) and we will allow access to rooms info without + -- a token + if token == nil then + module:log("warn", "no token provided for %s", room_address); + return false; + end + + local session = {}; + session.auth_token = token; + local verified, reason, msg = token_util:process_and_verify_token(session); + if not verified then + module:log("warn", "not a valid token %s %s for %s", tostring(reason), tostring(msg), room_address); + return false; + end + + return true; +end + +-- Validates the request by checking for required url param room and +-- validates the token provided with the request +-- @param request - The request to validate. +-- @return [error_code, room] +local function validate_and_get_room(request) + if not request.url.query then + module:log("warn", "No query"); + return 400, nil; + end + + local params = formdecode(request.url.query); + local room_name = urlencode(params.room) or ""; + local subdomain = urlencode(params.prefix) or ""; + + if not room_name then + module:log("warn", "Missing room param for %s", room_name); + return 400, nil; + end + + local room_address = jid.join(room_name, muc_domain_prefix.."."..muc_domain_base); + + if subdomain and subdomain ~= "" then + room_address = "["..subdomain.."]"..room_address; + end + + -- verify access + local token = request.headers["authorization"] + + if token and starts_with(token,'Bearer ') then + token = token:sub(8,#token) + end + + if not verify_token(token, room_address) then + return 403, nil; + end + + local room = get_room_from_jid(room_address); + + if not room then + module:log("warn", "No room found for %s", room_address); + return 404, nil; + else + return 200, room; + end +end + +function handle_validate_room_password (event) + local request = event.request; + + if request.headers.content_type ~= json_content_type + or (not request.body or #request.body == 0) then + module:log("warn", "Wrong content type: %s", request.headers.content_type); + return { status_code = 400; } + end + + local params, error = json.decode(request.body); + if not params then + module:log("warn", "Missing params error:%s", error); + return { status_code = 400; } + end + + local passcode = params["passcode"]; + + if not passcode then + module:log("warn", "Missing passcode param"); + return { status_code = 400; }; + end + + local error_code, room = validate_and_get_room(request); + + if not room then + return { status_code = error_code; } + end + + local json_msg_str, error_encode = json.encode({ valid = (room:get_password() == passcode) }); + if not json_msg_str then + module:log('error', 'Cannot encode json room:%s error:%s', room.jid, error_encode); + return { status_code = 400; }; + end + + local PUT_response = { + headers = { content_type = "application/json"; }; + body = json_msg_str; + }; + + -- module:log("debug","Sending response for room password validate: %s", inspect(PUT_response)); + + return PUT_response; +end + +--- Handles request for retrieving the room participants details +-- @param event the http event, holds the request query +-- @return GET response, containing a json with participants details +function handle_get_room_password (event) + local error_code, room = validate_and_get_room(event.request); + + if not room then + return { status_code = error_code; } + end + + room_details = {}; + room_details["conference"] = room.jid; + room_details["passcodeProtected"] = room:get_password() ~= nil; + room_details["lobbyEnabled"] = room._data ~= nil and room._data.lobbyroom ~= nil; + + local json_msg_str, error = json.encode(room_details); + if not json_msg_str then + module:log('error', 'Cannot encode json room:%s error:%s', room.jid, error); + return { status_code = 400; }; + end + + local GET_response = { + headers = { + content_type = "application/json"; + }; + body = json_msg_str; + }; + -- module:log("debug","Sending response for room password: %s", inspect(GET_response)); + + return GET_response; +end + +process_host_module(muc_domain_base, function(host_module, host) + module:log("info","Adding http handler for /room-info on %s", host_module.host); + host_module:depends("http"); + host_module:provides("http", { + default_path = "/"; + route = { + ["GET room-info"] = function (event) return async_handler_wrapper(event, handle_get_room_password) end; + ["PUT room-info"] = function (event) return async_handler_wrapper(event, handle_validate_room_password) end; + }; + }); +end); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_password_whitelist.lua b/roles/jitsi/files/prosody/modules/mod_muc_password_whitelist.lua new file mode 100644 index 0000000..ea892e9 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_password_whitelist.lua @@ -0,0 +1,56 @@ +--- AUTHOR: https://gist.github.com/legastero Lance Stout +local jid_split = require "util.jid".split; +local whitelist = module:get_option_set("muc_password_whitelist"); + +local MUC_NS = "http://jabber.org/protocol/muc"; + + +module:hook("muc-occupant-pre-join", function (event) + local room, stanza = event.room, event.stanza; + + local user, domain, res = jid_split(event.stanza.attr.from); + + --no user object means no way to check whitelist + if user == nil then + return + end + + if not whitelist then + return; + end + if not whitelist:contains(domain) and not whitelist:contains(user..'@'..domain) then + return; + end + + local join = stanza:get_child("x", MUC_NS); + if not join then + join = stanza:tag("x", { xmlns = MUC_NS }); + end + + local password = join:get_child("password", MUC_NS); + if password then + -- removes stat_longest_queue then + stat_longest_queue = queue:count(); + measure_longest_queue(stat_longest_queue); + end + + return true; + end +end + +-- process join_rate_presence_queue in the room and pops element passing them to handle_normal_presence +-- returns 1 if we want to reschedule it after 1 second +local function timer_process_queue_elements (rate, queue, process, queue_empty_cb) + if not queue or queue:count() == 0 or queue.empty then + return; + end + + for _ = 1, rate do + local ev = queue:pop(); + if ev then + process(ev); + end + end + + -- if there are elements left, schedule an execution in a second + if queue:count() > 0 then + return 1; + else + queue_empty_cb(); + end +end + +-- we check join rate before occupant joins. If rate is exceeded we queue the events and start a timer +-- that will run every second processing the events passing them to the room handling function handle_normal_presence +-- from where those arrived, this way we keep a maximum rate of joining +module:hook("muc-occupant-pre-join", function (event) + local room, stanza = event.room, event.stanza; + + -- skipping events we had produced and clear our flag + if stanza.delayed_join_skip == true then + event.stanza.delayed_join_skip = nil; + return nil; + end + + local throttle = room.join_rate_throttle; + if not room.join_rate_throttle then + throttle = new_throttle(join_rate_per_conference, 1); -- rate per one second + room.join_rate_throttle = throttle; + end + + if not throttle:poll(1) then + if not room.join_rate_presence_queue then + -- if this is the first item for a room we increment the stat for rooms with queues + measure_rooms_with_queue(); + room.join_rate_presence_queue = queue.new(PRESENCE_QUEUE_MAX_SIZE); + end + + if not add_item_to_queue(room.join_rate_presence_queue, event, room, stanza.attr.from, true) then + -- let's not stop processing the event + return nil; + end + + if not room.join_rate_queue_timer then + timer.add_task(1, function () + local status, result = pcall(timer_process_queue_elements, + join_rate_per_conference, + room.join_rate_presence_queue, + function(ev) + -- we mark what we pass here so we can skip it on the next muc-occupant-pre-join event + ev.stanza.delayed_join_skip = true; + room:handle_normal_presence(ev.origin, ev.stanza); + end, + function() -- empty callback + room.join_rate_queue_timer = false; + end + ); + if not status then + -- there was an error in the timer function + module:log('error', 'Error processing join queue: %s', result); + + measure_errors_processing_queue(); + + -- let's re-schedule timer so we do not lose the queue + return 1; + end + + return result; + end); + room.join_rate_queue_timer = true; + end + + return true; -- we stop execution, so we do not process this join at the moment + end + + if room.join_rate_queue_timer then + -- there is timer so we need to order the presences, put it in the queue + + -- if add fails as queue is full we return false and the event will continue processing, we risk re-order + -- but not losing it + return add_item_to_queue(room.join_rate_presence_queue, event, room, stanza.attr.from, true); + end + +end, 9); -- as we will rate limit joins we need to be the first to execute + -- we ran it after muc_max_occupants which is with priority 10, there is nothing to rate limit + -- if max number of occupants is reached + +-- clear queue on room destroy so timer will skip next run if any +module:hook('muc-room-destroyed',function(event) + if event.room.join_rate_presence_queue then + event.room.join_rate_presence_queue.empty = true; + end + if event.room.leave_rate_presence_queue then + event.room.leave_rate_presence_queue.empty = true; + end +end); + +module:hook('muc-occupant-pre-leave', function (event) + local occupant, room, stanza = event.occupant, event.room, event.stanza; + local throttle = room.leave_rate_throttle; + + if not throttle then + throttle = new_throttle(leave_rate_per_conference, 1); -- rate per one second + room.leave_rate_throttle = throttle; + end + + if not throttle:poll(1) then + if not room.leave_rate_presence_queue then + room.leave_rate_presence_queue = queue.new(PRESENCE_QUEUE_MAX_SIZE); + end + + -- we need it later when processing the event + event.orig_role = occupant.role; + + if not add_item_to_queue(room.leave_rate_presence_queue, event, room, stanza.attr.from, false) then + -- let's not stop processing the event + return nil; + end + + -- set role to nil so the occupant will be removed from room occupants when we save it + -- we remove occupant from the list early on batches so we can spare sending few presences + occupant.role = nil; + room:save_occupant(occupant); + + if not room.leave_rate_queue_timer then + timer.add_task(1, function () + local status, result = pcall(timer_process_queue_elements, + leave_rate_per_conference, + room.leave_rate_presence_queue, + function(ev) + local occupant, orig_role, origin, room, stanza + = ev.occupant, ev.orig_role, ev.origin, ev.room, ev.stanza; + + room:publicise_occupant_status( + occupant, + st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}), + nil, nil, nil, orig_role); + + module:fire_event("muc-occupant-left", { + room = room; + nick = occupant.nick; + occupant = occupant; + origin = origin; + stanza = stanza; + }); + end, + function() -- empty callback + room.leave_rate_queue_timer = false; + end + ); + if not status then + -- there was an error in the timer function + module:log('error', 'Error processing leave queue: %s', result); + + -- let's re-schedule timer so we do not lose the queue + return 1; + end + + return result; + end); + room.leave_rate_queue_timer = true; + end + + return true; -- we stop execution, so we do not process this leave at the moment + end +end); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_size.lua b/roles/jitsi/files/prosody/modules/mod_muc_size.lua new file mode 100644 index 0000000..757d5cd --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_size.lua @@ -0,0 +1,197 @@ +-- Prosody IM +-- Copyright (C) 2021-present 8x8, Inc. +-- + +local jid = require "util.jid"; +local it = require "util.iterators"; +local json = require 'cjson.safe'; +local iterators = require "util.iterators"; +local array = require"util.array"; + +local have_async = pcall(require, "util.async"); +if not have_async then + module:log("error", "requires a version of Prosody with util.async"); + return; +end + +local async_handler_wrapper = module:require "util".async_handler_wrapper; + +local tostring = tostring; +local neturl = require "net.url"; +local parse = neturl.parseQuery; + +-- option to enable/disable room API token verifications +local enableTokenVerification + = module:get_option_boolean("enable_roomsize_token_verification", false); + +local token_util = module:require "token/util".new(module); +local get_room_from_jid = module:require "util".get_room_from_jid; + +-- no token configuration but required +if token_util == nil and enableTokenVerification then + log("error", "no token configuration but it is required"); + return; +end + +-- required parameter for custom muc component prefix, +-- defaults to "conference" +local muc_domain_prefix + = module:get_option_string("muc_mapper_domain_prefix", "conference"); + +--- Verifies room name, domain name with the values in the token +-- @param token the token we received +-- @param room_address the full room address jid +-- @return true if values are ok or false otherwise +function verify_token(token, room_address) + if not enableTokenVerification then + return true; + end + + -- if enableTokenVerification is enabled and we do not have token + -- stop here, cause the main virtual host can have guest access enabled + -- (allowEmptyToken = true) and we will allow access to rooms info without + -- a token + if token == nil then + log("warn", "no token provided"); + return false; + end + + local session = {}; + session.auth_token = token; + local verified, reason = token_util:process_and_verify_token(session); + if not verified then + log("warn", "not a valid token %s", tostring(reason)); + return false; + end + + if not token_util:verify_room(session, room_address) then + log("warn", "Token %s not allowed to join: %s", + tostring(token), tostring(room_address)); + return false; + end + + return true; +end + +--- Handles request for retrieving the room size +-- @param event the http event, holds the request query +-- @return GET response, containing a json with participants count, +-- the value is without counting the focus. +function handle_get_room_size(event) + if (not event.request.url.query) then + return { status_code = 400; }; + end + + local params = parse(event.request.url.query); + local room_name = params["room"]; + local domain_name = params["domain"]; + local subdomain = params["subdomain"]; + + local room_address + = jid.join(room_name, muc_domain_prefix.."."..domain_name); + + if subdomain and subdomain ~= "" then + room_address = "["..subdomain.."]"..room_address; + end + + if not verify_token(params["token"], room_address) then + return { status_code = 403; }; + end + + local room = get_room_from_jid(room_address); + local participant_count = 0; + + log("debug", "Querying room %s", tostring(room_address)); + + if room then + local occupants = room._occupants; + if occupants then + participant_count = iterators.count(room:each_occupant()); + end + log("debug", + "there are %s occupants in room", tostring(participant_count)); + else + log("debug", "no such room exists"); + return { status_code = 404; }; + end + + if participant_count > 1 then + participant_count = participant_count - 1; + end + + return { status_code = 200; body = [[{"participants":]]..participant_count..[[}]] }; +end + +--- Handles request for retrieving the room participants details +-- @param event the http event, holds the request query +-- @return GET response, containing a json with participants details +function handle_get_room (event) + if (not event.request.url.query) then + return { status_code = 400; }; + end + + local params = parse(event.request.url.query); + local room_name = params["room"]; + local domain_name = params["domain"]; + local subdomain = params["subdomain"]; + local room_address + = jid.join(room_name, muc_domain_prefix.."."..domain_name); + + if subdomain and subdomain ~= "" then + room_address = "["..subdomain.."]"..room_address; + end + + if not verify_token(params["token"], room_address) then + return { status_code = 403; }; + end + + local room = get_room_from_jid(room_address); + local participant_count = 0; + local occupants_json = array(); + + log("debug", "Querying room %s", tostring(room_address)); + + if room then + local occupants = room._occupants; + if occupants then + participant_count = iterators.count(room:each_occupant()); + for _, occupant in room:each_occupant() do + -- filter focus as we keep it as hidden participant + if string.sub(occupant.nick,-string.len("/focus"))~="/focus" then + for _, pr in occupant:each_session() do + local nick = pr:get_child_text("nick", "http://jabber.org/protocol/nick") or ""; + local email = pr:get_child_text("email") or ""; + occupants_json:push({ + jid = tostring(occupant.nick), + email = tostring(email), + display_name = tostring(nick)}); + end + end + end + end + log("debug", + "there are %s occupants in room", tostring(participant_count)); + else + log("debug", "no such room exists"); + return { status_code = 404; }; + end + + if participant_count > 1 then + participant_count = participant_count - 1; + end + + return { status_code = 200; body = json.encode(occupants_json); }; +end; + +function module.load() + module:depends("http"); + module:provides("http", { + default_path = "/"; + route = { + ["GET room-size"] = function (event) return async_handler_wrapper(event,handle_get_room_size) end; + ["GET sessions"] = function () return tostring(it.count(it.keys(prosody.full_sessions))); end; + ["GET room"] = function (event) return async_handler_wrapper(event,handle_get_room) end; + }; + }); +end + diff --git a/roles/jitsi/files/prosody/modules/mod_muc_transcription_filter.lua b/roles/jitsi/files/prosody/modules/mod_muc_transcription_filter.lua new file mode 100644 index 0000000..b66e44a --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_transcription_filter.lua @@ -0,0 +1,31 @@ +--This module performs features checking when a transcription is requested. +--If the transcription feature is not allowed, the tag indicating that a +--transcription is being requested will be stripped from the presence stanza. +--The module must be enabled under the muc component. +local is_feature_allowed = module:require "util".is_feature_allowed; + +module:log("info", "Loading mod_muc_transcription_filter!"); +local filtered_tag_name = "jitsi_participant_requestingTranscription"; + +function filter_transcription_tag(event) + local stanza = event.stanza; + local session = event.origin; + if stanza and stanza.name == "presence" then + if not is_feature_allowed(session.jitsi_meet_context_features,'transcription') then + stanza:maptags(function(tag) + if tag and tag.name == filtered_tag_name then + module:log("info", "Removing %s tag from presence stanza!", filtered_tag_name); + return nil; + else + return tag; + end + end) + end + end +end + +module:hook("presence/bare", filter_transcription_tag); +module:hook("presence/full", filter_transcription_tag); +module:hook("presence/host", filter_transcription_tag); + +module:log("info", "Loaded mod_muc_transcription_filter!"); diff --git a/roles/jitsi/files/prosody/modules/mod_muc_wait_for_host.lua b/roles/jitsi/files/prosody/modules/mod_muc_wait_for_host.lua new file mode 100644 index 0000000..042bf9e --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_muc_wait_for_host.lua @@ -0,0 +1,102 @@ +-- This module is activated under the main muc component +-- This will prevent anyone joining the call till jicofo and one moderator join the room +-- for the rest of the participants lobby will be turned on and they will be waiting there till +-- the main participant joins and lobby will be turned off at that time and rest of the participants will +-- join the room. It expects main virtual host to be set to require jwt tokens and guests to use +-- the guest domain which is anonymous. +-- The module has the option to set participants to moderators when connected via token/when they are authenticated +-- This module depends on mod_persistent_lobby. +local um_is_admin = require 'core.usermanager'.is_admin; +local jid = require 'util.jid'; +local util = module:require "util"; +local is_healthcheck_room = util.is_healthcheck_room; +local is_moderated = util.is_moderated; +local process_host_module = util.process_host_module; + +local disable_auto_owners = module:get_option_boolean('wait_for_host_disable_auto_owners', false); + +local muc_domain_base = module:get_option_string('muc_mapper_domain_base'); +if not muc_domain_base then + module:log('warn', "No 'muc_mapper_domain_base' option set, disabling muc_mapper plugin inactive"); + 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 + +local function is_admin(jid) + return um_is_admin(jid, module.host); +end + +-- if not authenticated user is trying to join the room we enable lobby in it +-- and wait for the moderator to join +module:hook('muc-occupant-pre-join', function (event) + local room, occupant, session = event.room, event.occupant, event.origin; + + -- we ignore jicofo as we want it to join the room or if the room has already seen its + -- authenticated host + if is_admin(occupant.bare_jid) or is_healthcheck_room(room.jid) or room.has_host then + return; + end + + local has_host = false; + for _, o in room:each_occupant() do + if jid.host(o.bare_jid) == muc_domain_base then + room.has_host = true; + end + end + + if not room.has_host then + if session.auth_token or (session.username and jid.host(occupant.bare_jid) == muc_domain_base) then + -- the host is here, let's drop the lobby + room:set_members_only(false); + + -- let's set the default role of 'participant' for the newly created occupant as it was nil when created + -- when the room was still members_only, later if not disabled this participant will become a moderator + occupant.role = room:get_default_role(room:get_affiliation(occupant.bare_jid)) or 'participant'; + + module:log('info', 'Host %s arrived in %s.', occupant.bare_jid, room.jid); + audit_logger('room_jid:%s created_by:%s', room.jid, + session.jitsi_meet_context_user and session.jitsi_meet_context_user.id or 'nil'); + module:fire_event('room_host_arrived', room.jid, session); + lobby_host:fire_event('destroy-lobby-room', { + room = room, + newjid = room.jid, + message = 'Host arrived.', + }); + elseif not room:get_members_only() then + -- let's enable lobby + module:log('info', 'Will wait for host in %s.', room.jid); + prosody.events.fire_event('create-persistent-lobby-room', { + room = room; + reason = 'waiting-for-host', + skip_display_name_check = true; + }); + end + end +end); + +process_host_module(lobby_muc_component_config, function(host_module, host) + -- lobby muc component created + module:log('info', 'Lobby component loaded %s', host); + lobby_host = module:context(host_module); +end); diff --git a/roles/jitsi/files/prosody/modules/mod_persistent_lobby.lua b/roles/jitsi/files/prosody/modules/mod_persistent_lobby.lua new file mode 100644 index 0000000..2f4e30c --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_persistent_lobby.lua @@ -0,0 +1,199 @@ +-- This module allows lobby room to be created even when the main room is empty. +-- Without this module, the empty main room will get deleted after grace period +-- which triggers lobby room deletion even if there are still people in the lobby. +-- +-- This module should be added to the main virtual host domain. +-- It assumes you have properly configured the muc_lobby_rooms module and lobby muc component. +-- +-- To trigger creation of lobby room: +-- prosody.events.fire_event("create-persistent-lobby-room", { room = room; }); +-- +module:depends('room_destroy'); + +local util = module:require "util"; +local is_healthcheck_room = util.is_healthcheck_room; +local main_muc_component_host = module:get_option_string('main_muc'); +local lobby_muc_component_host = module:get_option_string('lobby_muc'); + + +if main_muc_component_host == nil then + module:log('error', 'main_muc not configured. Cannot proceed.'); + return; +end + +if lobby_muc_component_host == nil then + module:log('error', 'lobby not enabled missing lobby_muc config'); + return; +end + + +-- Helper function to wait till a component is loaded before running the given callback +local function run_when_component_loaded(component_host_name, callback) + local function trigger_callback() + module:log('info', 'Component loaded %s', component_host_name); + callback(module:context(component_host_name), component_host_name); + end + + if prosody.hosts[component_host_name] == nil then + module:log('debug', 'Host %s not yet loaded. Will trigger when it is loaded.', component_host_name); + prosody.events.add_handler('host-activated', function (host) + if host == component_host_name then + trigger_callback(); + end + end); + else + trigger_callback(); + end +end + +-- Helper function to wait till a component's muc module is loaded before running the given callback +local function run_when_muc_module_loaded(component_host_module, component_host_name, callback) + local function trigger_callback() + module:log('info', 'MUC module loaded for %s', component_host_name); + callback(prosody.hosts[component_host_name].modules.muc, component_host_module); + end + + if prosody.hosts[component_host_name].modules.muc == nil then + module:log('debug', 'MUC module for %s not yet loaded. Will trigger when it is loaded.', component_host_name); + prosody.hosts[component_host_name].events.add_handler('module-loaded', function(event) + if (event.module == 'muc') then + trigger_callback(); + end + end); + else + trigger_callback() + end +end + + +local lobby_muc_service; +local main_muc_service; +local main_muc_module; + + +-- Helper methods to track rooms that have persistent lobby +local function set_persistent_lobby(room) + room._data.persist_lobby = true; +end + +local function has_persistent_lobby(room) + if room._data.persist_lobby == true then + return true; + else + return false; + end +end + + +-- Helper method to trigger main room destroy +local function trigger_room_destroy(room) + prosody.events.fire_event("maybe-destroy-room", { + room = room; + reason = 'main room and lobby now empty'; + caller = module:get_name(); + }); +end + + +-- For rooms with persistent lobby, we need to trigger deletion ourselves when both the main room +-- and the lobby room are empty. This will be checked each time an occupant leaves the main room +-- of if someone drops off the lobby. + + +-- Handle events on main muc module +run_when_component_loaded(main_muc_component_host, function(host_module, host_name) + run_when_muc_module_loaded(host_module, host_name, function (main_muc, main_module) + main_muc_service = main_muc; -- so it can be accessed from lobby muc event handlers + main_muc_module = main_module; + + main_module:hook("muc-occupant-left", function(event) + -- Check if room should be destroyed when someone leaves the main room + + local main_room = event.room; + if is_healthcheck_room(main_room.jid) or not has_persistent_lobby(main_room) then + return; + end + + local lobby_room_jid = main_room._data.lobbyroom; + + -- If occupant leaving results in main room being empty, we trigger room destroy if + -- a) lobby exists and is not empty + -- b) lobby does not exist (possible for lobby to be disabled manually by moderator in meeting) + -- + -- (main room destroy also triggers lobby room destroy in muc_lobby_rooms) + if not main_room:has_occupant() then + if lobby_room_jid == nil then -- lobby disabled + trigger_room_destroy(main_room); + else -- lobby exists + local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid); + if lobby_room and not lobby_room:has_occupant() then + trigger_room_destroy(main_room); + end + end + end + end); + + end); +end); + + +-- Handle events on lobby muc module +run_when_component_loaded(lobby_muc_component_host, function(host_module, host_name) + run_when_muc_module_loaded(host_module, host_name, function (lobby_muc, lobby_module) + lobby_muc_service = lobby_muc; -- so it can be accessed from main muc event handlers + + lobby_module:hook("muc-occupant-left", function(event) + -- Check if room should be destroyed when someone leaves the lobby + + local lobby_room = event.room; + local main_room = lobby_room.main_room; + + if not main_room or is_healthcheck_room(main_room.jid) or not has_persistent_lobby(main_room) then + return; + end + + -- If both lobby room and main room are empty, we destroy main room. + -- (main room destroy also triggers lobby room destroy in muc_lobby_rooms) + if not lobby_room:has_occupant() and main_room and not main_room:has_occupant() then + trigger_room_destroy(main_room); + end + + end); + end); +end); + + +function handle_create_persistent_lobby(event) + local room = event.room; + prosody.events.fire_event("create-lobby-room", event); + + set_persistent_lobby(room); + room:set_persistent(true); +end + + +module:hook_global('create-persistent-lobby-room', handle_create_persistent_lobby); + + +-- Stop other modules from destroying room if persistent lobby not empty +function handle_maybe_destroy_main_room(event) + local main_room = event.room; + local caller = event.caller; + + if caller == module:get_name() then + -- we were the one that requested the deletion. Do not override. + return nil; + end + + -- deletion was requested by another module. Check for lobby occupants. + if has_persistent_lobby(main_room) and main_room._data.lobbyroom then + local lobby_room_jid = main_room._data.lobbyroom; + local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid); + if lobby_room and lobby_room:has_occupant() then + module:log('info', 'Suppressing room destroy. Persistent lobby still occupied %s', lobby_room_jid); + return true; -- stop room destruction + end + end +end + +module:hook_global("maybe-destroy-room", handle_maybe_destroy_main_room); diff --git a/roles/jitsi/files/prosody/modules/mod_polls.lua b/roles/jitsi/files/prosody/modules/mod_polls.lua new file mode 100644 index 0000000..5494a6f --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_polls.lua @@ -0,0 +1,211 @@ +-- This module provides persistence for the "polls" feature, +-- by keeping track of the state of polls in each room, and sending +-- that state to new participants when they join. + +local json = require 'cjson.safe'; +local st = require("util.stanza"); +local jid = require "util.jid"; +local util = module:require("util"); +local muc = module:depends("muc"); + +local NS_NICK = 'http://jabber.org/protocol/nick'; +local is_healthcheck_room = util.is_healthcheck_room; + +-- Checks if the given stanza contains a JSON message, +-- and that the message type pertains to the polls feature. +-- If yes, returns the parsed message. Otherwise, returns nil. +local function get_poll_message(stanza) + if stanza.attr.type ~= "groupchat" then + return nil; + end + local json_data = stanza:get_child_text("json-message", "http://jitsi.org/jitmeet"); + if json_data == nil then + return nil; + end + local data, error = json.decode(json_data); + if not data or (data.type ~= "new-poll" and data.type ~= "answer-poll") then + if error then + module:log('error', 'Error decoding data error:%s', error); + end + return nil; + end + return data; +end + +-- Logs a warning and returns true if a room does not +-- have poll data associated with it. +local function check_polls(room) + if room.polls == nil then + module:log("warn", "no polls data in room"); + return true; + end + return false; +end + +--- Returns a table having occupant id and occupant name. +--- If the id cannot be extracted from nick a nil value is returned +--- if the occupant name cannot be extracted from presence the Fellow Jitster +--- name is used +local function get_occupant_details(occupant) + if not occupant then + return nil + end + local presence = occupant:get_presence(); + local occupant_name; + if presence then + occupant_name = presence:get_child("nick", NS_NICK) and presence:get_child("nick", NS_NICK):get_text() or 'Fellow Jitster'; + else + occupant_name = 'Fellow Jitster' + end + local _, _, occupant_id = jid.split(occupant.nick) + if not occupant_id then + return nil + end + return { ["occupant_id"] = occupant_id, ["occupant_name"] = occupant_name } +end + +-- Sets up poll data in new rooms. +module:hook("muc-room-created", function(event) + local room = event.room; + if is_healthcheck_room(room.jid) then return end + module:log("debug", "setting up polls in room %s", room.jid); + room.polls = { + by_id = {}; + order = {}; + }; +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/bare", function(event) + local data = get_poll_message(event.stanza); + if data == nil then return end + + local room = muc.get_room_from_jid(event.stanza.attr.to); + + if data.type == "new-poll" then + if check_polls(room) then return end + + local occupant_jid = event.stanza.attr.from; + local occupant = room:get_occupant_by_real_jid(occupant_jid); + if not occupant then + module:log("error", "Occupant %s was not found in room %s", occupant_jid, room.jid) + return + end + local poll_creator = get_occupant_details(occupant) + if not poll_creator then + module:log("error", "Cannot retrieve poll creator id and name for %s from %s", occupant.jid, room.jid) + return + end + + local answers = {} + local compact_answers = {} + for i, name in ipairs(data.answers) do + table.insert(answers, { name = name, voters = {} }); + table.insert(compact_answers, { key = i, name = name}); + end + + local poll = { + id = data.pollId, + sender_id = poll_creator.occupant_id, + sender_name = poll_creator.occupant_name, + question = data.question, + answers = answers + }; + + room.polls.by_id[data.pollId] = poll + table.insert(room.polls.order, poll) + + local pollData = { + event = event, + room = room, + poll = { + pollId = data.pollId, + senderId = poll_creator.occupant_id, + senderName = poll_creator.occupant_name, + question = data.question, + answers = compact_answers + } + } + module:fire_event("poll-created", pollData); + + elseif data.type == "answer-poll" then + if check_polls(room) then return end + + local occupant_jid = event.stanza.attr.from; + local occupant = room:get_occupant_by_real_jid(occupant_jid); + if not occupant then + module:log("error", "Occupant %s does not exists for room %s", occupant_jid, room.jid) + return + end + local poll = room.polls.by_id[data.pollId]; + if poll == nil then + module:log("warn", "answering inexistent poll"); + return; + end + + local voter = get_occupant_details(occupant) + if not voter then + module:log("error", "Cannot retrieve voter id and name for %s from %s", occupant.jid, room.jid) + return + end + + local answers = {}; + for vote_option_idx, vote_flag in ipairs(data.answers) do + table.insert(answers, { + key = vote_option_idx, + value = vote_flag, + name = poll.answers[vote_option_idx].name, + }); + poll.answers[vote_option_idx].voters[voter.occupant_id] = vote_flag and voter.occupant_name or nil; + end + local answerData = { + event = event, + room = room, + pollId = poll.id, + voterName = voter.occupant_name, + voterId = voter.occupant_id, + answers = answers + } + module:fire_event("answer-poll", answerData); + end +end); + +-- Sends the current poll state to new occupants after joining a room. +module:hook("muc-occupant-joined", function(event) + local room = event.room; + if is_healthcheck_room(room.jid) then return end + if room.polls == nil or #room.polls.order == 0 then + return + end + + local data = { + type = "old-polls", + polls = {}, + }; + for i, poll in ipairs(room.polls.order) do + data.polls[i] = { + id = poll.id, + senderId = poll.sender_id, + senderName = poll.sender_name, + question = poll.question, + answers = poll.answers + }; + end + + local json_msg_str, error = json.encode(data); + if not json_msg_str then + module:log('error', 'Error encoding data room:%s error:%s', room.jid, error); + end + + local stanza = st.message({ + from = room.jid, + to = event.occupant.jid + }) + :tag("json-message", { xmlns = "http://jitsi.org/jitmeet" }) + :text(json_msg_str) + :up(); + room:route_stanza(stanza); +end); diff --git a/roles/jitsi/files/prosody/modules/mod_poltergeist_component.lua b/roles/jitsi/files/prosody/modules/mod_poltergeist_component.lua new file mode 100644 index 0000000..aa06175 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_poltergeist_component.lua @@ -0,0 +1,21 @@ +local st = require "util.stanza"; + +-- A component which we use to receive all stanzas for the created poltergeists +-- replays with error if an iq is sent +function no_action() + return true; +end + +function error_reply(event) + module:send(st.error_reply(event.stanza, "cancel", "service-unavailable")); + return true; +end + +module:hook("presence/host", no_action); +module:hook("message/host", no_action); +module:hook("presence/full", no_action); +module:hook("message/full", no_action); + +module:hook("iq/host", error_reply); +module:hook("iq/full", error_reply); +module:hook("iq/bare", error_reply); diff --git a/roles/jitsi/files/prosody/modules/mod_presence_identity.lua b/roles/jitsi/files/prosody/modules/mod_presence_identity.lua new file mode 100644 index 0000000..4db397e --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_presence_identity.lua @@ -0,0 +1,22 @@ +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) + if event and event["stanza"] then + if event.origin and event.origin.jitsi_meet_context_user then + + update_presence_identity( + event.stanza, + event.origin.jitsi_meet_context_user, + event.origin.jitsi_meet_context_group + ); + + end + end +end + +module:hook("pre-presence/bare", on_message); +module:hook("pre-presence/full", on_message); diff --git a/roles/jitsi/files/prosody/modules/mod_rate_limit.lua b/roles/jitsi/files/prosody/modules/mod_rate_limit.lua new file mode 100644 index 0000000..7d07d53 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_rate_limit.lua @@ -0,0 +1,242 @@ +-- 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 config = {}; +local limits_resolution = 1; + +local function load_config() + -- Max allowed login rate in events per second. + config.login_rate = module:get_option_number("rate_limit_login_rate", 3); + -- The rate to which sessions from IPs exceeding the join rate will be limited, in bytes per second. + config.ip_rate = module:get_option_number("rate_limit_ip_rate", 2000); + -- The rate to which sessions exceeding the stanza(iq, presence, message) rate will be limited, in bytes per second. + config.session_rate = module:get_option_number("rate_limit_session_rate", 1000); + -- The time in seconds, after which the limit for an IP address is lifted. + config.timeout = module:get_option_number("rate_limit_timeout", 60); + -- List of regular expressions for IP addresses that are not limited by this module. + config.whitelist = module:get_option_set("rate_limit_whitelist", { "127.0.0.1", "::1" })._items; + -- The size of the cache that saves state for IP addresses + config.cache_size = module:get_option_number("rate_limit_cache_size", 10000); + + -- Max allowed presence rate in events per second. + config.presence_rate = module:get_option_number("rate_limit_presence_rate", 4); + -- Max allowed iq rate in events per second. + config.iq_rate = module:get_option_number("rate_limit_iq_rate", 15); + -- Max allowed message rate in events per second. + config.message_rate = module:get_option_number("rate_limit_message_rate", 3); + + -- A list of hosts for which sessions we ignore rate limiting + config.whitelist_hosts = module:get_option_set("rate_limit_whitelist_hosts", {}); + + local wl = ""; + for ip in config.whitelist do wl = wl .. ip .. "," end + local wl_hosts = ""; + for j in config.whitelist_hosts do wl_hosts = wl_hosts .. j .. "," end + module:log("info", "Loaded configuration: "); + module:log("info", "- ip_rate=%s bytes/sec, session_rate=%s bytes/sec, timeout=%s sec, cache size=%s, whitelist=%s, whitelist_hosts=%s", + config.ip_rate, config.session_rate, config.timeout, config.cache_size, wl, wl_hosts); + module:log("info", "- login_rate=%s/sec, presence_rate=%s/sec, iq_rate=%s/sec, message_rate=%s/sec", + config.login_rate, config.presence_rate, config.iq_rate, config.message_rate); +end +load_config(); + +-- Maps an IP address to a util.throttle which keeps the rate of login/join events from that IP. +local login_rates = cache.new(config.cache_size); + +-- Keeps the IP addresses that have exceeded the allowed login/join rate (i.e. the IP addresses whose sessions need +-- to be limited). Mapped to the last instant at which the rate was exceeded. +local limited_ips = cache.new(config.cache_size); + +local function is_whitelisted(ip) + local parsed_ip = new_ip(ip) + for entry in config.whitelist do + if match_ip(parsed_ip, parse_cidr(entry)) then + return true; + end + end + + return false; +end + +local function is_whitelisted_host(h) + return config.whitelist_hosts:contains(h); +end + +-- Discover real remote IP of a session +-- Note: http_server.get_request_from_conn() was added in Prosody 0.12.3, +-- this code provides backwards compatibility with older versions +local get_request_from_conn = http_server.get_request_from_conn or function (conn) + local response = conn and conn._http_open_response; + return response and response.request or nil; +end; + +-- Add an IP to the set of limied IPs +local function limit_ip(ip) + module:log("info", "Limiting %s due to login/join rate exceeded.", ip); + limited_ips:set(ip, gettime()); +end + +-- Installable as a session filter to limit the reading rate for a session. Based on mod_limits. +local function limit_bytes_in(bytes, session) + local sess_throttle = session.jitsi_throttle; + if sess_throttle then + -- if the limit timeout has elapsed let's stop the throttle + if not sess_throttle.start or gettime() - sess_throttle.start > config.timeout then + module:log("info", "Stop throttling session=%s, ip=%s.", session.id, session.ip); + session.jitsi_throttle = nil; + return bytes; + end + local ok, _, outstanding = sess_throttle:poll(#bytes, true); + if not ok then + session.log("debug", + "Session over rate limit (%d) with %d (by %d), pausing", sess_throttle.max, #bytes, outstanding); + outstanding = ceil(outstanding); + session.conn:pause(); -- Read no more data from the connection until there is no outstanding data + local outstanding_data = bytes:sub(-outstanding); + bytes = bytes:sub(1, #bytes-outstanding); + timer.add_task(limits_resolution, function () + if not session.conn then return; end + if sess_throttle:peek(#outstanding_data) then + session.log("debug", "Resuming paused session"); + session.conn:resume(); + end + -- Handle what we can of the outstanding data + session.data(outstanding_data); + end); + end + end + return bytes; +end + +-- Throttles reading from the connection of a specific session. +local function throttle_session(session, rate, timeout) + if not session.jitsi_throttle then + if (session.conn and session.conn.setlimit) then + session.jitsi_throttle_counter = session.jitsi_throttle_counter + 1; + module:log("info", "Enabling throttle (%s bytes/s) via setlimit, session=%s, ip=%s, counter=%s.", + rate, session.id, session.ip, session.jitsi_throttle_counter); + session.conn:setlimit(rate); + if timeout then + if session.jitsi_throttle_timer then + -- if there was a timer stop it as we will schedule a new one + session.jitsi_throttle_timer:stop(); + session.jitsi_throttle_timer = nil; + end + session.jitsi_throttle_timer = module:add_timer(timeout, function() + if session.conn then + module:log("info", "Stop throttling session=%s, ip=%s.", session.id, session.ip); + session.conn:setlimit(0); + end + session.jitsi_throttle_timer = nil; + end); + end + else + module:log("info", "Enabling throttle (%s bytes/s) via filter, session=%s, ip=%s.", rate, session.id, session.ip); + session.jitsi_throttle = new_throttle(rate, 2); + filters.add_filter(session, "bytes/in", limit_bytes_in, 1000); + -- throttle.start used for stop throttling after the timeout + session.jitsi_throttle.start = gettime(); + end + else + -- update the throttling start + session.jitsi_throttle.start = gettime(); + end +end + +-- checks different stanzas for rate limiting (per session) +function filter_stanza(stanza, session) + local rate = session[stanza.name.."_rate"]; + if rate then + local ok, _, _ = rate:poll(1, true); + if not ok then + module:log("info", "%s rate exceeded for %s, limiting.", stanza.name, session.full_jid); + throttle_session(session, config.session_rate, config.timeout); + end + end + + return stanza; +end + +local function on_login(session, ip) + local login_rate = login_rates:get(ip); + if not login_rate then + module:log("debug", "Create new join rate for %s", ip); + login_rate = new_throttle(config.login_rate, 2); + login_rates:set(ip, login_rate); + end + + local ok, _, _ = login_rate:poll(1, true); + if not ok then + module:log("info", "Join rate exceeded for %s, limiting.", ip); + limit_ip(ip); + end +end + +local function filter_hook(session) + -- ignore outgoing sessions (s2s) + if session.outgoing then + return; + end + + local request = get_request_from_conn(session.conn); + local ip = request and request.ip or session.ip; + module:log("debug", "New session from %s", ip); + if is_whitelisted(ip) or is_whitelisted_host(session.host) then + return; + end + + on_login(session, ip); + + -- creates the stanzas rates + session.jitsi_throttle_counter = 0; + session.presence_rate = new_throttle(config.presence_rate, 2); + session.iq_rate = new_throttle(config.iq_rate, 2); + session.message_rate = new_throttle(config.message_rate, 2); + filters.add_filter(session, "stanzas/in", filter_stanza); + + local oldt = limited_ips:get(ip); + if oldt then + local newt = gettime(); + local elapsed = newt - oldt; + if elapsed < config.timeout then + if elapsed < 5 then + module:log("info", "IP address %s was limited %s seconds ago, refreshing.", ip, elapsed); + limited_ips:set(ip, newt); + end + throttle_session(session, config.ip_rate); + else + module:log("info", "Removing the limit for %s", ip); + limited_ips:set(ip, nil); + end + end +end + +function module.load() + filters.add_filter_hook(filter_hook); +end + +function module.unload() + filters.remove_filter_hook(filter_hook); +end + +module:hook_global("config-reloaded", load_config); + +-- we calculate the stats on the configured interval (60 seconds by default) +local measure_limited_ips = module:measure('limited-ips', 'amount'); -- we send stats for the total number limited ips +module:hook_global('stats-update', function () + measure_limited_ips(limited_ips:count()); +end); diff --git a/roles/jitsi/files/prosody/modules/mod_reservations.lua b/roles/jitsi/files/prosody/modules/mod_reservations.lua new file mode 100644 index 0000000..62db02d --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_reservations.lua @@ -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); diff --git a/roles/jitsi/files/prosody/modules/mod_room_destroy.lua b/roles/jitsi/files/prosody/modules/mod_room_destroy.lua new file mode 100644 index 0000000..d3dfb3b --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_room_destroy.lua @@ -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'); diff --git a/roles/jitsi/files/prosody/modules/mod_room_metadata.lua b/roles/jitsi/files/prosody/modules/mod_room_metadata.lua new file mode 100644 index 0000000..a31abc5 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_room_metadata.lua @@ -0,0 +1,10 @@ +-- Generic room metadata +-- See mod_room_metadata_component.lua + +local COMPONENT_IDENTITY_TYPE = 'room_metadata'; +local room_metadata_component_host = module:get_option_string('room_metadata_component', 'metadata.'..module.host); + +module:depends("jitsi_session"); + +-- Advertise the component so clients can pick up the address and use it +module:add_identity('component', COMPONENT_IDENTITY_TYPE, room_metadata_component_host); diff --git a/roles/jitsi/files/prosody/modules/mod_room_metadata_component.lua b/roles/jitsi/files/prosody/modules/mod_room_metadata_component.lua new file mode 100644 index 0000000..9459172 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_room_metadata_component.lua @@ -0,0 +1,240 @@ +-- This module implements a generic metadata storage system for rooms. +-- +-- VirtualHost "jitmeet.example.com" +-- modules_enabled = { +-- "room_metadata" +-- } +-- room_metadata_component = "metadata.jitmeet.example.com" +-- main_muc = "conference.jitmeet.example.com" +-- +-- Component "metadata.jitmeet.example.com" "room_metadata_component" +-- muc_component = "conference.jitmeet.example.com" +-- breakout_rooms_component = "breakout.jitmeet.example.com" + +local jid_node = require 'util.jid'.node; +local json = require 'cjson.safe'; +local st = require 'util.stanza'; + +local util = module:require 'util'; +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 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 breakout_rooms_component_host = module:get_option_string('breakout_rooms_component'); + +module:log("info", "Starting room metadata for %s", muc_component_host); + +local main_muc_module; + +-- Utility functions + +function getMetadataJSON(room) + local res, error = json.encode({ + type = COMPONENT_IDENTITY_TYPE, + metadata = room.jitsiMetadata or {} + }); + + if not res then + module:log('error', 'Error encoding data room:%s', room.jid, error); + end + + return res; +end + +-- Putting the information on the config form / disco-info allows us to save +-- an extra message to users who join later. +function getFormData(room) + return { + name = FORM_KEY; + type = 'text-multi'; + label = 'Room metadata'; + value = getMetadataJSON(room); + }; +end + +function broadcastMetadata(room) + local json_msg = getMetadataJSON(room); + + for _, occupant in room:each_occupant() do + send_json_msg(occupant.jid, internal_room_jid_match_rewrite(room.jid), json_msg) + end +end + +function send_json_msg(to_jid, room_jid, json_msg) + local stanza = st.message({ from = module.host; to = to_jid; }) + :tag('json-message', { xmlns = 'http://jitsi.org/jitmeet', room = 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 +end + +function on_message(event) + local session = event.origin; + + -- Check the type of the incoming stanza to avoid loops: + if event.stanza.attr.type == 'error' then + return; -- We do not want to reply to these, so leave. + end + + if not session or not session.jitsi_web_query_room then + return false; + end + + local message = event.stanza:get_child(COMPONENT_IDENTITY_TYPE, 'http://jitsi.org/jitmeet'); + local messageText = message:get_text(); + + if not message or not messageText then + return false; + end + + local roomJid = message.attr.room; + local room = get_room_from_jid(room_jid_match_rewrite(roomJid)); + + if not room then + module:log('warn', 'No room found found for %s/%s', + session.jitsi_web_query_prefix, session.jitsi_web_query_room); + return false; + end + + -- check that the participant requesting is a moderator and is an occupant in the room + local from = event.stanza.attr.from; + local occupant = room:get_occupant_by_real_jid(from); + + if not occupant then + module:log('warn', 'No occupant %s found for %s', from, room.jid); + return false; + end + + 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 jsonData, error = json.decode(messageText); + if jsonData == nil then -- invalid JSON + module:log("error", "Invalid JSON message: %s error:%s", messageText, error); + return false; + end + + if jsonData.key == nil or jsonData.data == nil then + module:log("error", "Invalid JSON payload, key or data are missing: %s", messageText); + return false; + end + + room.jitsiMetadata[jsonData.key] = jsonData.data; + + broadcastMetadata(room); + + -- fire and event for the change + main_muc_module:fire_event('jitsi-metadata-updated', { room = room; actor = occupant; key = jsonData.key; }); + + 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); + + host_module:hook('muc-disco#info', function (event) + local room = event.room; + + table.insert(event.form, getFormData(room)); + end); + + host_module:hook("muc-config-form", function(event) + local room = event.room; + + table.insert(event.form, getFormData(room)); + end); + -- The room metadata was updated internally (from another module). + host_module:hook("room-metadata-changed", function(event) + broadcastMetadata(event.room); + 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); + + host_module:hook('muc-disco#info', function (event) + local room = event.room; + + table.insert(event.form, getFormData(room)); + end); + + host_module:hook("muc-config-form", function(event) + local room = event.room; + + table.insert(event.form, getFormData(room)); + 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 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 diff --git a/roles/jitsi/files/prosody/modules/mod_roster_command.lua b/roles/jitsi/files/prosody/modules/mod_roster_command.lua new file mode 100644 index 0000000..985a8c2 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_roster_command.lua @@ -0,0 +1,164 @@ +----------------------------------------------------------- +-- mod_roster_command: Manage rosters through prosodyctl +-- version 0.02 +----------------------------------------------------------- +-- Copyright (C) 2011 Matthew Wild +-- Copyright (C) 2011 Adam Nielsen +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +----------------------------------------------------------- + +if module.host ~= "*" then + module:log("error", "Do not load this module in Prosody, for correct usage see: https://modules.prosody.im/mod_roster_command.html"); + return; +end + + +-- Workaround for lack of util.startup... +local prosody = _G.prosody; +local hosts = prosody.hosts; +prosody.bare_sessions = prosody.bare_sessions or {}; +_G.bare_sessions = _G.bare_sessions or prosody.bare_sessions; + +local usermanager = require "core.usermanager"; +local rostermanager = require "core.rostermanager"; +local storagemanager = require "core.storagemanager"; +local jid = require "util.jid"; +local warn = require"util.prosodyctl".show_warning; + +-- Make a *one-way* subscription. User will see when contact is online, +-- contact will not see when user is online. +function subscribe(user_jid, contact_jid) + local user_username, user_host = jid.split(user_jid); + local contact_username, contact_host = jid.split(contact_jid); + if not hosts[user_host] then + warn("The host '%s' is not configured for this server.", user_host); + return; + end + if hosts[user_host].users.name == "null" then + storagemanager.initialize_host(user_host); + usermanager.initialize_host(user_host); + end + -- Update user's roster to say subscription request is pending. Bare hosts (e.g. components) don't have rosters. + if user_username ~= nil then + rostermanager.set_contact_pending_out(user_username, user_host, contact_jid); + end + if hosts[contact_host] then + if contact_host ~= user_host and hosts[contact_host].users.name == "null" then + storagemanager.initialize_host(contact_host); + usermanager.initialize_host(contact_host); + end + -- Update contact's roster to say subscription request is pending... + rostermanager.set_contact_pending_in(contact_username, contact_host, user_jid); + -- Update contact's roster to say subscription request approved... + rostermanager.subscribed(contact_username, contact_host, user_jid); + -- Update user's roster to say subscription request approved. Bare hosts (e.g. components) don't have rosters. + if user_username ~= nil then + rostermanager.process_inbound_subscription_approval(user_username, user_host, contact_jid); + end + end +end + +-- Make a mutual subscription between jid1 and jid2. Each JID will see +-- when the other one is online. +function subscribe_both(jid1, jid2) + subscribe(jid1, jid2); + subscribe(jid2, jid1); +end + +-- Unsubscribes user from contact (not contact from user, if subscribed). +function unsubscribe(user_jid, contact_jid) + local user_username, user_host = jid.split(user_jid); + local contact_username, contact_host = jid.split(contact_jid); + if not hosts[user_host] then + warn("The host '%s' is not configured for this server.", user_host); + return; + end + if hosts[user_host].users.name == "null" then + storagemanager.initialize_host(user_host); + usermanager.initialize_host(user_host); + end + -- Update user's roster to say subscription is cancelled... + rostermanager.unsubscribe(user_username, user_host, contact_jid); + if hosts[contact_host] then + if contact_host ~= user_host and hosts[contact_host].users.name == "null" then + storagemanager.initialize_host(contact_host); + usermanager.initialize_host(contact_host); + end + -- Update contact's roster to say subscription is cancelled... + rostermanager.unsubscribed(contact_username, contact_host, user_jid); + end +end + +-- Cancel any subscription in either direction. +function unsubscribe_both(jid1, jid2) + unsubscribe(jid1, jid2); + unsubscribe(jid2, jid1); +end + +-- Set the name shown and group used in the contact list +function rename(user_jid, contact_jid, contact_nick, contact_group) + local user_username, user_host = jid.split(user_jid); + if not hosts[user_host] then + warn("The host '%s' is not configured for this server.", user_host); + return; + end + if hosts[user_host].users.name == "null" then + storagemanager.initialize_host(user_host); + usermanager.initialize_host(user_host); + end + + -- Load user's roster and find the contact + local roster = rostermanager.load_roster(user_username, user_host); + local item = roster[contact_jid]; + if item then + if contact_nick then + item.name = contact_nick; + end + if contact_group then + item.groups = {}; -- Remove from all current groups + item.groups[contact_group] = true; + end + rostermanager.save_roster(user_username, user_host, roster); + end +end + +function remove(user_jid, contact_jid) + unsubscribe_both(user_jid, contact_jid); + local user_username, user_host = jid.split(user_jid); + local roster = rostermanager.load_roster(user_username, user_host); + roster[contact_jid] = nil; + rostermanager.save_roster(user_username, user_host, roster); +end + +function module.command(arg) + local command = arg[1]; + if not command then + warn("Valid subcommands: (un)subscribe(_both) | rename"); + return 0; + end + table.remove(arg, 1); + if command == "subscribe" then + subscribe(arg[1], arg[2]); + return 0; + elseif command == "subscribe_both" then + subscribe_both(arg[1], arg[2]); + return 0; + elseif command == "unsubscribe" then + unsubscribe(arg[1], arg[2]); + return 0; + elseif command == "unsubscribe_both" then + unsubscribe_both(arg[1], arg[2]); + return 0; + elseif command == "remove" then + remove(arg[1], arg[2]); + return 0; + elseif command == "rename" then + rename(arg[1], arg[2], arg[3], arg[4]); + return 0; + else + warn("Unknown command: %s", command); + return 1; + end +end diff --git a/roles/jitsi/files/prosody/modules/mod_roster_command.patch b/roles/jitsi/files/prosody/modules/mod_roster_command.patch new file mode 100644 index 0000000..19edb46 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_roster_command.patch @@ -0,0 +1,47 @@ +# HG changeset patch +# User Boris Grozev +# 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 + diff --git a/roles/jitsi/files/prosody/modules/mod_s2s_whitelist.lua b/roles/jitsi/files/prosody/modules/mod_s2s_whitelist.lua new file mode 100644 index 0000000..a08cd5e --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_s2s_whitelist.lua @@ -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); diff --git a/roles/jitsi/files/prosody/modules/mod_s2sout_override.lua b/roles/jitsi/files/prosody/modules/mod_s2sout_override.lua new file mode 100644 index 0000000..85b1689 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_s2sout_override.lua @@ -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); diff --git a/roles/jitsi/files/prosody/modules/mod_s2soutinjection.lua b/roles/jitsi/files/prosody/modules/mod_s2soutinjection.lua new file mode 100644 index 0000000..9920984 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_s2soutinjection.lua @@ -0,0 +1,93 @@ +-- Using version https://hg.prosody.im/prosody-modules/file/4fb922aa0ace/mod_s2soutinjection/mod_s2soutinjection.lua +local st = require"util.stanza"; +local new_outgoing = require"core.s2smanager".new_outgoing; +local bounce_sendq = module:depends"s2s".route_to_new_session.bounce_sendq; +local initialize_filters = require "util.filters".initialize; + +local portmanager = require "core.portmanager"; + +local addclient = require "net.server".addclient; + +module:depends("s2s"); + +local sessions = module:shared("sessions"); + +local injected = module:get_option("s2s_connect_overrides"); + +-- The proxy_listener handles connection while still connecting to the proxy, +-- then it hands them over to the normal listener (in mod_s2s) +local proxy_listener = { default_port = nil, default_mode = "*a", default_interface = "*" }; + +function proxy_listener.onconnect(conn) + local session = sessions[conn]; + + -- needed in mod_rate_limit + session.ip = conn:ip(); + + -- Now the real s2s listener can take over the connection. + local listener = portmanager.get_service("s2s").listener; + + local log = session.log; + + local filter = initialize_filters(session); + + session.version = 1; + + session.sends2s = function (t) + -- log("debug", "sending (s2s over proxy): %s", (t.top_tag and t:top_tag()) or t:match("^[^>]*>?")); + if t.name then + t = filter("stanzas/out", t); + end + if t then + t = filter("bytes/out", tostring(t)); + if t then + return conn:write(tostring(t)); + end + end + end + + session.open_stream = function () + session.sends2s(st.stanza("stream:stream", { + xmlns='jabber:server', ["xmlns:db"]='jabber:server:dialback', + ["xmlns:stream"]='http://etherx.jabber.org/streams', + from=session.from_host, to=session.to_host, version='1.0', ["xml:lang"]='en'}):top_tag()); + end + + conn.setlistener(conn, listener); + + listener.register_outgoing(conn, session); + + listener.onconnect(conn); +end + +function proxy_listener.register_outgoing(conn, session) + session.direction = "outgoing"; + sessions[conn] = session; +end + +function proxy_listener.ondisconnect(conn, err) + sessions[conn] = nil; +end + +module:hook("route/remote", function(event) + local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza; + local inject = injected and injected[to_host]; + if not inject then return end + -- module:log("debug", "opening a new outgoing connection for this stanza"); + local host_session = new_outgoing(from_host, to_host); + + -- Store in buffer + host_session.bounce_sendq = bounce_sendq; + host_session.sendq = { {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)} }; + -- host_session.log("debug", "stanza [%s] queued until connection complete", tostring(stanza.name)); + + local host, port = inject[1] or inject, tonumber(inject[2]) or 5269; + + local conn = addclient(host, port, proxy_listener, "*a"); + + proxy_listener.register_outgoing(conn, host_session); + + host_session.conn = conn; + return true; +end, -2); + diff --git a/roles/jitsi/files/prosody/modules/mod_secure_interfaces.lua b/roles/jitsi/files/prosody/modules/mod_secure_interfaces.lua new file mode 100644 index 0000000..e984903 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_secure_interfaces.lua @@ -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); diff --git a/roles/jitsi/files/prosody/modules/mod_smacks.lua b/roles/jitsi/files/prosody/modules/mod_smacks.lua new file mode 100644 index 0000000..a6d1e62 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_smacks.lua @@ -0,0 +1,683 @@ +-- XEP-0198: Stream Management for Prosody IM +-- +-- Copyright (C) 2010-2015 Matthew Wild +-- Copyright (C) 2010 Waqas Hussain +-- Copyright (C) 2012-2021 Kim Alvefur +-- Copyright (C) 2012 Thijs Alkemade +-- Copyright (C) 2014 Florian Zeitz +-- Copyright (C) 2016-2020 Thilo Molitor +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; +local dep = require "util.dependencies"; +local cache = dep.softreq("util.cache"); -- only available in prosody 0.10+ +local uuid_generate = require "util.uuid".generate; +local jid = require "util.jid"; + +local t_remove = table.remove; +local math_min = math.min; +local math_max = math.max; +local os_time = os.time; +local tonumber, tostring = tonumber, tostring; +local add_filter = require "util.filters".add_filter; +local timer = require "util.timer"; +local datetime = require "util.datetime"; + +local xmlns_mam2 = "urn:xmpp:mam:2"; +local xmlns_sm2 = "urn:xmpp:sm:2"; +local xmlns_sm3 = "urn:xmpp:sm:3"; +local xmlns_errors = "urn:ietf:params:xml:ns:xmpp-stanzas"; +local xmlns_delay = "urn:xmpp:delay"; + +local sm2_attr = { xmlns = xmlns_sm2 }; +local sm3_attr = { xmlns = xmlns_sm3 }; + +local resume_timeout = module:get_option_number("smacks_hibernation_time", 600); +local s2s_smacks = module:get_option_boolean("smacks_enabled_s2s", true); +local s2s_resend = module:get_option_boolean("smacks_s2s_resend", false); +local max_unacked_stanzas = module:get_option_number("smacks_max_unacked_stanzas", 0); +local max_inactive_unacked_stanzas = module:get_option_number("smacks_max_inactive_unacked_stanzas", 256); +local delayed_ack_timeout = module:get_option_number("smacks_max_ack_delay", 30); +local max_hibernated_sessions = module:get_option_number("smacks_max_hibernated_sessions", 10); +local max_old_sessions = module:get_option_number("smacks_max_old_sessions", 10); +local core_process_stanza = prosody.core_process_stanza; +local sessionmanager = require"core.sessionmanager"; + +assert(max_hibernated_sessions > 0, "smacks_max_hibernated_sessions must be greater than 0"); +assert(max_old_sessions > 0, "smacks_max_old_sessions must be greater than 0"); + +local c2s_sessions = module:shared("/*/c2s/sessions"); + +local function init_session_cache(max_entries, evict_callback) + -- old prosody version < 0.10 (no limiting at all!) + if not cache then + local store = {}; + return { + get = function(user, key) + if not user then return nil; end + if not key then return nil; end + return store[key]; + end; + set = function(user, key, value) + if not user then return nil; end + if not key then return nil; end + store[key] = value; + end; + }; + end + + -- use per user limited cache for prosody >= 0.10 + local stores = {}; + return { + get = function(user, key) + if not user then return nil; end + if not key then return nil; end + if not stores[user] then + stores[user] = cache.new(max_entries, evict_callback); + end + return stores[user]:get(key); + end; + set = function(user, key, value) + if not user then return nil; end + if not key then return nil; end + if not stores[user] then stores[user] = cache.new(max_entries, evict_callback); end + stores[user]:set(key, value); + -- remove empty caches completely + if not stores[user]:count() then stores[user] = nil; end + end; + }; +end +local old_session_registry = init_session_cache(max_old_sessions, nil); +local session_registry = init_session_cache(max_hibernated_sessions, function(resumption_token, session) + if session.destroyed then return true; end -- destroyed session can always be removed from cache + session.log("warn", "User has too much hibernated sessions, removing oldest session (token: %s)", resumption_token); + -- store old session's h values on force delete + -- save only actual h value and username/host (for security) + old_session_registry.set(session.username, resumption_token, { + h = session.handled_stanza_count, + username = session.username, + host = session.host + }); + return true; -- allow session to be removed from full cache to make room for new one +end); + +local function stoppable_timer(delay, callback) + local stopped = false; + local timer = module:add_timer(delay, function (t) + if stopped then return; end + return callback(t); + end); + if timer and timer.stop then return timer; end -- new prosody api includes stop() function + return { + stop = function(self) stopped = true end; + timer; + }; +end + +local function delayed_ack_function(session, stanza) + -- fire event only if configured to do so and our session is not already hibernated or destroyed + if delayed_ack_timeout > 0 and session.awaiting_ack + and not session.hibernating and not session.destroyed then + session.log("debug", "Firing event 'smacks-ack-delayed', queue = %d", + session.outgoing_stanza_queue and #session.outgoing_stanza_queue or 0); + module:fire_event("smacks-ack-delayed", {origin = session, queue = session.outgoing_stanza_queue, stanza = stanza}); + end + session.delayed_ack_timer = nil; +end + +local function can_do_smacks(session, advertise_only) + if session.smacks then return false, "unexpected-request", "Stream management is already enabled"; end + + local session_type = session.type; + if session.username then + if not(advertise_only) and not(session.resource) then -- Fail unless we're only advertising sm + return false, "unexpected-request", "Client must bind a resource before enabling stream management"; + end + return true; + elseif s2s_smacks and (session_type == "s2sin" or session_type == "s2sout") then + return true; + end + return false, "service-unavailable", "Stream management is not available for this stream"; +end + +module:hook("stream-features", + function (event) + if can_do_smacks(event.origin, true) then + event.features:tag("sm", sm2_attr):tag("optional"):up():up(); + event.features:tag("sm", sm3_attr):tag("optional"):up():up(); + end + end); + +module:hook("s2s-stream-features", + function (event) + if can_do_smacks(event.origin, true) then + event.features:tag("sm", sm2_attr):tag("optional"):up():up(); + event.features:tag("sm", sm3_attr):tag("optional"):up():up(); + end + end); + +local function request_ack_if_needed(session, force, reason, stanza) + local queue = session.outgoing_stanza_queue; + local expected_h = session.last_acknowledged_stanza + #queue; + -- session.log("debug", "*** SMACKS(1) ***: awaiting_ack=%s, hibernating=%s", tostring(session.awaiting_ack), tostring(session.hibernating)); + if session.awaiting_ack == nil and not session.hibernating then + local max_unacked = max_unacked_stanzas; + if session.state == "inactive" then + max_unacked = max_inactive_unacked_stanzas; + end + -- this check of last_requested_h prevents ack-loops if missbehaving clients report wrong + -- stanza counts. it is set when an is really sent (e.g. inside timer), preventing any + -- further requests until a higher h-value would be expected. + -- session.log("debug", "*** SMACKS(2) ***: #queue=%s, max_unacked_stanzas=%s, expected_h=%s, last_requested_h=%s", tostring(#queue), tostring(max_unacked_stanzas), tostring(expected_h), tostring(session.last_requested_h)); + if (#queue > max_unacked and expected_h ~= session.last_requested_h) or force then + session.log("debug", "Queuing (in a moment) from %s - #queue=%d", reason, #queue); + session.awaiting_ack = false; + session.awaiting_ack_timer = stoppable_timer(1e-06, function () + -- session.log("debug", "*** SMACKS(3) ***: awaiting_ack=%s, hibernating=%s", tostring(session.awaiting_ack), tostring(session.hibernating)); + -- only request ack if needed and our session is not already hibernated or destroyed + if not session.awaiting_ack and not session.hibernating and not session.destroyed then + session.log("debug", "Sending (inside timer, before send) from %s - #queue=%d", reason, #queue); + (session.sends2s or session.send)(st.stanza("r", { xmlns = session.smacks })) + if session.destroyed then return end -- sending something can trigger destruction + session.awaiting_ack = true; + -- expected_h could be lower than this expression e.g. more stanzas added to the queue meanwhile) + session.last_requested_h = session.last_acknowledged_stanza + #queue; + session.log("debug", "Sending (inside timer, after send) from %s - #queue=%d", reason, #queue); + if not session.delayed_ack_timer then + session.delayed_ack_timer = stoppable_timer(delayed_ack_timeout, function() + delayed_ack_function(session, nil); -- we don't know if this is the only new stanza in the queue + end); + end + end + end); + end + end + + -- Trigger "smacks-ack-delayed"-event if we added new (ackable) stanzas to the outgoing queue + -- and there isn't already a timer for this event running. + -- If we wouldn't do this, stanzas added to the queue after the first "smacks-ack-delayed"-event + -- would not trigger this event (again). + if #queue > max_unacked_stanzas and session.awaiting_ack and session.delayed_ack_timer == nil then + session.log("debug", "Calling delayed_ack_function directly (still waiting for ack)"); + delayed_ack_function(session, stanza); -- this is the only new stanza in the queue --> provide it to other modules + end +end + +local function outgoing_stanza_filter(stanza, session) + -- XXX: Normally you wouldn't have to check the xmlns for a stanza as it's + -- supposed to be nil. + -- However, when using mod_smacks with mod_websocket, then mod_websocket's + -- stanzas/out filter can get called before this one and adds the xmlns. + local is_stanza = stanza.attr and + (not stanza.attr.xmlns or stanza.attr.xmlns == 'jabber:client') + and not stanza.name:find":"; + + if is_stanza and not stanza._cached then + local queue = session.outgoing_stanza_queue; + local cached_stanza = st.clone(stanza); + cached_stanza._cached = true; + + if cached_stanza and cached_stanza.name ~= "iq" and cached_stanza:get_child("delay", xmlns_delay) == nil then + cached_stanza = cached_stanza:tag("delay", { + xmlns = xmlns_delay, + from = jid.bare(session.full_jid or session.host), + stamp = datetime.datetime() + }); + end + + queue[#queue+1] = cached_stanza; + if session.hibernating then + session.log("debug", "hibernating since %s, stanza queued", datetime.datetime(session.hibernating)); + module:fire_event("smacks-hibernation-stanza-queued", {origin = session, queue = queue, stanza = cached_stanza}); + return nil; + end + request_ack_if_needed(session, false, "outgoing_stanza_filter", stanza); + end + return stanza; +end + +local function count_incoming_stanzas(stanza, session) + if not stanza.attr.xmlns then + session.handled_stanza_count = session.handled_stanza_count + 1; + session.log("debug", "Handled %d incoming stanzas", session.handled_stanza_count); + end + return stanza; +end + +local function wrap_session_out(session, resume) + if not resume then + session.outgoing_stanza_queue = {}; + session.last_acknowledged_stanza = 0; + end + + add_filter(session, "stanzas/out", outgoing_stanza_filter, -999); + + local session_close = session.close; + function session.close(...) + if session.resumption_token then + session_registry.set(session.username, session.resumption_token, nil); + old_session_registry.set(session.username, session.resumption_token, nil); + session.resumption_token = nil; + end + -- send out last ack as per revision 1.5.2 of XEP-0198 + if session.smacks and session.conn then + (session.sends2s or session.send)(st.stanza("a", { xmlns = session.smacks, h = string.format("%d", session.handled_stanza_count) })); + end + return session_close(...); + end + return session; +end + +local function wrap_session_in(session, resume) + if not resume then + session.handled_stanza_count = 0; + end + add_filter(session, "stanzas/in", count_incoming_stanzas, 999); + + return session; +end + +local function wrap_session(session, resume) + wrap_session_out(session, resume); + wrap_session_in(session, resume); + return session; +end + +function handle_enable(session, stanza, xmlns_sm) + local ok, err, err_text = can_do_smacks(session); + if not ok then + session.log("warn", "Failed to enable smacks: %s", err_text); -- TODO: XEP doesn't say we can send error text, should it? + (session.sends2s or session.send)(st.stanza("failed", { xmlns = xmlns_sm }):tag(err, { xmlns = xmlns_errors})); + return true; + end + + module:log("debug", "Enabling stream management"); + session.smacks = xmlns_sm; + + wrap_session(session, false); + + local resume_token; + local resume = stanza.attr.resume; + if resume == "true" or resume == "1" then + resume_token = uuid_generate(); + session_registry.set(session.username, resume_token, session); + session.resumption_token = resume_token; + end + (session.sends2s or session.send)(st.stanza("enabled", { xmlns = xmlns_sm, id = resume_token, resume = resume, max = tostring(resume_timeout) })); + return true; +end +module:hook_stanza(xmlns_sm2, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm2); end, 100); +module:hook_stanza(xmlns_sm3, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm3); end, 100); + +module:hook_stanza("http://etherx.jabber.org/streams", "features", + function (session, stanza) + stoppable_timer(1e-6, function () + if can_do_smacks(session) then + if stanza:get_child("sm", xmlns_sm3) then + session.sends2s(st.stanza("enable", sm3_attr)); + session.smacks = xmlns_sm3; + elseif stanza:get_child("sm", xmlns_sm2) then + session.sends2s(st.stanza("enable", sm2_attr)); + session.smacks = xmlns_sm2; + else + return; + end + wrap_session_out(session, false); + end + end); + end); + +function handle_enabled(session, stanza, xmlns_sm) + module:log("debug", "Enabling stream management"); + session.smacks = xmlns_sm; + + wrap_session_in(session, false); + + -- FIXME Resume? + + return true; +end +module:hook_stanza(xmlns_sm2, "enabled", function (session, stanza) return handle_enabled(session, stanza, xmlns_sm2); end, 100); +module:hook_stanza(xmlns_sm3, "enabled", function (session, stanza) return handle_enabled(session, stanza, xmlns_sm3); end, 100); + +function handle_r(origin, stanza, xmlns_sm) + if not origin.smacks then + module:log("debug", "Received ack request from non-smack-enabled session"); + return; + end + module:log("debug", "Received ack request, acking for %d", origin.handled_stanza_count); + -- Reply with + (origin.sends2s or origin.send)(st.stanza("a", { xmlns = xmlns_sm, h = string.format("%d", origin.handled_stanza_count) })); + -- piggyback our own ack request if needed (see request_ack_if_needed() for explanation of last_requested_h) + local expected_h = origin.last_acknowledged_stanza + #origin.outgoing_stanza_queue; + if #origin.outgoing_stanza_queue > 0 and expected_h ~= origin.last_requested_h then + request_ack_if_needed(origin, true, "piggybacked by handle_r", nil); + end + return true; +end +module:hook_stanza(xmlns_sm2, "r", function (origin, stanza) return handle_r(origin, stanza, xmlns_sm2); end); +module:hook_stanza(xmlns_sm3, "r", function (origin, stanza) return handle_r(origin, stanza, xmlns_sm3); end); + +function handle_a(origin, stanza) + if not origin.smacks then return; end + origin.awaiting_ack = nil; + if origin.awaiting_ack_timer then + origin.awaiting_ack_timer:stop(); + end + if origin.delayed_ack_timer then + origin.delayed_ack_timer:stop(); + origin.delayed_ack_timer = nil; + end + -- Remove handled stanzas from outgoing_stanza_queue + -- origin.log("debug", "ACK: h=%s, last=%s", stanza.attr.h or "", origin.last_acknowledged_stanza or ""); + local h = tonumber(stanza.attr.h); + if not h then + origin:close{ condition = "invalid-xml"; text = "Missing or invalid 'h' attribute"; }; + return; + end + local handled_stanza_count = h-origin.last_acknowledged_stanza; + local queue = origin.outgoing_stanza_queue; + if handled_stanza_count > #queue then + origin.log("warn", "The client says it handled %d new stanzas, but we only sent %d :)", + handled_stanza_count, #queue); + origin.log("debug", "Client h: %d, our h: %d", tonumber(stanza.attr.h), origin.last_acknowledged_stanza); + for i=1,#queue do + origin.log("debug", "Q item %d: %s", i, tostring(queue[i])); + end + end + + for i=1,math_min(handled_stanza_count,#queue) do + local handled_stanza = t_remove(origin.outgoing_stanza_queue, 1); + module:fire_event("delivery/success", { session = origin, stanza = handled_stanza }); + end + + origin.log("debug", "#queue = %d", #queue); + origin.last_acknowledged_stanza = origin.last_acknowledged_stanza + handled_stanza_count; + request_ack_if_needed(origin, false, "handle_a", nil) + return true; +end +module:hook_stanza(xmlns_sm2, "a", handle_a); +module:hook_stanza(xmlns_sm3, "a", handle_a); + +--TODO: Optimise... incoming stanzas should be handled by a per-session +-- function that has a counter as an upvalue (no table indexing for increments, +-- and won't slow non-198 sessions). We can also then remove the .handled flag +-- on stanzas + +local function handle_unacked_stanzas(session) + local queue = session.outgoing_stanza_queue; + local error_attr = { type = "cancel" }; + if #queue > 0 then + session.outgoing_stanza_queue = {}; + for i=1,#queue do + if not module:fire_event("delivery/failure", { session = session, stanza = queue[i] }) then + if queue[i].attr.type ~= "error" then + local reply = st.reply(queue[i]); + if reply.attr.to ~= session.full_jid then + reply.attr.type = "error"; + reply:tag("error", error_attr) + :tag("recipient-unavailable", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}); + core_process_stanza(session, reply); + end + end + end + end + end +end + +-- don't send delivery errors for messages which will be delivered by mam later on +-- check if stanza was archived --> this will allow us to send back errors for stanzas not archived +-- because the user configured the server to do so ("no-archive"-setting for one special contact for example) +local function get_stanza_id(stanza, by_jid) + for tag in stanza:childtags("stanza-id", "urn:xmpp:sid:0") do + if tag.attr.by == by_jid then + return tag.attr.id; + end + end + return nil; +end +module:hook("delivery/failure", function(event) + local session, stanza = event.session, event.stanza; + -- Only deal with authenticated (c2s) sessions + if session.username then + if stanza.name == "message" and stanza.attr.xmlns == nil and + ( stanza.attr.type == "chat" or ( stanza.attr.type or "normal" ) == "normal" ) then + -- don't store messages in offline store if they are mam results + local mam_result = stanza:get_child("result", xmlns_mam2); + if mam_result ~= nil then + return true; -- stanza already "handled", don't send an error and don't add it to offline storage + end + -- do nothing here for normal messages and don't send out "message delivery errors", + -- because messages are already in MAM at this point (no need to frighten users) + local stanza_id = get_stanza_id(stanza, jid.bare(session.full_jid)); + if session.mam_requested and stanza_id ~= nil then + session.log("debug", "mod_smacks delivery/failure returning true for mam-handled stanza: mam-archive-id=%s", tostring(stanza_id)); + return true; -- stanza handled, don't send an error + end + -- store message in offline store, if this client does not use mam *and* was the last client online + local sessions = prosody.hosts[module.host].sessions[session.username] and + prosody.hosts[module.host].sessions[session.username].sessions or nil; + if sessions and next(sessions) == session.resource and next(sessions, session.resource) == nil then + local ok = module:fire_event("message/offline/handle", { origin = session, stanza = stanza } ); + session.log("debug", "mod_smacks delivery/failuere returning %s for offline-handled stanza", tostring(ok)); + return ok; -- if stanza was handled, don't send an error + end + end + end +end); + +module:hook("pre-resource-unbind", function (event) + local session, err = event.session, event.error; + if session.smacks then + if not session.resumption_token then + local queue = session.outgoing_stanza_queue; + if #queue > 0 then + session.log("debug", "Destroying session with %d unacked stanzas", #queue); + handle_unacked_stanzas(session); + end + else + session.log("debug", "mod_smacks hibernating session for up to %d seconds", resume_timeout); + local hibernate_time = os_time(); -- Track the time we went into hibernation + session.hibernating = hibernate_time; + local resumption_token = session.resumption_token; + module:fire_event("smacks-hibernation-start", {origin = session, queue = session.outgoing_stanza_queue}); + timer.add_task(resume_timeout, function () + session.log("debug", "mod_smacks hibernation timeout reached..."); + -- We need to check the current resumption token for this resource + -- matches the smacks session this timer is for in case it changed + -- (for example, the client may have bound a new resource and + -- started a new smacks session, or not be using smacks) + local curr_session = full_sessions[session.full_jid]; + if session.destroyed then + session.log("debug", "The session has already been destroyed"); + elseif curr_session and curr_session.resumption_token == resumption_token + -- Check the hibernate time still matches what we think it is, + -- otherwise the session resumed and re-hibernated. + and session.hibernating == hibernate_time then + -- wait longer if the timeout isn't reached because push was enabled for this session + -- session.first_hibernated_push is the starting point for hibernation timeouts of those push enabled clients + -- wait for an additional resume_timeout seconds if no push occurred since hibernation at all + local current_time = os_time(); + local timeout_start = math_max(session.hibernating, session.first_hibernated_push or session.hibernating); + if session.push_identifier ~= nil and not session.first_hibernated_push then + session.log("debug", "No push happened since hibernation started, hibernating session for up to %d extra seconds", resume_timeout); + return resume_timeout; + end + if session.push_identifier ~= nil and current_time-timeout_start < resume_timeout then + session.log("debug", "A push happened since hibernation started, hibernating session for up to %d extra seconds", resume_timeout-(current_time-timeout_start)); + return resume_timeout-(current_time-timeout_start); -- time left to wait + end + session.log("debug", "Destroying session for hibernating too long"); + session_registry.set(session.username, session.resumption_token, nil); + -- save only actual h value and username/host (for security) + old_session_registry.set(session.username, session.resumption_token, { + h = session.handled_stanza_count, + username = session.username, + host = session.host + }); + session.resumption_token = nil; + sessionmanager.destroy_session(session); + else + session.log("debug", "Session resumed before hibernation timeout, all is well") + end + end); + return true; -- Postpone destruction for now + end + end +end); + +local function handle_s2s_destroyed(event) + local session = event.session; + local queue = session.outgoing_stanza_queue; + if queue and #queue > 0 then + session.log("warn", "Destroying session with %d unacked stanzas", #queue); + if s2s_resend then + for i = 1, #queue do + module:send(queue[i]); + end + session.outgoing_stanza_queue = nil; + else + handle_unacked_stanzas(session); + end + end +end + +module:hook("s2sout-destroyed", handle_s2s_destroyed); +module:hook("s2sin-destroyed", handle_s2s_destroyed); + +local function get_session_id(session) + return session.id or (tostring(session):match("[a-f0-9]+$")); +end + +function handle_resume(session, stanza, xmlns_sm) + if session.full_jid then + session.log("warn", "Tried to resume after resource binding"); + session.send(st.stanza("failed", { xmlns = xmlns_sm }) + :tag("unexpected-request", { xmlns = xmlns_errors }) + ); + return true; + end + + local id = stanza.attr.previd; + local original_session = session_registry.get(session.username, id); + if not original_session then + session.log("debug", "Tried to resume non-existent session with id %s", id); + local old_session = old_session_registry.get(session.username, id); + if old_session and session.username == old_session.username + and session.host == old_session.host + and old_session.h then + session.send(st.stanza("failed", { xmlns = xmlns_sm, h = string.format("%d", old_session.h) }) + :tag("item-not-found", { xmlns = xmlns_errors }) + ); + else + session.send(st.stanza("failed", { xmlns = xmlns_sm }) + :tag("item-not-found", { xmlns = xmlns_errors }) + ); + end; + elseif session.username == original_session.username + and session.host == original_session.host then + session.log("debug", "mod_smacks resuming existing session %s...", get_session_id(original_session)); + original_session.log("debug", "mod_smacks session resumed from %s...", get_session_id(session)); + -- TODO: All this should move to sessionmanager (e.g. session:replace(new_session)) + if original_session.conn then + original_session.log("debug", "mod_smacks closing an old connection for this session"); + local conn = original_session.conn; + c2s_sessions[conn] = nil; + conn:close(); + end + local migrated_session_log = session.log; + original_session.ip = session.ip; + original_session.conn = session.conn; + original_session.send = session.send; + original_session.close = session.close; + original_session.filter = session.filter; + original_session.filter.session = original_session; + original_session.filters = session.filters; + original_session.stream = session.stream; + original_session.secure = session.secure; + original_session.hibernating = nil; + session.log = original_session.log; + session.type = original_session.type; + wrap_session(original_session, true); + -- Inform xmppstream of the new session (passed to its callbacks) + original_session.stream:set_session(original_session); + -- Similar for connlisteners + c2s_sessions[session.conn] = original_session; + + original_session.send(st.stanza("resumed", { xmlns = xmlns_sm, + h = string.format("%d", original_session.handled_stanza_count), previd = id })); + + -- Fake an with the h of the from the client + original_session:dispatch_stanza(st.stanza("a", { xmlns = xmlns_sm, + h = stanza.attr.h })); + + -- Ok, we need to re-send any stanzas that the client didn't see + -- ...they are what is now left in the outgoing stanza queue + -- We have to use the send of "session" because we don't want to add our resent stanzas + -- to the outgoing queue again + local queue = original_session.outgoing_stanza_queue; + session.log("debug", "resending all unacked stanzas that are still queued after resume, #queue = %d", #queue); + for i=1,#queue do + session.send(queue[i]); + end + session.log("debug", "all stanzas resent, now disabling send() in this migrated session, #queue = %d", #queue); + function session.send(stanza) + migrated_session_log("error", "Tried to send stanza on old session migrated by smacks resume (maybe there is a bug?): %s", tostring(stanza)); + return false; + end + module:fire_event("smacks-hibernation-end", {origin = session, resumed = original_session, queue = queue}); + request_ack_if_needed(original_session, true, "handle_resume", nil); + else + module:log("warn", "Client %s@%s[%s] tried to resume stream for %s@%s[%s]", + session.username or "?", session.host or "?", session.type, + original_session.username or "?", original_session.host or "?", original_session.type); + session.send(st.stanza("failed", { xmlns = xmlns_sm }) + :tag("not-authorized", { xmlns = xmlns_errors })); + end + return true; +end +module:hook_stanza(xmlns_sm2, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm2); end); +module:hook_stanza(xmlns_sm3, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm3); end); + +module:hook("csi-client-active", function (event) + if event.origin.smacks then + request_ack_if_needed(event.origin, true, "csi-active", nil); + end +end); + +module:hook("csi-flushing", function (event) + if event.session.smacks then + request_ack_if_needed(event.session, true, "csi-active", nil); + end +end); + +local function handle_read_timeout(event) + local session = event.session; + if session.smacks then + if session.awaiting_ack then + if session.awaiting_ack_timer then + session.awaiting_ack_timer:stop(); + end + if session.delayed_ack_timer then + session.delayed_ack_timer:stop(); + session.delayed_ack_timer = nil; + end + return false; -- Kick the session + end + session.log("debug", "Sending (read timeout)"); + (session.sends2s or session.send)(st.stanza("r", { xmlns = session.smacks })); + session.awaiting_ack = true; + if not session.delayed_ack_timer then + session.delayed_ack_timer = stoppable_timer(delayed_ack_timeout, function() + delayed_ack_function(session, nil); + end); + end + return true; + end +end + +module:hook("s2s-read-timeout", handle_read_timeout); +module:hook("c2s-read-timeout", handle_read_timeout); diff --git a/roles/jitsi/files/prosody/modules/mod_speakerstats.lua b/roles/jitsi/files/prosody/modules/mod_speakerstats.lua new file mode 100644 index 0000000..bac15c4 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_speakerstats.lua @@ -0,0 +1,6 @@ +local speakerstats_component + = module:get_option_string("speakerstats_component", "speakerstats."..module.host); + +-- Advertise speaker stats so client can pick up the address and start sending +-- dominant speaker events +module:add_identity("component", "speakerstats", speakerstats_component); diff --git a/roles/jitsi/files/prosody/modules/mod_speakerstats_component.lua b/roles/jitsi/files/prosody/modules/mod_speakerstats_component.lua new file mode 100644 index 0000000..3487b17 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_speakerstats_component.lua @@ -0,0 +1,379 @@ +local util = module:require "util"; +local get_room_from_jid = util.get_room_from_jid; +local room_jid_match_rewrite = util.room_jid_match_rewrite; +local is_healthcheck_room = util.is_healthcheck_room; +local process_host_module = util.process_host_module; +local jid_resource = require "util.jid".resource; +local st = require "util.stanza"; +local socket = require "socket"; +local json = require 'cjson.safe'; +local um_is_admin = require "core.usermanager".is_admin; +local jid_split = require 'util.jid'.split; + +-- we use async to detect Prosody 0.10 and earlier +local have_async = pcall(require, "util.async"); +if not have_async then + module:log("warn", "speaker stats will not work with Prosody version 0.10 or less."); + return; +end + +local muc_component_host = module:get_option_string("muc_component"); +local muc_domain_base = module:get_option_string("muc_mapper_domain_base"); + +if muc_component_host == nil or muc_domain_base == nil then + module:log("error", "No muc_component specified. No muc to operate on!"); + return; +end +local breakout_room_component_host = "breakout." .. muc_domain_base; + +module:log("info", "Starting speakerstats for %s", muc_component_host); + +local main_muc_service; + +local function is_admin(jid) + return um_is_admin(jid, module.host); +end + +-- Searches all rooms in the main muc component that holds a breakout room +-- caches it if found so we don't search it again +-- we should not cache objects in _data as this is being serialized when calling room:save() +local function get_main_room(breakout_room) + if breakout_room.main_room then + return breakout_room.main_room; + end + + -- let's search all rooms to find the main room + for room in main_muc_service.each_room() do + if room._data and room._data.breakout_rooms_active and room._data.breakout_rooms[breakout_room.jid] then + breakout_room.main_room = room; + return room; + end + end +end + +-- receives messages from client currently connected to the room +-- clients indicates their own dominant speaker events +function on_message(event) + -- Check the type of the incoming stanza to avoid loops: + if event.stanza.attr.type == "error" then + return; -- We do not want to reply to these, so leave. + end + + local speakerStats + = event.stanza:get_child('speakerstats', 'http://jitsi.org/jitmeet'); + if speakerStats then + local roomAddress = speakerStats.attr.room; + local silence = speakerStats.attr.silence == 'true'; + local room = get_room_from_jid(room_jid_match_rewrite(roomAddress)); + + if not room then + module:log("warn", "No room found %s", roomAddress); + return false; + end + + if not room.speakerStats then + module:log("warn", "No speakerStats found for %s", roomAddress); + return false; + end + + local roomSpeakerStats = room.speakerStats; + local from = event.stanza.attr.from; + + local occupant = room:get_occupant_by_real_jid(from); + if not occupant then + module:log("warn", "No occupant %s found for %s", from, roomAddress); + return false; + end + + local newDominantSpeaker = roomSpeakerStats[occupant.jid]; + local oldDominantSpeakerId = roomSpeakerStats['dominantSpeakerId']; + + if oldDominantSpeakerId and occupant.jid ~= oldDominantSpeakerId then + local oldDominantSpeaker = roomSpeakerStats[oldDominantSpeakerId]; + if oldDominantSpeaker then + oldDominantSpeaker:setDominantSpeaker(false, false); + end + end + + if newDominantSpeaker then + newDominantSpeaker:setDominantSpeaker(true, silence); + end + + room.speakerStats['dominantSpeakerId'] = occupant.jid; + end + + local newFaceLandmarks = event.stanza:get_child('faceLandmarks', 'http://jitsi.org/jitmeet'); + + if newFaceLandmarks then + local roomAddress = newFaceLandmarks.attr.room; + local room = get_room_from_jid(room_jid_match_rewrite(roomAddress)); + + if not room then + module:log("warn", "No room found %s", roomAddress); + return false; + end + if not room.speakerStats then + module:log("warn", "No speakerStats found for %s", roomAddress); + return false; + end + local from = event.stanza.attr.from; + + local occupant = room:get_occupant_by_real_jid(from); + if not occupant or not room.speakerStats[occupant.jid] then + module:log("warn", "No occupant %s found for %s", from, roomAddress); + return false; + end + local faceLandmarks = room.speakerStats[occupant.jid].faceLandmarks; + table.insert(faceLandmarks, + { + faceExpression = newFaceLandmarks.attr.faceExpression, + timestamp = tonumber(newFaceLandmarks.attr.timestamp), + duration = tonumber(newFaceLandmarks.attr.duration), + }) + end + + return true +end + +--- Start SpeakerStats implementation +local SpeakerStats = {}; +SpeakerStats.__index = SpeakerStats; + +function new_SpeakerStats(nick, context_user) + return setmetatable({ + totalDominantSpeakerTime = 0; + _dominantSpeakerStart = 0; + _isSilent = false; + _isDominantSpeaker = false; + nick = nick; + context_user = context_user; + displayName = nil; + faceLandmarks = {}; + }, SpeakerStats); +end + +-- Changes the dominantSpeaker data for current occupant +-- saves start time if it is new dominat speaker +-- or calculates and accumulates time of speaking +function SpeakerStats:setDominantSpeaker(isNowDominantSpeaker, silence) + -- module:log("debug", "set isDominant %s for %s", tostring(isNowDominantSpeaker), self.nick); + + local now = socket.gettime()*1000; + + if not self:isDominantSpeaker() and isNowDominantSpeaker and not silence then + self._dominantSpeakerStart = now; + elseif self:isDominantSpeaker() then + if not isNowDominantSpeaker then + if not self._isSilent then + local timeElapsed = math.floor(now - self._dominantSpeakerStart); + + self.totalDominantSpeakerTime = self.totalDominantSpeakerTime + timeElapsed; + self._dominantSpeakerStart = 0; + end + elseif self._isSilent and not silence then + self._dominantSpeakerStart = now; + elseif not self._isSilent and silence then + local timeElapsed = math.floor(now - self._dominantSpeakerStart); + + self.totalDominantSpeakerTime = self.totalDominantSpeakerTime + timeElapsed; + self._dominantSpeakerStart = 0; + end + end + + self._isDominantSpeaker = isNowDominantSpeaker; + self._isSilent = silence; +end + +-- Returns true if the tracked user is currently a dominant speaker. +function SpeakerStats:isDominantSpeaker() + return self._isDominantSpeaker; +end + + -- Returns true if the tracked user is currently silent. +function SpeakerStats:isSilent() + return self._isSilent; +end +--- End SpeakerStats + +-- create speakerStats for the room +function room_created(event) + local room = event.room; + + if is_healthcheck_room(room.jid) then + return ; + end + room.speakerStats = {}; + room.speakerStats.sessionId = room._data.meetingId; +end + +-- create speakerStats for the breakout +function breakout_room_created(event) + local room = event.room; + if is_healthcheck_room(room.jid) then + return ; + end + local main_room = get_main_room(room); + room.speakerStats = {}; + room.speakerStats.isBreakout = true + room.speakerStats.breakoutRoomId = jid_split(room.jid) + room.speakerStats.sessionId = main_room._data.meetingId; +end + +-- Create SpeakerStats object for the joined user +function occupant_joined(event) + local occupant, room = event.occupant, event.room; + + if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then + return; + end + + local occupant = event.occupant; + + local nick = jid_resource(occupant.nick); + + if room.speakerStats then + -- lets send the current speaker stats to that user, so he can update + -- its local stats + if next(room.speakerStats) ~= nil then + local users_json = {}; + for jid, values in pairs(room.speakerStats) do + -- skip reporting those without a nick('dominantSpeakerId') + -- and skip focus if sneaked into the table + if values and type(values) == 'table' and values.nick ~= nil and values.nick ~= 'focus' then + local totalDominantSpeakerTime = values.totalDominantSpeakerTime; + local faceLandmarks = values.faceLandmarks; + if totalDominantSpeakerTime > 0 or room:get_occupant_jid(jid) == nil or values:isDominantSpeaker() + or next(faceLandmarks) ~= nil then + -- before sending we need to calculate current dominant speaker state + if values:isDominantSpeaker() and not values:isSilent() then + local timeElapsed = math.floor(socket.gettime()*1000 - values._dominantSpeakerStart); + totalDominantSpeakerTime = totalDominantSpeakerTime + timeElapsed; + end + + users_json[values.nick] = { + displayName = values.displayName, + totalDominantSpeakerTime = totalDominantSpeakerTime, + faceLandmarks = faceLandmarks + }; + end + end + end + + if next(users_json) ~= nil then + local body_json = {}; + body_json.type = 'speakerstats'; + body_json.users = users_json; + + local json_msg_str, error = json.encode(body_json); + + if json_msg_str then + local stanza = st.message({ + from = module.host; + to = occupant.jid; }) + :tag("json-message", {xmlns='http://jitsi.org/jitmeet'}) + :text(json_msg_str):up(); + + room:route_stanza(stanza); + else + module:log('error', 'Error encoding room:%s error:%s', room.jid, error); + end + end + end + + local context_user = event.origin and event.origin.jitsi_meet_context_user or nil; + room.speakerStats[occupant.jid] = new_SpeakerStats(nick, context_user); + end +end + +-- Occupant left set its dominant speaker to false and update the store the +-- display name +function occupant_leaving(event) + local room = event.room; + + if is_healthcheck_room(room.jid) then + return; + end + + if not room.speakerStats then + return; + end + + local occupant = event.occupant; + + local speakerStatsForOccupant = room.speakerStats[occupant.jid]; + if speakerStatsForOccupant then + speakerStatsForOccupant:setDominantSpeaker(false, false); + + -- set display name + local displayName = occupant:get_presence():get_child_text( + 'nick', 'http://jabber.org/protocol/nick'); + speakerStatsForOccupant.displayName = displayName; + end +end + +-- Conference ended, send speaker stats +function room_destroyed(event) + local room = event.room; + + if is_healthcheck_room(room.jid) then + return; + end + + module:fire_event("send-speaker-stats", { room = room; roomSpeakerStats = room.speakerStats; }); +end + +module:hook("message/host", on_message); + +function process_main_muc_loaded(main_muc, host_module) + -- the conference muc component + module:log("info", "Hook to muc events on %s", host_module.host); + main_muc_service = main_muc; + module:log("info", "Main muc service %s", main_muc_service) + host_module:hook("muc-room-created", room_created, -1); + host_module:hook("muc-occupant-joined", occupant_joined, -1); + host_module:hook("muc-occupant-pre-leave", occupant_leaving, -1); + host_module:hook("muc-room-destroyed", room_destroyed, -1); +end + +function process_breakout_muc_loaded(breakout_muc, host_module) + -- the Breakout muc component + module:log("info", "Hook to muc events on %s", host_module.host); + host_module:hook("muc-room-created", breakout_room_created, -1); + host_module:hook("muc-occupant-joined", occupant_joined, -1); + host_module:hook("muc-occupant-pre-leave", occupant_leaving, -1); + host_module:hook("muc-room-destroyed", room_destroyed, -1); +end + +-- process or waits to process the conference muc component +process_host_module(muc_component_host, function(host_module, host) + module:log('info', 'Conference component loaded %s', host); + + local muc_module = prosody.hosts[host].modules.muc; + if muc_module then + process_main_muc_loaded(muc_module, host_module); + else + module:log('debug', 'Will wait for muc to be available'); + prosody.hosts[host].events.add_handler('module-loaded', function(event) + if (event.module == 'muc') then + process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module); + end + end); + end +end); + +-- process or waits to process the breakout rooms muc component +process_host_module(breakout_room_component_host, function(host_module, host) + module:log('info', 'Breakout component loaded %s', host); + + local muc_module = prosody.hosts[host].modules.muc; + if muc_module then + process_breakout_muc_loaded(muc_module, host_module); + else + module:log('debug', 'Will wait for muc to be available'); + prosody.hosts[host].events.add_handler('module-loaded', function(event) + if (event.module == 'muc') then + process_breakout_muc_loaded(prosody.hosts[host].modules.muc, host_module); + end + end); + end +end); diff --git a/roles/jitsi/files/prosody/modules/mod_system_chat_message.lua b/roles/jitsi/files/prosody/modules/mod_system_chat_message.lua new file mode 100644 index 0000000..79c71e1 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_system_chat_message.lua @@ -0,0 +1,126 @@ +-- Module which can be used as an http endpoint to send system private chat messages to meeting participants. The provided token +--- in the request is verified whether it has the right to do so. This module should be loaded under the virtual host. +-- Copyright (C) 2024-present 8x8, Inc. + +-- curl https://{host}/send-system-chat-message -d '{"message": "testmessage", "connectionJIDs": ["{connection_jid}"], "room": "{room_jid}"}' -H "content-type: application/json" -H "authorization: Bearer {token}" + +local util = module:require "util"; +local token_util = module:require "token/util".new(module); + +local async_handler_wrapper = util.async_handler_wrapper; +local room_jid_match_rewrite = util.room_jid_match_rewrite; +local starts_with = util.starts_with; +local get_room_from_jid = util.get_room_from_jid; + +local st = require "util.stanza"; +local json = require "cjson.safe"; + +local muc_domain_base = module:get_option_string("muc_mapper_domain_base"); +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; + }; +}); diff --git a/roles/jitsi/files/prosody/modules/mod_token_verification.lua b/roles/jitsi/files/prosody/modules/mod_token_verification.lua new file mode 100644 index 0000000..a888dc0 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_token_verification.lua @@ -0,0 +1,139 @@ +-- Token authentication +-- Copyright (C) 2021-present 8x8, Inc. + +local log = module._log; +local host = module.host; +local st = require "util.stanza"; +local um_is_admin = require "core.usermanager".is_admin; +local jid_split = require 'util.jid'.split; +local jid_bare = require 'util.jid'.bare; + +local DEBUG = false; + +local measure_success = module:measure('success', 'counter'); +local measure_fail = module:measure('fail', 'counter'); + +local function is_admin(jid) + return um_is_admin(jid, host); +end + +local parentHostName = string.gmatch(tostring(host), "%w+.(%w.+)")(); +if parentHostName == nil then + module:log("error", "Failed to start - unable to get parent hostname"); + return; +end + +local parentCtx = module:context(parentHostName); +if parentCtx == nil then + module:log("error", + "Failed to start - unable to get parent context for host: %s", + tostring(parentHostName)); + return; +end + +local token_util = module:require "token/util".new(parentCtx); + +-- no token configuration +if token_util == nil then + return; +end + +module:log("debug", + "%s - starting MUC token verifier app_id: %s app_secret: %s allow empty: %s", + tostring(host), tostring(token_util.appId), tostring(token_util.appSecret), + tostring(token_util.allowEmptyToken)); + +-- option to disable room modification (sending muc config form) for guest that do not provide token +local require_token_for_moderation; +-- option to allow domains to skip token verification +local allowlist; +local function load_config() + require_token_for_moderation = module:get_option_boolean("token_verification_require_token_for_moderation"); + allowlist = module:get_option_set('token_verification_allowlist', {}); +end +load_config(); + +-- verify user and whether he is allowed to join a room based on the token information +local function verify_user(session, stanza) + if DEBUG then + module:log("debug", "Session token: %s, session room: %s", + tostring(session.auth_token), tostring(session.jitsi_meet_room)); + end + + -- token not required for admin users + local user_jid = stanza.attr.from; + if is_admin(user_jid) then + if DEBUG then module:log("debug", "Token not required from admin user: %s", user_jid); end + return true; + end + + -- token not required for users matching allow list + local user_bare_jid = jid_bare(user_jid); + local _, user_domain = jid_split(user_jid); + + -- allowlist for participants + if allowlist:contains(user_domain) or allowlist:contains(user_bare_jid) then + if DEBUG then module:log("debug", "Token not required from user in allow list: %s", user_jid); end + return true; + end + + if DEBUG then module:log("debug", "Will verify token for user: %s, room: %s ", user_jid, stanza.attr.to); end + if not token_util:verify_room(session, stanza.attr.to) then + module:log("error", "Token %s not allowed to join: %s", + tostring(session.auth_token), tostring(stanza.attr.to)); + session.send( + st.error_reply( + stanza, "cancel", "not-allowed", "Room and token mismatched")); + return false; -- we need to just return non nil + end + if DEBUG then module:log("debug", "allowed: %s to enter/create room: %s", user_jid, stanza.attr.to); end + return true; +end + +module:hook("muc-room-pre-create", function(event) + local origin, stanza = event.origin, event.stanza; + if DEBUG then module:log("debug", "pre create: %s %s", tostring(origin), tostring(stanza)); end + if not verify_user(origin, stanza) then + measure_fail(1); + return true; -- Returning any value other than nil will halt processing of the event + end + measure_success(1); +end, 99); + +module:hook("muc-occupant-pre-join", function(event) + local origin, room, stanza = event.origin, event.room, event.stanza; + if DEBUG then module:log("debug", "pre join: %s %s", tostring(room), tostring(stanza)); end + if not verify_user(origin, stanza) then + measure_fail(1); + return true; -- Returning any value other than nil will halt processing of the event + end + measure_success(1); +end, 99); + +for event_name, method in pairs { + -- Normal room interactions + ["iq-set/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; + -- Host room + ["iq-set/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; +} do + module:hook(event_name, function (event) + local session, stanza = event.origin, event.stanza; + + -- if we do not require token we pass it through(default behaviour) + -- or the request is coming from admin (focus) + if not require_token_for_moderation or is_admin(stanza.attr.from) then + return; + end + + -- jitsi_meet_room is set after the token had been verified + if not session.auth_token or not session.jitsi_meet_room then + session.send( + st.error_reply( + stanza, "cancel", "not-allowed", "Room modification disabled for guests")); + return true; + end + + end, -1); -- the default prosody hook is on -2 +end + +module:hook_global('config-reloaded', load_config); diff --git a/roles/jitsi/files/prosody/modules/mod_turncredentials.lua b/roles/jitsi/files/prosody/modules/mod_turncredentials.lua new file mode 100644 index 0000000..9648385 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_turncredentials.lua @@ -0,0 +1,80 @@ +-- XEP-0215 implementation for time-limited turn credentials +-- Copyright (C) 2012-2014 Philipp Hancke +-- This file is MIT/X11 licensed. + +--turncredentials_secret = "keepthissecret"; +--turncredentials = { +-- { type = "stun", host = "8.8.8.8" }, +-- { type = "turn", host = "8.8.8.8", port = "3478" }, +-- { type = "turn", host = "8.8.8.8", port = "80", transport = "tcp" } +--} +-- for stun servers, host is required, port defaults to 3478 +-- for turn servers, host is required, port defaults to tcp, +-- transport defaults to udp +-- hosts can be a list of server names / ips for random +-- choice loadbalancing + +local st = require "util.stanza"; +local hmac_sha1 = require "util.hashes".hmac_sha1; +local base64 = require "util.encodings".base64; +local os_time = os.time; +local secret = module:get_option_string("turncredentials_secret"); +local ttl = module:get_option_number("turncredentials_ttl", 86400); +local hosts = module:get_option("turncredentials") or {}; +if not (secret) then + module:log("error", "turncredentials not configured"); + return; +end + +module:add_feature("urn:xmpp:extdisco:1"); + +function random(arr) + local index = math.random(1, #arr); + return arr[index]; +end + + +module:hook_global("config-reloaded", function() + module:log("debug", "config-reloaded") + secret = module:get_option_string("turncredentials_secret"); + ttl = module:get_option_number("turncredentials_ttl", 86400); + hosts = module:get_option("turncredentials") or {}; +end); + +module:hook("iq-get/host/urn:xmpp:extdisco:1:services", function(event) + local origin, stanza = event.origin, event.stanza; + if origin.type ~= "c2s" then + return; + end + local now = os_time() + ttl; + local userpart = tostring(now); + local nonce = base64.encode(hmac_sha1(secret, tostring(userpart), false)); + local reply = st.reply(stanza):tag("services", {xmlns = "urn:xmpp:extdisco:1"}) + for idx, item in pairs(hosts) do + if item.type == "stun" or item.type == "stuns" then + -- stun items need host and port (defaults to 3478) + reply:tag("service", + { type = item.type, host = item.host, port = tostring(item.port) or "3478" } + ):up(); + elseif item.type == "turn" or item.type == "turns" then + local turn = {} + -- turn items need host, port (defaults to 3478), + -- transport (defaults to udp) + -- username, password, ttl + turn.type = item.type; + turn.port = tostring(item.port); + turn.transport = item.transport; + turn.username = userpart; + turn.password = nonce; + turn.ttl = tostring(ttl); + if item.hosts then + turn.host = random(item.hosts) + else + turn.host = item.host + end + reply:tag("service", turn):up(); + end + end + origin.send(reply); + return true; +end); diff --git a/roles/jitsi/files/prosody/modules/mod_turncredentials_http.lua b/roles/jitsi/files/prosody/modules/mod_turncredentials_http.lua new file mode 100644 index 0000000..1aaef2e --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_turncredentials_http.lua @@ -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 diff --git a/roles/jitsi/files/prosody/modules/mod_visitors.lua b/roles/jitsi/files/prosody/modules/mod_visitors.lua new file mode 100644 index 0000000..8070100 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_visitors.lua @@ -0,0 +1,355 @@ +--- 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 presence_check_status = util.presence_check_status; +local process_host_module = util.process_host_module; + +local um_is_admin = require 'core.usermanager'.is_admin; +local function is_admin(jid) + return um_is_admin(jid, module.host); +end + +local MUC_NS = 'http://jabber.org/protocol/muc'; + +-- required parameter for custom muc component prefix, defaults to 'conference' +local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference'); + +local main_muc_component_config = module:get_option_string('main_muc'); +if main_muc_component_config == nil then + module:log('error', 'visitors rooms not enabled missing main_muc config'); + return ; +end + +-- A list of domains which to be ignored for visitors. For occupants using those domain we do not propagate them +-- to visitor nodes and we do not update them with presence changes +local ignore_list = module:get_option_set('visitors_ignore_list', {}); + +-- Advertise the component for discovery via disco#items +module:add_identity('component', 'visitors', 'visitors.'..module.host); + +local sent_iq_cache = require 'util.cache'.new(200); + +-- visitors_nodes = { +-- roomjid1 = { +-- nodes = { +-- ['conference.visitors1.jid'] = 2, // number of main participants, on 0 we clean it +-- ['conference.visitors2.jid'] = 3 +-- } +-- }, +-- roomjid2 = {} +--} +local visitors_nodes = {}; + +-- sends connect or update iq +-- @parameter type - Type of iq to send 'connect' or 'update' +local function send_visitors_iq(conference_service, room, type) + -- send iq informing the vnode that the connect is done and it will allow visitors to join + local iq_id = new_id(); + sent_iq_cache:set(iq_id, socket.gettime()); + local connect_done = 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, + moderatorId = room._data.moderator_id, -- can be used from external modules to set single moderator for meetings + createdTimestamp = room.created_timestamp and tostring(room.created_timestamp) or nil + }):up(); + + module:send(connect_done); +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; + + for _, o in room:each_occupant() do + if not is_admin(o.bare_jid) then + local fmuc_pr = st.clone(o:get_presence()); + local user, _, res = jid.split(o.nick); + fmuc_pr.attr.to = jid.join(user, conference_service , res); + fmuc_pr.attr.from = o.jid; + -- add + fmuc_pr:tag('x', { xmlns = MUC_NS }); + + -- if there is a password on the main room let's add the password for the vnode join + -- as we will set the password to the vnode room and we will need it + local pass = room:get_password(); + if pass and pass ~= '' then + fmuc_pr:tag('password'):text(pass); + end + fmuc_pr:up(); + + module:send(fmuc_pr); + + sent_main_participants = sent_main_participants + 1; + end + end + visitors_nodes[room.jid].nodes[conference_service] = sent_main_participants; + + send_visitors_iq(conference_service, room, 'connect'); +end +module:hook('jitsi-connect-vnode', connect_vnode); + +-- listens for responses to the iq sent for connecting vnode +local function stanza_handler(event) + local origin, stanza = event.origin, event.stanza; + + if stanza.name ~= 'iq' then + return; + end + + -- we receive error from vnode for our disconnect message as the room was already destroyed (all visitors left) + if (stanza.attr.type == 'result' or stanza.attr.type == 'error') and sent_iq_cache:get(stanza.attr.id) then + sent_iq_cache:set(stanza.attr.id, nil); + return true; + end +end +module:hook('iq/host', stanza_handler, 10); + +-- an event received from visitors component, which receives iqs from jicofo +local function disconnect_vnode(event) + local room, vnode = event.room, event.vnode; + + if visitors_nodes[event.room.jid] == nil then + -- maybe the room was already destroyed and vnodes cleared + return; + end + + local conference_service = muc_domain_prefix..'.'..vnode..'.meet.jitsi'; + + visitors_nodes[room.jid].nodes[conference_service] = nil; + + send_visitors_iq(conference_service, room, 'disconnect'); +end +module:hook('jitsi-disconnect-vnode', disconnect_vnode); + +-- takes care when the visitor nodes destroys the room to count the leaving participants from there, and if its really destroyed +-- we clean up, so if we establish again the connection to the same visitor node to send the main participants +module:hook('presence/full', function(event) + local stanza = event.stanza; + local room_name, from_host = jid.split(stanza.attr.from); + if stanza.attr.type == 'unavailable' and from_host ~= main_muc_component_config then + local room_jid = jid.join(room_name, main_muc_component_config); -- converts from visitor to main room jid + + local x = stanza:get_child('x', 'http://jabber.org/protocol/muc#user'); + if not presence_check_status(x, '110') then + return; + end + + if visitors_nodes[room_jid] and visitors_nodes[room_jid].nodes + and visitors_nodes[room_jid].nodes[from_host] then + visitors_nodes[room_jid].nodes[from_host] = visitors_nodes[room_jid].nodes[from_host] - 1; + -- we clean only on disconnect coming from jicofo + end + end +end, 900); + +process_host_module(main_muc_component_config, function(host_module, host) + -- detects presence change in a main participant and propagate it to the used visitor nodes + host_module:hook('muc-occupant-pre-change', function (event) + local room, stanza, occupant = event.room, event.stanza, event.dest_occupant; + + -- filter focus and configured domains (used for jibri and transcribers) + if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil + or ignore_list:contains(jid.host(occupant.bare_jid)) 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, stanza, occupant = event.room, event.stanza, event.occupant; + + -- ignore configured domains (jibri and transcribers) + if is_admin(occupant.bare_jid) or visitors_nodes[room.jid] == nil or visitors_nodes[room.jid].nodes == nil + or ignore_list:contains(jid.host(occupant.bare_jid)) then + return; + end + + --this is probably participant kick scenario, create an unavailable presence and send to vnodes. + if not stanza then + stanza = st.presence {from = occupant.nick; type = "unavailable";}; + end + + -- we want to update visitor node that a main participant left or kicked. + if stanza then + local vnodes = visitors_nodes[room.jid].nodes; + local user, _, res = jid.split(occupant.nick); + for k in pairs(vnodes) do + local fmuc_pr = st.clone(stanza); + fmuc_pr.attr.to = jid.join(user, k, res); + fmuc_pr.attr.from = occupant.jid; + module:send(fmuc_pr); + end + end + end); + + -- cleanup cache + host_module:hook('muc-room-destroyed',function(event) + local room = event.room; + + -- room is destroyed let's disconnect all vnodes + if visitors_nodes[room.jid] then + local vnodes = visitors_nodes[room.jid].nodes; + for conference_service in pairs(vnodes) do + send_visitors_iq(conference_service, room, 'disconnect'); + end + + visitors_nodes[room.jid] = nil; + end + end); + + -- detects new participants joining main room and sending them to the visitor nodes + host_module:hook('muc-occupant-joined', function (event) + local room, stanza, occupant = event.room, event.stanza, event.occupant; + + -- filter focus, ignore configured domains (jibri and transcribers) + if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil + or ignore_list:contains(jid.host(occupant.bare_jid)) 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 + 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, stanza, occupant = event.room, event.stanza, event.occupant; + + -- filter sending messages from transcribers/jibris to visitors + if not visitors_nodes[room.jid] or ignore_list:contains(jid.host(occupant.bare_jid)) then + return; + end + + local vnodes = visitors_nodes[room.jid].nodes; + local user = jid.node(occupant.nick); + -- a main participant we need to update all active visitor nodes + for k in pairs(vnodes) do + local fmuc_msg = st.clone(stanza); + fmuc_msg.attr.to = jid.join(user, k); + fmuc_msg.attr.from = occupant.jid; + module:send(fmuc_msg); + end + end); + -- receiving messages from visitor nodes and forward them to local main participants + -- and forward them to the rest of visitor nodes + host_module:hook('muc-occupant-groupchat', function(event) + local occupant, room, stanza = event.occupant, event.room, event.stanza; + 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 + + -- 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 + + 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 +end); + +module:hook('jitsi-lobby-enabled', function(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-disabled', function(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); diff --git a/roles/jitsi/files/prosody/modules/mod_visitors_component.lua b/roles/jitsi/files/prosody/modules/mod_visitors_component.lua new file mode 100644 index 0000000..0b33644 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_visitors_component.lua @@ -0,0 +1,573 @@ +module:log('info', 'Starting visitors_component at %s', module.host); + +local http = require 'net.http'; +local jid = require 'util.jid'; +local st = require 'util.stanza'; +local util = module:require 'util'; +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 is_vpaas = util.is_vpaas; +local is_sip_jibri_join = util.is_sip_jibri_join; +local process_host_module = util.process_host_module; +local new_id = require 'util.id'.medium; +local um_is_admin = require 'core.usermanager'.is_admin; +local json = require 'cjson.safe'; +local inspect = require 'inspect'; + +-- 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" +}; + +local function is_admin(jid) + return um_is_admin(jid, module.host); +end + +-- 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); + +-- send iq result that the iq was received and will be processed +local function respond_iq_result(origin, stanza) + -- respond with successful receiving the iq + origin.send(st.iq({ + type = 'result'; + from = stanza.attr.to; + to = stanza.attr.from; + id = stanza.attr.id + })); +end + +-- 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, force_promote) + -- if visitors is enabled for the room + if visitors_promotion_map[room.jid] then + -- only for raise hand, ignore lowering the hand + if time and time > 0 and ( + auto_allow_promotion + or force_promote == 'true') then + -- we are in auto-allow mode, let's reply with accept + -- we store where the request is coming from so we can send back the response + local username = new_id():lower(); + visitors_promotion_map[room.jid][username] = { + from = from_vnode; + jid = from_jid; + }; + + local req_from = visitors_promotion_map[room.jid][username].from; + local req_jid = visitors_promotion_map[room.jid][username].jid; + local focus_occupant = get_focus_occupant(room); + local focus_jid = focus_occupant and focus_occupant.bare_jid or nil; + + local iq_id = new_id(); + sent_iq_cache:set(iq_id, socket.gettime()); + + local node = jid.node(room.jid); + + module:send(st.iq({ + type='set', to = req_from, from = module.host, id = iq_id }) + :tag('visitors', { + xmlns='jitsi:visitors', + room = jid.join(node, muc_domain_prefix..'.'..req_from), + focusjid = focus_jid }) + :tag('promotion-response', { + xmlns='jitsi:visitors', + jid = req_jid, + username = username , + allow = 'true' }):up()); + return true; + else + -- send promotion request to all moderators + local body_json = {}; + body_json.type = 'visitors'; + body_json.room = internal_room_jid_match_rewrite(room.jid); + body_json.action = 'promotion-request'; + body_json.nick = nick; + body_json.from = from_jid; + + if time and time > 0 then + -- raise hand + body_json.on = true; + else + -- lower hand, we want to inform interested parties that + -- the visitor is no longer interested in joining the main call + body_json.on = false; + end + + local msg_to_send, error = json.encode(body_json); + + if not msg_to_send then + module:log('error', 'Error encoding msg room:%s error:%s', room.jid, error) + return true; + end + + if visitors_promotion_requests[room.jid] then + visitors_promotion_requests[room.jid][from_jid] = { + msg = msg_to_send; + from = from_vnode; + }; + else + module:log('warn', 'Received promotion request for room %s with visitors not enabled. %s', + room.jid, msg_to_send); + end + + -- let's send a notification to every moderator + for _, occupant in room:each_occupant() do + if occupant.role == 'moderator' and not is_admin(occupant.bare_jid) then + send_json_message(occupant.jid, msg_to_send); + end + end + + return true; + end + end + + module:log('warn', 'Received promotion request from %s for room %s without active visitors', from, room.jid); +end + +local function connect_vnode_received(room, vnode) + module:context(muc_domain_base):fire_event('jitsi-connect-vnode', { room = room; vnode = vnode; }); + + if not visitors_promotion_map[room.jid] then + -- visitors is enabled + visitors_promotion_map[room.jid] = {}; + visitors_promotion_requests[room.jid] = {}; + room._connected_vnodes = cache.new(16); -- we up to 16 vnodes for this prosody + end + + room._connected_vnodes:set(vnode..'.meet.jitsi', 'connected'); +end + +local function disconnect_vnode_received(room, vnode) + module:context(muc_domain_base):fire_event('jitsi-disconnect-vnode', { room = room; vnode = vnode; }); + + room._connected_vnodes:set(vnode..'.meet.jitsi', nil); + + if room._connected_vnodes:count() == 0 then + visitors_promotion_map[room.jid] = nil; + visitors_promotion_requests[room.jid] = nil; + room._connected_vnodes = nil; + end +end + +-- 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 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; + 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 (room._connected_vnodes and room._connected_vnodes:get(stanza.attr.from)) then + module:log('warn', 'Received forged promotion-request: %s %s %s', stanza, inspect(room._connected_vnodes), room._connected_vnodes:get(stanza.attr.from)); + 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.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 + + 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) + -- lets reply to participant that requested promotion + local username = new_id():lower(); + visitors_promotion_map[room.jid][username] = { + from = visitors_promotion_requests[room.jid][id].from; + jid = id; + }; + + local req_from = visitors_promotion_map[room.jid][username].from; + local req_jid = visitors_promotion_map[room.jid][username].jid; + local focus_occupant = get_focus_occupant(room); + local focus_jid = focus_occupant and focus_occupant.bare_jid or nil; + + local iq_id = new_id(); + sent_iq_cache:set(iq_id, socket.gettime()); + + local node = jid.node(room.jid); + + module:send(st.iq({ + type='set', to = req_from, from = module.host, id = iq_id }) + :tag('visitors', { + xmlns='jitsi:visitors', + room = jid.join(node, muc_domain_prefix..'.'..req_from), + focusjid = focus_jid }) + :tag('promotion-response', { + xmlns='jitsi:visitors', + jid = req_jid, + username = username, + allow = approved }):up()); +end + +-- if room metadata does not have visitors.live set to `true` and there are no occupants in the meeting +-- it will skip calling goLive endpoint +local function go_live(room) + if room._jitsi_go_live_sent then + return; + end + + if not (room.jitsiMetadata and room.jitsiMetadata.visitors and room.jitsiMetadata.visitors.live) then + 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 + 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 + 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 + + -- we skip any checks when auto-allow is enabled + if auto_allow_promotion + or ignore_list:contains(jid.host(stanza.attr.from)) -- jibri or other domains to ignore + or is_sip_jigasi(stanza) + or is_sip_jibri_join(stanza) then + return; + end + + if visitors_promotion_map[room.jid] then + -- now let's check for jid + if visitors_promotion_map[room.jid][jid.node(stanza.attr.from)] -- promotion was approved + or ignore_list:contains(jid.host(stanza.attr.from)) then -- jibri or other domains to ignore + -- allow join + 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 + end + + end, 7); -- after muc_meeting_id, the logic for not joining before jicofo + host_module:hook('muc-room-destroyed', function (event) + visitors_promotion_map[event.room.jid] = nil; + visitors_promotion_requests[event.room.jid] = nil; + end); + + host_module:hook('muc-occupant-joined', function (event) + local room, occupant = event.room, event.occupant; + + if 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 + 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("message/bare", function(event) + local stanza = event.stanza; + + if stanza.attr.type ~= "groupchat" then + return; + end + local json_data = stanza:get_child_text("json-message", "http://jitsi.org/jitmeet"); + if json_data == nil then + return; + end + local data, error = json.decode(json_data); + 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 + + local room = get_room_from_jid(event.stanza.attr.to); + + local occupant_jid = event.stanza.attr.from; + local occupant = room:get_occupant_by_real_jid(occupant_jid); + if not occupant then + module:log("error", "Occupant %s was not found in room %s", occupant_jid, room.jid) + 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) + go_live(event.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); diff --git a/roles/jitsi/files/prosody/modules/mod_websocket_session_event.patch b/roles/jitsi/files/prosody/modules/mod_websocket_session_event.patch new file mode 100644 index 0000000..2237d8b --- /dev/null +++ b/roles/jitsi/files/prosody/modules/mod_websocket_session_event.patch @@ -0,0 +1,19 @@ +# HG changeset patch +# User Matthew Wild +# Date 1579882890 0 +# Node ID 37936c72846d77bb4b23c4987ccc9dc8805fe67c +# Parent b9a054ad38e72c0480534c06a7b4397c048d122a +mod_websocket: Fire event on session creation (thanks Aaron van Meerten) + +diff -r b9a054ad38e7 -r 37936c72846d plugins/mod_websocket.lua +--- a/plugins/mod_websocket.lua Thu Jan 23 21:59:13 2020 +0000 ++++ b/plugins/mod_websocket.lua Fri Jan 24 16:21:30 2020 +0000 +@@ -305,6 +305,8 @@ + response.headers.sec_webSocket_accept = base64(sha1(request.headers.sec_websocket_key .. "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")); + response.headers.sec_webSocket_protocol = "xmpp"; + ++ module:fire_event("websocket-session", { session = session, request = request }); ++ + session.log("debug", "Sending WebSocket handshake"); + + return ""; diff --git a/roles/jitsi/files/prosody/modules/muc_owner_allow_kick-0.12.patch b/roles/jitsi/files/prosody/modules/muc_owner_allow_kick-0.12.patch new file mode 100644 index 0000000..de08935 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/muc_owner_allow_kick-0.12.patch @@ -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 diff --git a/roles/jitsi/files/prosody/modules/muc_owner_allow_kick.patch b/roles/jitsi/files/prosody/modules/muc_owner_allow_kick.patch new file mode 100644 index 0000000..5e5c245 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/muc_owner_allow_kick.patch @@ -0,0 +1,21 @@ +--- muc.lib.lua 2016-10-26 18:26:53.432377291 +0000 ++++ muc.lib.lua 2016-10-26 18:41:40.754426072 +0000 +@@ -1256,15 +1256,16 @@ + if actor == true then + actor = nil -- So we can pass it safely to 'publicise_occupant_status' below + else ++ local actor_affiliation = self:get_affiliation(actor); ++ + -- 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 ++ 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 diff --git a/roles/jitsi/files/prosody/modules/poltergeist.lib.lua b/roles/jitsi/files/prosody/modules/poltergeist.lib.lua new file mode 100644 index 0000000..e44c785 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/poltergeist.lib.lua @@ -0,0 +1,397 @@ +local inspect = require("inspect") +local jid = require("util.jid") +local stanza = require("util.stanza") +local timer = require("util.timer") +local update_presence_identity = module:require("util").update_presence_identity +local uuid = require("util.uuid") + +local component = module:get_option_string( + "poltergeist_component", + module.host +) + +local expiration_timeout = module:get_option_string( + "poltergeist_leave_timeout", + 30 -- defaults to 30 seconds +) + +local MUC_NS = "http://jabber.org/protocol/muc" + +-------------------------------------------------------------------------------- +-- Utility functions for commonly used poltergeist codes. +-------------------------------------------------------------------------------- + +-- Creates a nick for a poltergeist. +-- @param username is the unique username of the poltergeist +-- @return a nick to use for xmpp +local function create_nick(username) + return string.sub(username, 0,8) +end + +-- Returns the last presence of the occupant. +-- @param room the room instance where to check for occupant +-- @param nick the nick of the occupant +-- @return presence stanza of the occupant +function get_presence(room, nick) + local occupant_jid = room:get_occupant_jid(component.."/"..nick) + if occupant_jid then + return room:get_occupant_by_nick(occupant_jid):get_presence(); + end + return nil; +end + +-- Checks for existence of a poltergeist occupant in a room. +-- @param room the room instance where to check for the occupant +-- @param nick the nick of the occupant +-- @return true if occupant is found, false otherwise +function occupies(room, nick) + -- Find out if we have a poltergeist occupant in the room for this JID + return not not room:get_occupant_jid(component.."/"..nick); +end + +-------------------------------------------------------------------------------- +-- Username storage for poltergeist. +-- +-- Every poltergeist will have a username stored in a table underneath +-- the room name that they are currently active in. The username can +-- be retrieved given a room and a user_id. The username is removed from +-- a room by providing the room and the nick. +-- +-- A table with a single entry looks like: +-- { +-- ["[hug]hostilewerewolvesthinkslightly"] = { +-- ["655363:52148a3e-b5fb-4cfc-8fbd-f55e793cf657"] = "ed7757d6-d88d-4e6a-8e24-aca2adc31348", +-- ed7757d6 = "655363:52148a3e-b5fb-4cfc-8fbd-f55e793cf657" +-- } +-- } +-------------------------------------------------------------------------------- +-- state is the table where poltergeist usernames and call resources are stored +-- for a given xmpp muc. +local state = module:shared("state") + +-- Adds a poltergeist to the store. +-- @param room is the room the poltergeist is being added to +-- @param user_id is the user_id of the user the poltergeist represents +-- @param username is the unique id of the poltergeist itself +local function store_username(room, user_id, username) + local room_name = jid.node(room.jid) + + if not state[room_name] then + state[room_name] = {} + end + + state[room_name][user_id] = username + state[room_name][create_nick(username)] = user_id +end + +-- Retrieves a poltergeist username from the store if one exists. +-- @param room is the room to check for the poltergeist in the store +-- @param user_id is the user id of the user the poltergeist represents +local function get_username(room, user_id) + local room_name = jid.node(room.jid) + + if not state[room_name] then + return nil + end + + return state[room_name][user_id] +end + +local function get_username_from_nick(room_name, nick) + if not state[room_name] then + return nil + end + + local user_id = state[room_name][nick] + return state[room_name][user_id] +end + +-- Removes the username from the store. +-- @param room is the room the poltergeist is being removed from +-- @param nick is the nick of the muc occupant +local function remove_username(room, nick) + local room_name = jid.node(room.jid) + if not state[room_name] then + return + end + + local user_id = state[room_name][nick] + state[room_name][user_id] = nil + state[room_name][nick] = nil +end + +-- Removes all poltergeists in the store for the provided room. +-- @param room is the room all poltergiest will be removed from +local function remove_room(room) + local room_name = jid.node(room.jid) + if state[room_name] then + state[room_name] = nil + end +end + +-- Adds a resource that is associated with a a call in a room. There +-- is only one resource for each type. +-- @param room is the room the call and poltergeist is in. +-- @param call_id is the unique id for the call. +-- @param resource_type is type of resource being added. +-- @param resource_id is the id of the resource being added. +local function add_call_resource(room, call_id, resource_type, resource_id) + local room_name = jid.node(room.jid) + if not state[room_name] then + state[room_name] = {} + end + + if not state[room_name][call_id] then + state[room_name][call_id] = {} + end + + state[room_name][call_id][resource_type] = resource_id +end + +-------------------------------------------------------------------------------- +-- State for toggling the tagging of presence stanzas with ignored tag. +-- +-- A poltergeist with it's full room/nick set to ignore will have a jitsi ignore +-- tag applied to all presence stanza's broadcasted. The following functions +-- assist in managing this state. +-------------------------------------------------------------------------------- +local presence_ignored = {} + +-- Sets the nick to ignored state. +-- @param room_nick full room/nick jid +local function set_ignored(room_nick) + presence_ignored[room_nick] = true +end + +-- Resets the nick out of ignored state. +-- @param room_nick full room/nick jid +local function reset_ignored(room_nick) + presence_ignored[room_nick] = nil +end + +-- Determines whether or not the leave presence should be tagged with ignored. +-- @param room_nick full room/nick jid +local function should_ignore(room_nick) + if presence_ignored[room_nick] == nil then + return false + end + return presence_ignored[room_nick] +end + +-------------------------------------------------------------------------------- +-- Poltergeist control functions for adding, updating and removing poltergeist. +-------------------------------------------------------------------------------- + +-- Updates the status tags and call flow tags of an existing poltergeist +-- presence. +-- @param presence_stanza is the actual presence stanza for a poltergeist. +-- @param status is the new status to be updated in the stanza. +-- @param call_details is a table of call flow signal information. +function update_presence_tags(presence_stanza, status, call_details) + local call_cancel = false + local call_id = nil + + -- Extract optional call flow signal information. + if call_details then + call_id = call_details["id"] + + if call_details["cancel"] then + call_cancel = call_details["cancel"] + end + end + + presence_stanza:maptags(function (tag) + if tag.name == "status" then + if call_cancel then + -- If call cancel is set then the status should not be changed. + return tag + end + return stanza.stanza("status"):text(status) + elseif tag.name == "call_id" then + if call_id then + return stanza.stanza("call_id"):text(call_id) + else + -- If no call id is provided the re-use the existing id. + return tag + end + elseif tag.name == "call_cancel" then + if call_cancel then + return stanza.stanza("call_cancel"):text("true") + else + return stanza.stanza("call_cancel"):text("false") + end + end + return tag + end) + + return presence_stanza +end + +-- Updates the presence status of a poltergeist. +-- @param room is the room the poltergeist has occupied +-- @param nick is the xmpp nick of the poltergeist occupant +-- @param status is the status string to set in the presence +-- @param call_details is a table of call flow control details +local function update(room, nick, status, call_details) + local original_presence = get_presence(room, nick) + + if not original_presence then + module:log("info", "update issued for a non-existing poltergeist") + return + end + + -- update occupant presence with appropriate to and from + -- so we can send it again + update_presence = stanza.clone(original_presence) + update_presence.attr.to = room.jid.."/"..nick + update_presence.attr.from = component.."/"..nick + + update_presence = update_presence_tags(update_presence, status, call_details) + + module:log("info", "updating poltergeist: %s/%s - %s", room, nick, status) + room:handle_normal_presence( + prosody.hosts[component], + update_presence + ) +end + +-- Removes the poltergeist from the room. +-- @param room is the room the poltergeist has occupied +-- @param nick is the xmpp nick of the poltergeist occupant +-- @param ignore toggles if the leave subsequent leave presence should be tagged +local function remove(room, nick, ignore) + local original_presence = get_presence(room, nick); + if not original_presence then + module:log("info", "attempted to remove a poltergeist with no presence") + return + end + + local leave_presence = stanza.clone(original_presence) + leave_presence.attr.to = room.jid.."/"..nick + leave_presence.attr.from = component.."/"..nick + leave_presence.attr.type = "unavailable" + + if (ignore) then + set_ignored(room.jid.."/"..nick) + end + + remove_username(room, nick) + module:log("info", "removing poltergeist: %s/%s", room, nick) + room:handle_normal_presence( + prosody.hosts[component], + leave_presence + ) +end + +-- Adds a poltergeist to a muc/room. +-- @param room is the room the poltergeist will occupy +-- @param is the id of the user the poltergeist represents +-- @param display_name is the display name to use for the poltergeist +-- @param avatar is the avatar link used for the poltergeist display +-- @param context is the session context of the user making the request +-- @param status is the presence status string to use +-- @param resources is a table of resource types and resource ids to correlate. +local function add_to_muc(room, user_id, display_name, avatar, context, status, resources) + local username = uuid.generate() + local presence_stanza = original_presence( + room, + username, + display_name, + avatar, + context, + status + ) + + module:log("info", "adding poltergeist: %s/%s", room, create_nick(username)) + store_username(room, user_id, username) + for k, v in pairs(resources) do + add_call_resource(room, username, k, v) + end + room:handle_first_presence( + prosody.hosts[component], + presence_stanza + ) + + local remove_delay = 5 + local expiration = expiration_timeout - remove_delay; + local nick = create_nick(username) + timer.add_task( + expiration, + function () + update(room, nick, "expired") + timer.add_task( + remove_delay, + function () + if occupies(room, nick) then + remove(room, nick, false) + end + end + ) + end + ) +end + +-- Generates an original presence for a new poltergeist +-- @param room is the room the poltergeist will occupy +-- @param username is the unique name for the poltergeist +-- @param display_name is the display name to use for the poltergeist +-- @param avatar is the avatar link used for the poltergeist display +-- @param context is the session context of the user making the request +-- @param status is the presence status string to use +-- @return a presence stanza that can be used to add the poltergeist to the muc +function original_presence(room, username, display_name, avatar, context, status) + local nick = create_nick(username) + local p = stanza.presence({ + to = room.jid.."/"..nick, + from = component.."/"..nick, + }):tag("x", { xmlns = MUC_NS }):up(); + + p:tag("bot", { type = "poltergeist" }):up(); + p:tag("call_cancel"):text(nil):up(); + p:tag("call_id"):text(username):up(); + + if status then + p:tag("status"):text(status):up(); + else + p:tag("status"):text(nil):up(); + end + + if display_name then + p:tag( + "nick", + { xmlns = "http://jabber.org/protocol/nick" }):text(display_name):up(); + end + + if avatar then + p:tag("avatar-url"):text(avatar):up(); + end + + -- If the room has a password set, let the poltergeist enter using it + local room_password = room:get_password(); + if room_password then + local join = p:get_child("x", MUC_NS); + join:tag("password", { xmlns = MUC_NS }):text(room_password); + end + + update_presence_identity( + p, + context.user, + context.group, + context.creator_user, + context.creator_group + ) + return p +end + +return { + get_username = get_username, + get_username_from_nick = get_username_from_nick, + occupies = occupies, + remove_room = remove_room, + reset_ignored = reset_ignored, + should_ignore = should_ignore, + create_nick = create_nick, + add_to_muc = add_to_muc, + update = update, + remove = remove +} diff --git a/roles/jitsi/files/prosody/modules/s2sout_override1.patch b/roles/jitsi/files/prosody/modules/s2sout_override1.patch new file mode 100644 index 0000000..bd361c6 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/s2sout_override1.patch @@ -0,0 +1,11 @@ +diff -r 214a679823e8 core/features.lua +--- a/core/features.lua Mon May 01 15:10:32 2023 +0200 ++++ b/core/features.lua Wed May 24 11:53:34 2023 -0500 +@@ -4,5 +4,7 @@ + available = set.new{ + -- mod_bookmarks bundled + "mod_bookmarks"; ++ ++ "s2sout-pre-connect-event"; + }; + }; diff --git a/roles/jitsi/files/prosody/modules/s2sout_override2.patch b/roles/jitsi/files/prosody/modules/s2sout_override2.patch new file mode 100644 index 0000000..60fd909 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/s2sout_override2.patch @@ -0,0 +1,14 @@ +diff -r 214a679823e8 plugins/mod_s2s.lua +--- a/mod_s2s.lua Mon May 01 15:10:32 2023 +0200 ++++ b/mod_s2s.lua Wed May 24 11:53:34 2023 -0500 +@@ -230,6 +230,10 @@ + resolver; + }); + end ++ ++ local pre_event = { session = host_session; resolver = resolver }; ++ module:context(from_host):fire_event("s2sout-pre-connect", pre_event); ++ resolver = pre_event.resolver; + connect(resolver, listener, nil, { session = host_session }); + m_initiated_connections:with_labels(from_host):add(1) + return true; diff --git a/roles/jitsi/files/prosody/modules/stanza_router_no-log.patch b/roles/jitsi/files/prosody/modules/stanza_router_no-log.patch new file mode 100644 index 0000000..8220628 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/stanza_router_no-log.patch @@ -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; diff --git a/roles/jitsi/files/prosody/modules/token/util.lib.lua b/roles/jitsi/files/prosody/modules/token/util.lib.lua new file mode 100644 index 0000000..2668815 --- /dev/null +++ b/roles/jitsi/files/prosody/modules/token/util.lib.lua @@ -0,0 +1,539 @@ +-- Token authentication +-- Copyright (C) 2021-present 8x8, Inc. + +local basexx = require "basexx"; +local have_async, async = pcall(require, "util.async"); +local hex = require "util.hex"; +local jwt = module:require "luajwtjitsi"; +local jid = require "util.jid"; +local json_safe = require "cjson.safe"; +local path = require "util.paths"; +local sha256 = require "util.hashes".sha256; +local main_util = module:require "util"; +local ends_with = main_util.ends_with; +local http_get_with_retry = main_util.http_get_with_retry; +local extract_subdomain = main_util.extract_subdomain; +local starts_with = main_util.starts_with; +local table_shallow_copy = main_util.table_shallow_copy; +local cjson_safe = require 'cjson.safe' +local timer = require "util.timer"; +local async = require "util.async"; +local inspect = require 'inspect'; + +local nr_retries = 3; +local ssl = require "ssl"; + +-- TODO: Figure out a less arbitrary default cache size. +local cacheSize = module:get_option_number("jwt_pubkey_cache_size", 128); + +-- the cache for generated asap jwt tokens +local jwtKeyCache = require 'util.cache'.new(cacheSize); + +local ASAPTTL_THRESHOLD = module:get_option_number('asap_ttl_threshold', 600); +local ASAPTTL = module:get_option_number('asap_ttl', 3600); +local ASAPIssuer = module:get_option_string('asap_issuer', 'jitsi'); +local ASAPAudience = module:get_option_string('asap_audience', 'jitsi'); +local ASAPKeyId = module:get_option_string('asap_key_id', 'jitsi'); +local ASAPKeyPath = module:get_option_string('asap_key_path', '/etc/prosody/certs/asap.key'); + +local ASAPKey; +local f = io.open(ASAPKeyPath, 'r'); + +if f then + ASAPKey = f:read('*all'); + f:close(); +end + +local Util = {} +Util.__index = Util + +--- Constructs util class for token verifications. +-- Constructor that uses the passed module to extract all the +-- needed configurations. +-- If configuration is missing returns nil +-- @param module the module in which options to check for configs. +-- @return the new instance or nil +function Util.new(module) + local self = setmetatable({}, Util) + + self.appId = module:get_option_string("app_id"); + self.appSecret = module:get_option_string("app_secret"); + self.asapKeyServer = module:get_option_string("asap_key_server"); + -- A URL that will return json file with a mapping between kids and public keys + -- If the response Cache-Control header we will respect it and refresh it + self.cacheKeysUrl = module:get_option_string("cache_keys_url"); + self.signatureAlgorithm = module:get_option_string("signature_algorithm"); + self.allowEmptyToken = module:get_option_boolean("allow_empty_token"); + + self.cache = require"util.cache".new(cacheSize); + + --[[ + Multidomain can be supported in some deployments. In these deployments + there is a virtual conference muc, which address contains the subdomain + to use. Those deployments are accessible + by URL https://domain/subdomain. + Then the address of the room will be: + roomName@conference.subdomain.domain. This is like a virtual address + where there is only one muc configured by default with address: + conference.domain and the actual presentation of the room in that muc + component is [subdomain]roomName@conference.domain. + These setups relay on configuration 'muc_domain_base' which holds + the main domain and we use it to subtract subdomains from the + virtual addresses. + The following confgurations are for multidomain setups and domain name + verification: + --]] + + -- optional parameter for custom muc component prefix, + -- defaults to "conference" + self.muc_domain_prefix = module:get_option_string( + "muc_mapper_domain_prefix", "conference"); + -- domain base, which is the main domain used in the deployment, + -- the main VirtualHost for the deployment + self.muc_domain_base = module:get_option_string("muc_mapper_domain_base"); + -- The "real" MUC domain that we are proxying to + if self.muc_domain_base then + self.muc_domain = module:get_option_string( + "muc_mapper_domain", + self.muc_domain_prefix.."."..self.muc_domain_base); + end + -- whether domain name verification is enabled, by default it is enabled + -- when disabled checking domain name and tenant if available will be skipped, we will check only room name. + self.enableDomainVerification = module:get_option_boolean('enable_domain_verification', true); + + if self.allowEmptyToken == true then + module:log("warn", "WARNING - empty tokens allowed"); + end + + if self.appId == nil then + module:log("error", "'app_id' must not be empty"); + return nil; + end + + if self.appSecret == nil and self.asapKeyServer == nil then + module:log("error", "'app_secret' or 'asap_key_server' must be specified"); + return nil; + end + + -- Set defaults for signature algorithm + if self.signatureAlgorithm == nil then + if self.asapKeyServer ~= nil then + self.signatureAlgorithm = "RS256" + elseif self.appSecret ~= nil then + self.signatureAlgorithm = "HS256" + end + end + + --array of accepted issuers: by default only includes our appId + self.acceptedIssuers = module:get_option_array('asap_accepted_issuers',{self.appId}) + + --array of accepted audiences: by default only includes our appId + self.acceptedAudiences = module:get_option_array('asap_accepted_audiences',{'*'}) + + self.requireRoomClaim = module:get_option_boolean('asap_require_room_claim', true); + + if self.asapKeyServer and not have_async then + module:log("error", "requires a version of Prosody with util.async"); + return nil; + end + + if self.cacheKeysUrl then + self.cachedKeys = {}; + local update_keys_cache; + update_keys_cache = async.runner(function (name) + local content, code, cache_for; + content, code, cache_for = http_get_with_retry(self.cacheKeysUrl, nr_retries); + if content ~= nil then + local keys_to_delete = table_shallow_copy(self.cachedKeys); + -- Let's convert any certificate to public key + for k, v in pairs(cjson_safe.decode(content)) do + if starts_with(v, '-----BEGIN CERTIFICATE-----') then + self.cachedKeys[k] = ssl.loadcertificate(v):pubkey(); + -- do not clean this key if it already exists + keys_to_delete[k] = nil; + end + end + -- let's schedule the clean in an hour and a half, current tokens will be valid for an hour + timer.add_task(90*60, function () + for k, _ in pairs(keys_to_delete) do + self.cachedKeys[k] = nil; + end + end); + + if cache_for then + cache_for = tonumber(cache_for); + -- let's schedule new update 60 seconds before the cache expiring + if cache_for > 60 then + cache_for = cache_for - 60; + end + timer.add_task(cache_for, function () + update_keys_cache:run("update_keys_cache"); + end); + else + -- no cache header let's consider updating in 6hours + timer.add_task(6*60*60, function () + update_keys_cache:run("update_keys_cache"); + end); + end + else + module:log('warn', 'Failed to retrieve cached public keys code:%s', code); + -- failed let's retry in 30 seconds + timer.add_task(30, function () + update_keys_cache:run("update_keys_cache"); + end); + end + end); + update_keys_cache:run("update_keys_cache"); + end + + return self +end + +function Util:set_asap_key_server(asapKeyServer) + self.asapKeyServer = asapKeyServer; +end + +function Util:set_asap_accepted_issuers(acceptedIssuers) + self.acceptedIssuers = acceptedIssuers; +end + +function Util:set_asap_accepted_audiences(acceptedAudiences) + self.acceptedAudiences = acceptedAudiences; +end + +function Util:set_asap_require_room_claim(checkRoom) + self.requireRoomClaim = checkRoom; +end + +function Util:clear_asap_cache() + self.cache = require"util.cache".new(cacheSize); +end + +--- Returns the public key by keyID +-- @param keyId the key ID to request +-- @return the public key (the content of requested resource) or nil +function Util:get_public_key(keyId) + local content = self.cache:get(keyId); + local code; + if content == nil then + -- If the key is not found in the cache. + -- module:log("debug", "Cache miss for key: %s", keyId); + local keyurl = path.join(self.asapKeyServer, hex.to(sha256(keyId))..'.pem'); + -- module:log("debug", "Fetching public key from: %s", keyurl); + content, code = http_get_with_retry(keyurl, nr_retries); + if content ~= nil then + self.cache:set(keyId, content); + else + if code == nil then + -- this is timout after nr_retries retries + module:log('warn', 'Timeout retrieving %s from %s', keyId, keyurl); + end + end + return content; + else + -- If the key is in the cache, use it. + -- module:log("debug", "Cache hit for key: %s", keyId); + return content; + end +end + +--- Verifies token and process needed values to be stored in the session. +-- Token is obtained from session.auth_token. +-- Stores in session the following values: +-- session.jitsi_meet_room - the room name value from the token +-- session.jitsi_meet_domain - the domain name value from the token +-- session.jitsi_meet_context_user - the user details from the token +-- session.jitsi_meet_context_room - the room details from the token +-- session.jitsi_meet_context_group - the group value from the token +-- session.jitsi_meet_context_features - the features value from the token +-- @param session the current session +-- @return false and error +function Util:process_and_verify_token(session) + if session.auth_token == nil then + if self.allowEmptyToken then + return true; + else + return false, "not-allowed", "token required"; + end + end + + local key; + if session.public_key then + -- We're using an public key stored in the session + -- module:log("debug","Public key was found on the session"); + key = session.public_key; + elseif self.asapKeyServer and session.auth_token ~= nil then + -- We're fetching an public key from an ASAP server + local dotFirst = session.auth_token:find("%."); + if not dotFirst then return false, "not-allowed", "Invalid token" end + local header, err = json_safe.decode(basexx.from_url64(session.auth_token:sub(1,dotFirst-1))); + if err then + return false, "not-allowed", "bad token format"; + end + local kid = header["kid"]; + if kid == nil then + return false, "not-allowed", "'kid' claim is missing"; + end + local alg = header["alg"]; + if alg == nil then + return false, "not-allowed", "'alg' claim is missing"; + end + if alg.sub(alg,1,2) ~= "RS" then + return false, "not-allowed", "'kid' claim only support with RS family"; + end + + if self.cachedKeys and self.cachedKeys[kid] then + key = self.cachedKeys[kid]; + else + key = self:get_public_key(kid); + end + + if key == nil then + return false, "not-allowed", "could not obtain public key"; + end + elseif self.appSecret ~= nil then + -- We're using a symmetric secret + key = self.appSecret + end + + if key == nil then + return false, "not-allowed", "signature verification key is missing"; + end + + -- now verify the whole token + local claims, msg = jwt.verify( + session.auth_token, + self.signatureAlgorithm, + key, + self.acceptedIssuers, + self.acceptedAudiences + ) + if claims ~= nil then + if self.requireRoomClaim then + local roomClaim = claims["room"]; + if roomClaim == nil then + return false, "'room' claim is missing"; + end + end + + -- Binds room name to the session which is later checked on MUC join + session.jitsi_meet_room = claims["room"]; + -- Binds domain name to the session + session.jitsi_meet_domain = claims["sub"]; + + -- Binds the user details to the session if available + if claims["context"] ~= nil then + session.jitsi_meet_str_tenant = claims["context"]["tenant"]; + + if claims["context"]["user"] ~= nil then + session.jitsi_meet_context_user = claims["context"]["user"]; + end + + if claims["context"]["group"] ~= nil then + -- Binds any group details to the session + session.jitsi_meet_context_group = claims["context"]["group"]; + end + + if claims["context"]["features"] ~= nil then + -- Binds any features details to the session + session.jitsi_meet_context_features = claims["context"]["features"]; + end + if claims["context"]["room"] ~= nil then + session.jitsi_meet_context_room = claims["context"]["room"] + end + elseif claims["user_id"] then + session.jitsi_meet_context_user = {}; + session.jitsi_meet_context_user.id = claims["user_id"]; + end + + -- fire event that token has been verified and pass the session and the decoded token + prosody.events.fire_event('jitsi-authentication-token-verified', { + session = session; + claims = claims; + }); + + if session.contextRequired and claims["context"] == nil then + return false, "not-allowed", 'jwt missing required context claim'; + end + + return true; + else + return false, "not-allowed", msg; + end +end + +--- Verifies room name and domain if necessary. +-- Checks configs and if necessary checks the room name extracted from +-- room_address against the one saved in the session when token was verified. +-- Also verifies domain name from token against the domain in the room_address, +-- if enableDomainVerification is enabled. +-- @param session the current session +-- @param room_address the whole room address as received +-- @return returns true in case room was verified or there is no need to verify +-- it and returns false in case verification was processed +-- and was not successful +function Util:verify_room(session, room_address) + if self.allowEmptyToken and session.auth_token == nil then + --module:log("debug", "Skipped room token verification - empty tokens are allowed"); + return true; + end + + -- extract room name using all chars, except the not allowed ones + local room,_,_ = jid.split(room_address); + if room == nil then + log("error", + "Unable to get name of the MUC room ? to: %s", room_address); + return true; + end + + local auth_room = session.jitsi_meet_room; + if auth_room then + if type(auth_room) == 'string' then + auth_room = string.lower(auth_room); + else + module:log('warn', 'session.jitsi_meet_room not string: %s', inspect(auth_room)); + end + end + if not self.enableDomainVerification then + -- if auth_room is missing, this means user is anonymous (no token for + -- its domain) we let it through, jicofo is verifying creation domain + if auth_room and (room ~= auth_room and not ends_with(room, ']'..auth_room)) and auth_room ~= '*' then + return false; + end + + return true; + end + + local room_address_to_verify = jid.bare(room_address); + local room_node = jid.node(room_address); + -- parses bare room address, for multidomain expected format is: + -- [subdomain]roomName@conference.domain + local target_subdomain, target_room = extract_subdomain(room_node); + + -- if we have '*' as room name in token, this means all rooms are allowed + -- so we will use the actual name of the room when constructing strings + -- to verify subdomains and domains to simplify checks + local room_to_check; + if auth_room == '*' then + -- authorized for accessing any room assign to room_to_check the actual + -- room name + if target_room ~= nil then + -- we are in multidomain mode and we were able to extract room name + room_to_check = target_room; + else + -- no target_room, room_address_to_verify does not contain subdomain + -- so we get just the node which is the room name + room_to_check = room_node; + end + else + -- no wildcard, so check room against authorized room from the token + if session.jitsi_meet_context_room and (session.jitsi_meet_context_room["regex"] == true or session.jitsi_meet_context_room["regex"] == "true") then + if target_room ~= nil then + -- room with subdomain + room_to_check = target_room:match(auth_room); + else + room_to_check = room_node:match(auth_room); + end + else + -- not a regex + room_to_check = auth_room; + end + -- module:log("debug", "room to check: %s", room_to_check) + if not room_to_check then + if not self.requireRoomClaim then + -- if we do not require to have the room claim, and it is missing + -- there is no point of continue and verifying the roomName and the tenant + return true; + end + + return false; + end + end + + if session.jitsi_meet_str_tenant + and string.lower(session.jitsi_meet_str_tenant) ~= session.jitsi_web_query_prefix then + module:log('warn', 'Tenant differs for user:%s group:%s url_tenant:%s token_tenant:%s', + session.jitsi_meet_context_user and session.jitsi_meet_context_user.id or '', + session.jitsi_meet_context_group, + session.jitsi_web_query_prefix, session.jitsi_meet_str_tenant); + session.jitsi_meet_tenant_mismatch = true; + end + + local auth_domain = string.lower(session.jitsi_meet_domain); + local subdomain_to_check; + if target_subdomain then + if auth_domain == '*' then + -- check for wildcard in JWT claim, allow access if found + subdomain_to_check = target_subdomain; + else + -- no wildcard in JWT claim, so check subdomain against sub in token + subdomain_to_check = auth_domain; + end + -- from this point we depend on muc_domain_base, + -- deny access if option is missing + if not self.muc_domain_base then + module:log("warn", "No 'muc_domain_base' option set, denying access!"); + return false; + end + + return room_address_to_verify == jid.join( + "["..subdomain_to_check.."]"..room_to_check, self.muc_domain); + else + if auth_domain == '*' then + -- check for wildcard in JWT claim, allow access if found + subdomain_to_check = self.muc_domain; + else + -- no wildcard in JWT claim, so check subdomain against sub in token + subdomain_to_check = self.muc_domain_prefix.."."..auth_domain; + end + -- we do not have a domain part (multidomain is not enabled) + -- verify with info from the token + return room_address_to_verify == jid.join(room_to_check, subdomain_to_check); + end +end + +function Util:generateAsapToken(audience) + if not ASAPKey then + module:log('warn', 'No ASAP Key read, asap key generation is disabled'); + return '' + end + + audience = audience or ASAPAudience + local t = os.time() + local err + local exp_key = 'asap_exp.'..audience + local token_key = 'asap_token.'..audience + local exp = jwtKeyCache:get(exp_key) + local token = jwtKeyCache:get(token_key) + + --if we find a token and it isn't too far from expiry, then use it + if token ~= nil and exp ~= nil then + exp = tonumber(exp) + if (exp - t) > ASAPTTL_THRESHOLD then + return token + end + end + + --expiry is the current time plus TTL + exp = t + ASAPTTL + local payload = { + iss = ASAPIssuer, + aud = audience, + nbf = t, + exp = exp, + } + + -- encode + local alg = 'RS256' + token, err = jwt.encode(payload, ASAPKey, alg, { kid = ASAPKeyId }) + if not err then + token = 'Bearer '..token + jwtKeyCache:set(exp_key, exp) + jwtKeyCache:set(token_key, token) + return token + else + return '' + end +end + +return Util; diff --git a/roles/jitsi/files/prosody/modules/util.lib.lua b/roles/jitsi/files/prosody/modules/util.lib.lua new file mode 100644 index 0000000..d51f5ae --- /dev/null +++ b/roles/jitsi/files/prosody/modules/util.lib.lua @@ -0,0 +1,564 @@ +local jid = require "util.jid"; +local timer = require "util.timer"; +local http = require "net.http"; +local cache = require "util.cache"; + +local http_timeout = 30; +local have_async, async = pcall(require, "util.async"); +local http_headers = { + ["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")" +}; + +local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference"); + +-- defaults to module.host, the module that uses the utility +local muc_domain_base = module:get_option_string("muc_mapper_domain_base", module.host); + +-- The "real" MUC domain that we are proxying to +local muc_domain = module:get_option_string("muc_mapper_domain", muc_domain_prefix.."."..muc_domain_base); + +local escaped_muc_domain_base = muc_domain_base:gsub("%p", "%%%1"); +local escaped_muc_domain_prefix = muc_domain_prefix:gsub("%p", "%%%1"); +-- The pattern used to extract the target subdomain +-- (e.g. extract 'foo' from 'conference.foo.example.com') +local target_subdomain_pattern = "^"..escaped_muc_domain_prefix..".([^%.]+)%."..escaped_muc_domain_base; + +-- table to store all incoming iqs without roomname in it, like discoinfo to the muc component +local roomless_iqs = {}; + +local OUTBOUND_SIP_JIBRI_PREFIXES = { 'outbound-sip-jibri@', 'sipjibriouta@', 'sipjibrioutb@' }; +local INBOUND_SIP_JIBRI_PREFIXES = { 'inbound-sip-jibri@', 'sipjibriina@', 'sipjibriina@' }; + +local split_subdomain_cache = cache.new(1000); +local extract_subdomain_cache = cache.new(1000); +local internal_room_jid_cache = cache.new(1000); + +local moderated_subdomains = module:get_option_set("allowners_moderated_subdomains", {}) +local moderated_rooms = module:get_option_set("allowners_moderated_rooms", {}) + +-- Utility function to split room JID to include room name and subdomain +-- (e.g. from room1@conference.foo.example.com/res returns (room1, example.com, res, foo)) +local function room_jid_split_subdomain(room_jid) + local ret = split_subdomain_cache:get(room_jid); + if ret then + return ret.node, ret.host, ret.resource, ret.subdomain; + end + + local node, host, resource = jid.split(room_jid); + + local target_subdomain = host and host:match(target_subdomain_pattern); + local cache_value = {node=node, host=host, resource=resource, subdomain=target_subdomain}; + split_subdomain_cache:set(room_jid, cache_value); + return node, host, resource, target_subdomain; +end + +--- Utility function to check and convert a room JID from +--- virtual room1@conference.foo.example.com to real [foo]room1@conference.example.com +-- @param room_jid the room jid to match and rewrite if needed +-- @param stanza the stanza +-- @return returns room jid [foo]room1@conference.example.com when it has subdomain +-- otherwise room1@conference.example.com(the room_jid value untouched) +local function room_jid_match_rewrite(room_jid, stanza) + local node, _, resource, target_subdomain = room_jid_split_subdomain(room_jid); + if not target_subdomain then + -- module:log("debug", "No need to rewrite out 'to' %s", room_jid); + return room_jid; + end + -- Ok, rewrite room_jid address to new format + local new_node, new_host, new_resource; + if node then + new_node, new_host, new_resource = "["..target_subdomain.."]"..node, muc_domain, resource; + else + -- module:log("debug", "No room name provided so rewriting only host 'to' %s", room_jid); + new_host, new_resource = muc_domain, resource; + + if (stanza and stanza.attr and stanza.attr.id) then + roomless_iqs[stanza.attr.id] = stanza.attr.to; + end + end + + return jid.join(new_node, new_host, new_resource); +end + +-- Utility function to check and convert a room JID from real [foo]room1@muc.example.com to virtual room1@muc.foo.example.com +local function internal_room_jid_match_rewrite(room_jid, stanza) + -- first check for roomless_iqs + if (stanza and stanza.attr and stanza.attr.id and roomless_iqs[stanza.attr.id]) then + local result = roomless_iqs[stanza.attr.id]; + roomless_iqs[stanza.attr.id] = nil; + return result; + end + + local ret = internal_room_jid_cache:get(room_jid); + if ret then + return ret; + end + + local node, host, resource = jid.split(room_jid); + if host ~= muc_domain or not node then + -- module:log("debug", "No need to rewrite %s (not from the MUC host)", room_jid); + internal_room_jid_cache:set(room_jid, room_jid); + return room_jid; + end + + local target_subdomain, target_node = extract_subdomain(node); + if not (target_node and target_subdomain) then + -- module:log("debug", "Not rewriting... unexpected node format: %s", node); + internal_room_jid_cache:set(room_jid, room_jid); + return room_jid; + end + + -- Ok, rewrite room_jid address to pretty format + ret = jid.join(target_node, muc_domain_prefix..".".. target_subdomain.."."..muc_domain_base, resource); + internal_room_jid_cache:set(room_jid, ret); + return ret; +end + +--- Finds and returns room by its jid +-- @param room_jid the room jid to search in the muc component +-- @return returns room if found or nil +function get_room_from_jid(room_jid) + local _, host = jid.split(room_jid); + local component = hosts[host]; + if component then + local muc = component.modules.muc + if muc and rawget(muc,"rooms") then + -- We're running 0.9.x or 0.10 (old MUC API) + return muc.rooms[room_jid]; + elseif muc and rawget(muc,"get_room_from_jid") then + -- We're running >0.10 (new MUC API) + return muc.get_room_from_jid(room_jid); + else + return + end + end +end + +-- Returns the room if available, work and in multidomain mode +-- @param room_name the name of the room +-- @param group name of the group (optional) +-- @return returns room if found or nil +function get_room_by_name_and_subdomain(room_name, subdomain) + local room_address; + + -- if there is a subdomain we are in multidomain mode and that subdomain is not our main host + if subdomain and subdomain ~= "" and subdomain ~= muc_domain_base then + room_address = jid.join("["..subdomain.."]"..room_name, muc_domain); + else + room_address = jid.join(room_name, muc_domain); + end + + return get_room_from_jid(room_address); +end + +function async_handler_wrapper(event, handler) + if not have_async then + module:log("error", "requires a version of Prosody with util.async"); + return nil; + end + + local runner = async.runner; + + -- Grab a local response so that we can send the http response when + -- the handler is done. + local response = event.response; + local async_func = runner( + function (event) + local result = handler(event) + + -- If there is a status code in the result from the + -- wrapped handler then add it to the response. + if tonumber(result.status_code) ~= nil then + response.status_code = result.status_code + end + + -- If there are headers in the result from the + -- wrapped handler then add them to the response. + if result.headers ~= nil then + response.headers = result.headers + end + + -- Send the response to the waiting http client with + -- or without the body from the wrapped handler. + if result.body ~= nil then + response:send(result.body) + else + response:send(); + end + end + ) + async_func:run(event) + -- return true to keep the client http connection open. + return true; +end + +--- Updates presence stanza, by adding identity node +-- @param stanza the presence stanza +-- @param user the user to which presence we are updating identity +-- @param group the group of the user to which presence we are updating identity +-- @param creator_user the user who created the user which presence we +-- are updating (this is the poltergeist case, where a user creates +-- a poltergeist), optional. +-- @param creator_group the group of the user who created the user which +-- presence we are updating (this is the poltergeist case, where a user creates +-- a poltergeist), optional. +function update_presence_identity( + stanza, user, group, creator_user, creator_group) + + -- First remove any 'identity' element if it already + -- exists, so it cannot be spoofed by a client + stanza:maptags( + function(tag) + for k, v in pairs(tag) do + if k == "name" and v == "identity" then + return nil + end + end + return tag + end + ) + + stanza:tag("identity"):tag("user"); + for k, v in pairs(user) do + v = tostring(v) + stanza:tag(k):text(v):up(); + end + stanza:up(); + + -- Add the group information if it is present + if group then + stanza:tag("group"):text(group):up(); + end + + -- Add the creator user information if it is present + if creator_user then + stanza:tag("creator_user"); + for k, v in pairs(creator_user) do + stanza:tag(k):text(v):up(); + end + stanza:up(); + + -- Add the creator group information if it is present + if creator_group then + stanza:tag("creator_group"):text(creator_group):up(); + end + end + + stanza:up(); -- Close identity tag +end + +-- Utility function to check whether feature is present and enabled. Allow +-- a feature if there are features present in the session(coming from +-- the token) and the value of the feature is true. +-- If features is not present in the token we skip feature detection and allow +-- everything. +function is_feature_allowed(features, ft) + if (features == nil or features[ft] == "true" or features[ft] == true) then + return true; + else + return false; + end +end + +--- Extracts the subdomain and room name from internal jid node [foo]room1 +-- @return subdomain(optional, if extracted or nil), the room name +function extract_subdomain(room_node) + local ret = extract_subdomain_cache:get(room_node); + if ret then + return ret.subdomain, ret.room; + end + + local subdomain, room_name = room_node:match("^%[([^%]]+)%](.+)$"); + local cache_value = {subdomain=subdomain, room=room_name}; + extract_subdomain_cache:set(room_node, cache_value); + return subdomain, room_name; +end + +function starts_with(str, start) + if not str then + return false; + end + return str:sub(1, #start) == start +end + +function starts_with_one_of(str, prefixes) + if not str then + return false; + end + for i=1,#prefixes do + if starts_with(str, prefixes[i]) then + return prefixes[i]; + end + end + return false +end + + +function ends_with(str, ending) + return ending == "" or str:sub(-#ending) == ending +end + +-- healthcheck rooms in jicofo starts with a string '__jicofo-health-check' +function is_healthcheck_room(room_jid) + return starts_with(room_jid, "__jicofo-health-check"); +end + +--- Utility function to make an http get request and +--- retry @param retry number of times +-- @param url endpoint to be called +-- @param retry nr of retries, if retry is +-- @param auth_token value to be passed as auth Bearer +-- nil there will be no retries +-- @returns result of the http call or nil if +-- the external call failed after the last retry +function http_get_with_retry(url, retry, auth_token) + local content, code, cache_for; + local timeout_occurred; + local wait, done = async.waiter(); + local request_headers = http_headers or {} + if auth_token ~= nil then + request_headers['Authorization'] = 'Bearer ' .. auth_token + end + + local function cb(content_, code_, response_, request_) + if timeout_occurred == nil then + code = code_; + if code == 200 or code == 204 then + -- module:log("debug", "External call was successful, content %s", content_); + content = content_; + + -- if there is cache-control header, let's return the max-age value + if response_ and response_.headers and response_.headers['cache-control'] then + local vals = {}; + for k, v in response_.headers['cache-control']:gmatch('(%w+)=(%w+)') do + vals[k] = v; + end + -- max-age=123 will be parsed by the regex ^ to age=123 + cache_for = vals.age; + end + else + module:log("warn", "Error on GET request: Code %s, Content %s", + code_, content_); + end + done(); + else + module:log("warn", "External call reply delivered after timeout from: %s", url); + end + end + + local function call_http() + return http.request(url, { + headers = request_headers, + method = "GET" + }, cb); + end + + local request = call_http(); + + local function cancel() + -- TODO: This check is racey. Not likely to be a problem, but we should + -- still stick a mutex on content / code at some point. + if code == nil then + timeout_occurred = true; + module:log("warn", "Timeout %s seconds making the external call to: %s", http_timeout, url); + -- no longer present in prosody 0.11, so check before calling + if http.destroy_request ~= nil then + http.destroy_request(request); + end + if retry == nil then + module:log("debug", "External call failed and retry policy is not set"); + done(); + elseif retry ~= nil and retry < 1 then + module:log("debug", "External call failed after retry") + done(); + else + module:log("debug", "External call failed, retry nr %s", retry) + retry = retry - 1; + request = call_http() + return http_timeout; + end + end + end + timer.add_task(http_timeout, cancel); + wait(); + + return content, code, cache_for; +end + +-- Checks whether there is status in the false +-- -> true, room_name, subdomain +-- -> true, room_name, nil (if no subdomain is used for the room) +function is_moderated(room_jid) + if moderated_subdomains:empty() and moderated_rooms:empty() then + return false; + end + + local room_node = jid.node(room_jid); + -- parses bare room address, for multidomain expected format is: + -- [subdomain]roomName@conference.domain + local target_subdomain, target_room_name = extract_subdomain(room_node); + if target_subdomain then + if moderated_subdomains:contains(target_subdomain) then + return true, target_room_name, target_subdomain; + end + elseif moderated_rooms:contains(room_node) then + return true, room_node, nil; + end + + return false; +end + +-- check if the room tenant starts with vpaas-magic-cookie- +-- @param room the room to check +function is_vpaas(room) + if not room then + return false; + end + + -- stored check in room object if it exist + if room.is_vpaas ~= nil then + return room.is_vpaas; + end + + room.is_vpaas = false; + + local node, host = jid.split(room.jid); + if host ~= muc_domain or not node then + return false; + end + local tenant, conference_name = node:match('^%[([^%]]+)%](.+)$'); + if not (tenant and conference_name) then + return false; + end + + if not starts_with(tenant, 'vpaas-magic-cookie-') then + return false; + end + + room.is_vpaas = true; + return true; +end + +-- Returns the initiator extension if the stanza is coming from a sip jigasi +function is_sip_jigasi(stanza) + return stanza:get_child('initiator', 'http://jitsi.org/protocol/jigasi'); +end + +function get_sip_jibri_email_prefix(email) + if not email then + return nil; + elseif starts_with_one_of(email, INBOUND_SIP_JIBRI_PREFIXES) then + return starts_with_one_of(email, INBOUND_SIP_JIBRI_PREFIXES); + elseif starts_with_one_of(email, OUTBOUND_SIP_JIBRI_PREFIXES) then + return starts_with_one_of(email, OUTBOUND_SIP_JIBRI_PREFIXES); + else + return nil; + end +end + +function is_sip_jibri_join(stanza) + if not stanza then + return false; + end + + local features = stanza:get_child('features'); + local email = stanza:get_child_text('email'); + + if not features or not email then + return false; + end + + for i = 1, #features do + local feature = features[i]; + if feature.attr and feature.attr.var and feature.attr.var == "http://jitsi.org/protocol/jibri" then + if get_sip_jibri_email_prefix(email) then + module:log("debug", "Occupant with email %s is a sip jibri ", email); + return true; + end + end + end + + return false +end + +-- process a host module directly if loaded or hooks to wait for its load +function process_host_module(name, callback) + local function process_host(host) + + if host == name then + callback(module:context(host), host); + end + end + + if prosody.hosts[name] == nil then + module:log('info', 'No host/component found, will wait for it: %s', name) + + -- when a host or component is added + prosody.events.add_handler('host-activated', process_host); + else + process_host(name); + end +end + +function table_shallow_copy(t) + local t2 = {} + for k, v in pairs(t) do + t2[k] = v + end + return t2 +end + +return { + OUTBOUND_SIP_JIBRI_PREFIXES = OUTBOUND_SIP_JIBRI_PREFIXES; + INBOUND_SIP_JIBRI_PREFIXES = INBOUND_SIP_JIBRI_PREFIXES; + extract_subdomain = extract_subdomain; + is_feature_allowed = is_feature_allowed; + is_healthcheck_room = is_healthcheck_room; + is_moderated = is_moderated; + is_sip_jibri_join = is_sip_jibri_join; + is_sip_jigasi = is_sip_jigasi; + is_vpaas = is_vpaas; + get_focus_occupant = get_focus_occupant; + get_room_from_jid = get_room_from_jid; + get_room_by_name_and_subdomain = get_room_by_name_and_subdomain; + get_sip_jibri_email_prefix = get_sip_jibri_email_prefix; + async_handler_wrapper = async_handler_wrapper; + presence_check_status = presence_check_status; + process_host_module = process_host_module; + room_jid_match_rewrite = room_jid_match_rewrite; + room_jid_split_subdomain = room_jid_split_subdomain; + internal_room_jid_match_rewrite = internal_room_jid_match_rewrite; + update_presence_identity = update_presence_identity; + http_get_with_retry = http_get_with_retry; + ends_with = ends_with; + starts_with = starts_with; + starts_with_one_of = starts_with_one_of; + table_shallow_copy = table_shallow_copy; +}; diff --git a/roles/jitsi/handlers/main.yml b/roles/jitsi/handlers/main.yml index 973afd9..39aa050 100644 --- a/roles/jitsi/handlers/main.yml +++ b/roles/jitsi/handlers/main.yml @@ -11,3 +11,6 @@ - name: restart jitsi-confmapper service: name=jitsi-confmapper state={{ jitsi_jigasi | ternary('restarted', 'stopped') }} + +- name: restart jitsi-excalidraw + service: name=jitsi-excalidraw state=restarted diff --git a/roles/jitsi/tasks/cleanup.yml b/roles/jitsi/tasks/cleanup.yml index d893f00..e4406d0 100644 --- a/roles/jitsi/tasks/cleanup.yml +++ b/roles/jitsi/tasks/cleanup.yml @@ -3,8 +3,13 @@ - name: Remove temp files file: path={{ item }} state=absent loop: - - "{{ jitsi_root_dir }}/tmp/jicofo-1.1-SNAPSHOT" - - "{{ jitsi_root_dir }}/src/jicofo/target" + - "{{ jitsi_root_dir }}/tmp/jicofo-stable-jitsi-meet_{{ jitsi_version }}.tar.gz" + - "{{ jitsi_root_dir }}/tmp/jicofo-stable-jitsi-meet_{{ jitsi_version }}" + - "{{ jitsi_root_dir }}/tmp/jigasi-master.tar.gz" + - "{{ jitsi_root_dir }}/tmp/jigasi-master" - "{{ jitsi_root_dir }}/tmp/jigasi-linux-x64-1.1-SNAPSHOT" - - "{{ jitsi_root_dir }}/src/jigasi/target" + - "{{ jitsi_root_dir }}/tmp/jitsi-meet-stable-jitsi-meet_{{ jitsi_version }}.tar.gz" + - "{{ jitsi_root_dir }}/tmp/jitsi-meet-stable-jitsi-meet_{{ jitsi_version }}" + - "{{ jitsi_root_dir }}/tmp/excalidraw-backend-{{ jitsi_excalidraw_version }}.tar.gz" + - "{{ jitsi_root_dir }}/tmp/excalidraw-backend-{{ jitsi_excalidraw_version }}" tags: jitsi diff --git a/roles/jitsi/tasks/conf.yml b/roles/jitsi/tasks/conf.yml index 7f65687..240116d 100644 --- a/roles/jitsi/tasks/conf.yml +++ b/roles/jitsi/tasks/conf.yml @@ -8,7 +8,7 @@ - name: Register XMPP accounts block: - - name: Reload prosody + - name: Restart prosody service: name=prosody state=restarted - name: register XMPP users diff --git a/roles/jitsi/tasks/directories.yml b/roles/jitsi/tasks/directories.yml index 478010e..16b678c 100644 --- a/roles/jitsi/tasks/directories.yml +++ b/roles/jitsi/tasks/directories.yml @@ -12,14 +12,6 @@ owner: "{{ jitsi_user }}" group: "{{ jitsi_user }}" mode: 700 - - dir: "{{ jitsi_root_dir }}/src/videobridge" - owner: "{{ jitsi_user }}" - - dir: "{{ jitsi_root_dir }}/src/jicofo" - owner: "{{ jitsi_user }}" - - dir: "{{ jitsi_root_dir }}/src/jigasi" - owner: "{{ jitsi_user }}" - - dir: "{{ jitsi_root_dir }}/src/meet" - owner: "{{ jitsi_user }}" - dir: "{{ jitsi_root_dir }}/videobridge" - dir: "{{ jitsi_root_dir }}/jibri" - dir: "{{ jitsi_root_dir }}/jicofo" @@ -51,4 +43,6 @@ group: "{{ jitsi_user }}" mode: 700 - dir: "{{ jitsi_root_dir }}/confmapper" + - dir: "{{ jitsi_root_dir }}/prosody/modules" + - dir: "{{ jitsi_root_dir }}/excalidraw" tags: jitsi diff --git a/roles/jitsi/tasks/facts.yml b/roles/jitsi/tasks/facts.yml index d22dca6..2585279 100644 --- a/roles/jitsi/tasks/facts.yml +++ b/roles/jitsi/tasks/facts.yml @@ -4,6 +4,46 @@ set_fact: jitsi_jigasi={{ (jitsi_jigasi_sip_user is defined and jitsi_jigasi_sip_secret is defined) | ternary(True, False) }} tags: jitsi +# Detect jicofo version, if already installed +- block: + - import_tasks: ../includes/webapps_set_install_mode.yml + vars: + - root_dir: "{{ jitsi_root_dir }}" + - version: "{{ jitsi_version }}" + - version_file: ansible_jicofo_version + - set_fact: jitsi_jicofo_install_mode={{ install_mode }} + tags: jitsi + +# Detect jigasi version, if already installed +- block: + - import_tasks: ../includes/webapps_set_install_mode.yml + vars: + - root_dir: "{{ jitsi_root_dir }}" + - version: "{{ jitsi_version }}" + - version_file: ansible_jigasi_version + - set_fact: jitsi_jigasi_install_mode={{ install_mode }} + tags: jitsi + +# Detect meet version, if already installed +- block: + - import_tasks: ../includes/webapps_set_install_mode.yml + vars: + - root_dir: "{{ jitsi_root_dir }}" + - version: "{{ jitsi_version }}" + - version_file: ansible_meet_version + - set_fact: jitsi_meet_install_mode={{ install_mode }} + tags: jitsi + +# Detect excalidraw version, if already installed +- block: + - import_tasks: ../includes/webapps_set_install_mode.yml + vars: + - root_dir: "{{ jitsi_root_dir }}" + - version: "{{ jitsi_excalidraw_version }}" + - version_file: ansible_excalidraw_version + - set_fact: jitsi_excalidraw_install_mode={{ install_mode }} + tags: jitsi + - name: Generate a random secret for videobridge block: - import_tasks: ../includes/get_rand_pass.yml @@ -97,17 +137,3 @@ register: jitsi_key_file tags: jitsi -- name: Check if jicofo is built - stat: path={{ jitsi_root_dir }}/jicofo/jicofo.sh - register: jitsi_jicofo_script - tags: jitsi - -- name: Check if jigasi is built - stat: path={{ jitsi_root_dir }}/jigasi/jigasi.sh - register: jitsi_jigasi_script - tags: jitsi - -- name: Check if meet is installed - stat: path={{ jitsi_root_dir }}/meet/index.html - register: jitsi_meet_index - tags: jitsi diff --git a/roles/jitsi/tasks/install.yml b/roles/jitsi/tasks/install.yml index ed50349..d163a2c 100644 --- a/roles/jitsi/tasks/install.yml +++ b/roles/jitsi/tasks/install.yml @@ -3,38 +3,19 @@ - name: Install dependencies yum: name: - - java-11-openjdk-devel + - java-17-openjdk-devel - git - nodejs # needed to build meet - libXScrnSaver # needed for jigasi - python3 # needed for confmapper - make - tags: jitsi - -- name: Detect exact JRE version - command: rpm -q java-11-openjdk - changed_when: False - register: jitsi_jre11_version - tags: jitsi - -- name: Select JRE 11 as default version - alternatives: - name: "{{ item.name }}" - link: "{{ item.link }}" - path: "{{ item.path }}" - loop: - - name: java - link: /usr/bin/java - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }}/bin/java - - name: javac - link: /usr/bin/javac - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }}/bin/javac - - name: jre_openjdk - link: /usr/lib/jvm/jre-openjdk - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }} - - name: java_sdk_openjdk - link: /usr/lib/jvm/java-openjdk - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }} + - lua-ldap # All the lua libs are for prosody + - lua-cyrussasl + - lua-cjson + - lua-basexx + - lua-luaossl + - lua-inspect + - libjwt tags: jitsi # If you use an Let's Encrypt cert, it might not be there yet. In this case, create a link @@ -50,89 +31,140 @@ when: not jitsi_key_file.stat.exists tags: jitsi - # This file used to contain proxy settings for maven - # now this is handled in a maven general dir, so remove it from here -- name: Remove local maven configuration - file: path={{ jitsi_root_dir }}/.m2/settings.xml state=absent +- name: Install prosody modules + synchronize: + src: prosody/modules/ + dest: "{{ jitsi_root_dir }}/prosody/modules/" + recursive: true + notify: restart prosody tags: jitsi - # Now, for every component, we will clone or update the repo. - # If the repo changed since the last run, we rebuild and restart the corresponding component -- name: Clone jicofo repo - git: - repo: "{{ jitsi_jicofo_git_url }}" - dest: "{{ jitsi_root_dir }}/src/jicofo" - force: True - depth: 1 - single_branch: True - become_user: "{{ jitsi_user }}" - register: jitsi_jicofo_git +- name: Install bypass_pwd module for prosody + template: src=mod_jibri_bypass_pwd.lua.j2 dest={{ jitsi_root_dir }}/prosody/modules/mod_jibri_bypass_pwd.lua + notify: restart prosody tags: jitsi -- name: Install or update jicofo +- when: jitsi_jicofo_install_mode != 'none' block: + - name: Download Jitsi jicofo archive + get_url: + url: "{{ jitsi_jicofo_archive_url }}" + dest: "{{ jitsi_root_dir }}/tmp" + checksum: sha256:{{ jitsi_jicofo_archive_sha256 }} + become_user: "{{ jitsi_user }}" + + - name: Extract Jitsi Jicofo archive + unarchive: + src: "{{ jitsi_root_dir }}/tmp/jicofo-stable-jitsi-meet_{{ jitsi_version }}.tar.gz" + dest: "{{ jitsi_root_dir }}/tmp/" + remote_src: true + become_user: "{{ jitsi_user }}" + - name: Build jicofo command: /opt/maven/apache-maven/bin/mvn package -DskipTests -Dassembly.skipAssembly=false args: - chdir: "{{ jitsi_root_dir }}/src/jicofo" + chdir: "{{ jitsi_root_dir }}/tmp/jicofo-stable-jitsi-meet_{{ jitsi_version }}" + environment: + JAVA_HOME: /usr/lib/jvm/java-17 become_user: "{{ jitsi_user }}" - - name: Extract jicofo archive - unarchive: - src: "{{ jitsi_root_dir }}/src/jicofo/jicofo/target/jicofo-1.1-SNAPSHOT-archive.zip" - dest: "{{ jitsi_root_dir }}/tmp/" - remote_src: True + - name: Install jicofo jar + copy: + src: "{{ jitsi_root_dir }}/tmp/jicofo-stable-jitsi-meet_{{ jitsi_version }}/jicofo/target/jicofo-1.1-SNAPSHOT-jar-with-dependencies.jar" + dest: "{{ jitsi_root_dir }}/jicofo/jicofo.jar" + remote_src: true + + - name: Install jicofo startup script + copy: + src: "{{ jitsi_root_dir }}/tmp/jicofo-stable-jitsi-meet_{{ jitsi_version }}/resources/jicofo.sh" + dest: "{{ jitsi_root_dir }}/jicofo/jicofo.sh" + mode: 0755 + owner: root + group: root + remote_src: true + + - name: Write version + copy: content={{ jitsi_version }} dest={{ jitsi_root_dir }}/meta/ansible_jicofo_version - - name: Move jicofo to its final directory - synchronize: - src: "{{ jitsi_root_dir }}/tmp/jicofo-1.1-SNAPSHOT/" - dest: "{{ jitsi_root_dir }}/jicofo/" - recursive: True - delete: True - compress: False - delegate_to: "{{ inventory_hostname }}" - notify: restart jitsi-jicofo - when: (jitsi_jicofo_git.changed and jitsi_manage_upgrade) or not jitsi_jicofo_script.stat.exists tags: jitsi -- name: Clone jigasi repo - git: - repo: "{{ jitsi_jigasi_git_url }}" - dest: "{{ jitsi_root_dir }}/src/jigasi" - force: True - depth: 1 - single_branch: True - become_user: "{{ jitsi_user }}" - register: jitsi_jigasi_git - tags: jitsi - -- name: Install or update jigasi +- when: jitsi_jigasi_install_mode != 'none' block: + - name: Download Jitsi jigasi archive + get_url: + url: "{{ jitsi_jigasi_archive_url }}" + dest: "{{ jitsi_root_dir }}/tmp" + become_user: "{{ jitsi_user }}" + + - name: Extract Jitsi Jigasi archive + unarchive: + src: "{{ jitsi_root_dir }}/tmp/jigasi-master.tar.gz" + dest: "{{ jitsi_root_dir }}/tmp/" + remote_src: true + become_user: "{{ jitsi_user }}" + - name: Build jigasi command: /opt/maven/apache-maven/bin/mvn package -DskipTests -Dassembly.skipAssembly=false args: - chdir: "{{ jitsi_root_dir }}/src/jigasi" + chdir: "{{ jitsi_root_dir }}/tmp/jigasi-master" + environment: + JAVA_HOME: /usr/lib/jvm/java-17 become_user: "{{ jitsi_user }}" - name: Extract jigasi archive unarchive: - src: "{{ jitsi_root_dir }}/src/jigasi/target/jigasi-linux-x64-1.1-SNAPSHOT.zip" + src: "{{ jitsi_root_dir }}/tmp/jigasi-master/target/jigasi-linux-x64-1.1-SNAPSHOT.zip" dest: "{{ jitsi_root_dir }}/tmp/" - remote_src: True - - # - name: Link libunix-java lib - # file: src=libunix-0.5.1.so dest={{ jitsi_root_dir }}/tmp/jigasi-linux-x64-1.1-SNAPSHOT/lib/libunix-java.so state=link + remote_src: true - name: Move jigasi to its final directory synchronize: src: "{{ jitsi_root_dir }}/tmp/jigasi-linux-x64-1.1-SNAPSHOT/" dest: "{{ jitsi_root_dir }}/jigasi/" - recursive: True - delete: True - compress: False + recursive: true + delete: true + compress: false delegate_to: "{{ inventory_hostname }}" notify: restart jitsi-jigasi - when: (jitsi_jigasi_git.changed and jitsi_manage_upgrade) or not jitsi_jigasi_script.stat.exists + + - name: Write version + copy: content={{ jitsi_version }} dest={{ jitsi_root_dir }}/meta/ansible_jigasi_version + + tags: jitsi + +- when: jitsi_excalidraw_install_mode != 'none' + block: + + - name: Download Excalidraw backend + get_url: + url: "{{ jitsi_excalidraw_archive_url }}" + dest: "{{ jitsi_root_dir }}/tmp" + checksum: sha256:{{ jitsi_excalidraw_archive_sha256 }} + become_user: "{{ jitsi_user }}" + + - name: Extract Excalidraw archive + unarchive: + src: "{{ jitsi_root_dir }}/tmp/excalidraw-backend-{{ jitsi_excalidraw_version }}.tar.gz" + dest: "{{ jitsi_root_dir }}/tmp/" + remote_src: true + become_user: "{{ jitsi_user }}" + + - name: Install node dependencies + npm: path={{ jitsi_root_dir }}/tmp/excalidraw-backend-{{ jitsi_excalidraw_version }} + become_user: "{{ jitsi_user }}" + + - name: Install Excalidraw backend + synchronize: + src: "{{ jitsi_root_dir }}/tmp/excalidraw-backend-{{ jitsi_excalidraw_version }}/" + dest: "{{ jitsi_root_dir }}/excalidraw/" + recursive: true + delete: true + compress: false + delegate_to: "{{ inventory_hostname }}" + + - name: Write installed version + copy: content={{ jitsi_excalidraw_version }} dest={{ jitsi_root_dir }}/meta/ansible_excalidraw_version + tags: jitsi - name: Deploy systemd unit @@ -141,11 +173,13 @@ - jitsi-jicofo - jitsi-jigasi - jitsi-confmapper + - jitsi-excalidraw register: jitsi_units notify: - restart jitsi-jicofo - restart jitsi-jigasi - restart jitsi-confmapper + - restart jitsi-excalidraw tags: jitsi - name: Reload systemd @@ -153,39 +187,35 @@ when: jitsi_units.results | selectattr('changed', 'equalto', True) | list | length > 0 tags: jitsi -- name: Clone jitsi meet - git: - repo: "{{ jitsi_meet_git_url }}" - dest: "{{ jitsi_root_dir }}/src/meet" - force: True - depth: 1 - single_branch: True - register: jitsi_meet_git - become_user: "{{ jitsi_user }}" - tags: jitsi - -- name: Install or update jitsi meet +- when: jitsi_meet_install_mode != 'none' block: + - name: Download Jitsi Meet archive + get_url: + url: "{{ jitsi_meet_archive_url }}" + dest: "{{ jitsi_root_dir }}/tmp" + checksum: sha256:{{ jitsi_meet_archive_sha256 }} + become_user: "{{ jitsi_user }}" + + - name: Extract Jitsi Meet archive + unarchive: + src: "{{ jitsi_root_dir }}/tmp/jitsi-meet-stable-jitsi-meet_{{ jitsi_version }}.tar.gz" + dest: "{{ jitsi_root_dir }}/tmp/" + remote_src: true + become_user: "{{ jitsi_user }}" + - name: Clear node_modules cache - file: path={{ jitsi_root_dir }}/src/meet/node_modules state=absent + file: path={{ jitsi_root_dir }}/tmp/jitsi-meet-stable-jitsi-meet_{{ jitsi_version }}/node_modules state=absent - name: Install jitsi meet node dependencies - npm: path={{ jitsi_root_dir }}/src/meet + npm: path={{ jitsi_root_dir }}/tmp/jitsi-meet-stable-jitsi-meet_{{ jitsi_version }} become_user: "{{ jitsi_user }}" - name: Build jitsi meet command: make args: - chdir: "{{ jitsi_root_dir }}/src/meet" + chdir: "{{ jitsi_root_dir }}/tmp/jitsi-meet-stable-jitsi-meet_{{ jitsi_version }}" become_user: "{{ jitsi_user }}" - #- name: Reset git (so next run won't detect a change) - # command: git checkout {{ jitsi_root_dir }}/src/meet/resources/load-test/package-lock.json - # changed_when: False - # args: - # chdir: "{{ jitsi_root_dir }}/src/meet" - # become_user: "{{ jitsi_user }}" - - name: Deploy new jitsi meet version shell: | rm -rf {{ jitsi_root_dir }}/meet/* @@ -193,8 +223,11 @@ cp -r *.js *.html resources/*.txt connection_optimization favicon.ico fonts images libs static sounds LICENSE lang {{ jitsi_root_dir }}/meet/ cp css/all.css {{ jitsi_root_dir }}/meet/css/ args: - chdir: "{{ jitsi_root_dir }}/src/meet" - when: (jitsi_meet_git.changed and jitsi_manage_upgrade) or not jitsi_meet_index.stat.exists + chdir: "{{ jitsi_root_dir }}/tmp/jitsi-meet-stable-jitsi-meet_{{ jitsi_version }}" + + - name: Write installed version + copy: content={{ jitsi_version }} dest={{ jitsi_root_dir }}/meta/ansible_meet_version + tags: jitsi - name: Update languages @@ -214,12 +247,3 @@ notify: restart jitsi-confmapper tags: jitsi -- name: Ensure prosody module dir exists - file: path=/opt/prosody/modules/ state=directory - tags: jitsi - -- name: Install bypass_pwd module for prosody - template: src=mod_jibri_bypass_pwd.lua.j2 dest=/opt/prosody/modules/mod_jibri_bypass_pwd.lua - notify: reload prosody - tags: jitsi - diff --git a/roles/jitsi/tasks/services.yml b/roles/jitsi/tasks/services.yml index 6e92eb7..4f9e714 100644 --- a/roles/jitsi/tasks/services.yml +++ b/roles/jitsi/tasks/services.yml @@ -1,7 +1,7 @@ --- - name: Start and enable services - service: name=jitsi-jicofo state=started enabled=True + service: name=jitsi-jicofo state=started enabled=true tags: jitsi - name: Start and enable jigasi @@ -11,3 +11,7 @@ - name: Start and enable confmapper service: name=jitsi-confmapper state={{ jitsi_jigasi | ternary('started', 'stopped') }} enabled={{ jitsi_jigasi }} tags: jitsi + +- name: Start and enable excalidraw + service: name=jitsi-excalidraw state=started enabled=true + tags: jitsi diff --git a/roles/jitsi/templates/jitsi-excalidraw.service.j2 b/roles/jitsi/templates/jitsi-excalidraw.service.j2 new file mode 100644 index 0000000..171d51c --- /dev/null +++ b/roles/jitsi/templates/jitsi-excalidraw.service.j2 @@ -0,0 +1,24 @@ +[Unit] +Description=Jitsi Whiteboard backend + +[Service] +Type=simple +User={{ jitsi_user }} +Group={{ jitsi_user }} +Environment=PORT=3018 +Environment=NODE_ENV=production +WorkingDirectory={{ jitsi_root_dir }}/excalidraw +ExecStart=/bin/npm start +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=full +ProtectHome=yes +NoNewPrivileges=yes +SyslogIdentifier=jitsi-excalidraw +Restart=on-failure +StartLimitInterval=0 +RestartSec=30 + +[Install] +WantedBy=multi-user.target + diff --git a/roles/jitsi/templates/jitsi-jicofo.service.j2 b/roles/jitsi/templates/jitsi-jicofo.service.j2 index 48e8f49..d166416 100644 --- a/roles/jitsi/templates/jitsi-jicofo.service.j2 +++ b/roles/jitsi/templates/jitsi-jicofo.service.j2 @@ -15,6 +15,8 @@ ReadOnlyDirectories={{ jitsi_root_dir }}/etc {{ jitsi_root_dir }}/jicofo Restart=on-failure StartLimitInterval=0 RestartSec=30 +Environment=JAVA_HOME=/usr/lib/jvm/java-17 +Environment=PATH=/usr/lib/jvm/java-17/bin:/usr/local/sbin:/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin Environment=JAVA_SYS_PROPS=-Dconfig.file={{ jitsi_root_dir }}/etc/jicofo/jicofo.conf ExecStart=/opt/jitsi/jicofo/jicofo.sh \ ${JICOFO_OPT} diff --git a/roles/jitsi/templates/jitsi-jigasi.service.j2 b/roles/jitsi/templates/jitsi-jigasi.service.j2 index 40c10f7..ee94200 100644 --- a/roles/jitsi/templates/jitsi-jigasi.service.j2 +++ b/roles/jitsi/templates/jitsi-jigasi.service.j2 @@ -15,6 +15,8 @@ ProtectSystem=full Restart=on-failure StartLimitInterval=0 RestartSec=30 +Environment=JAVA_HOME=/usr/lib/jvm/java-17 +Environment=PATH=/usr/lib/jvm/java-17/bin:/usr/local/sbin:/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin ExecStart=/opt/jitsi/jigasi/jigasi.sh \ --configdir={{ jitsi_root_dir }}/etc \ --configdirname=jigasi \ diff --git a/roles/jitsi/templates/nginx.conf.j2 b/roles/jitsi/templates/nginx.conf.j2 index e181993..ecfb7b9 100644 --- a/roles/jitsi/templates/nginx.conf.j2 +++ b/roles/jitsi/templates/nginx.conf.j2 @@ -26,6 +26,14 @@ server { # TODO : rate limit these endpoints to prevent room listing } + # Excalidraw + location /socket.io { + proxy_pass http://localhost:3018; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + # BOSH endpoint location /http-bind { proxy_socket_keepalive on; diff --git a/roles/jitsi/templates/prosody.cfg.lua.j2 b/roles/jitsi/templates/prosody.cfg.lua.j2 index 916606a..73a3e8e 100644 --- a/roles/jitsi/templates/prosody.cfg.lua.j2 +++ b/roles/jitsi/templates/prosody.cfg.lua.j2 @@ -1,4 +1,6 @@ +plugin_paths = { "{{ jitsi_root_dir }}/prosody/modules" } + muc_mapper_domain_base = "{{ jitsi_domain }}"; admins = { "{{ jitsi_jicofo_xmpp_user }}@{{ jitsi_auth_domain }}" }; http_default_host = "{{ jitsi_domain }}"; diff --git a/roles/jitsi_jibri/defaults/main.yml b/roles/jitsi_jibri/defaults/main.yml index b58bd08..2284457 100644 --- a/roles/jitsi_jibri/defaults/main.yml +++ b/roles/jitsi_jibri/defaults/main.yml @@ -3,10 +3,9 @@ jitsi_root_dir: /opt/jitsi jitsi_jibri_user: jibri -jitsi_jibri_git_url: https://github.com/jitsi/jibri.git - -# Should ansible manage upgrades or only initial install -jitsi_jibri_manage_upgrade: "{{ jitsi_manage_upgrade | default(True) }}" +jitsi_jibri_version: "{{ jitsi_version | default('9584') }}" +# Jibri as no release, nor tag, so use master +jitsi_jibri_archive_url: https://github.com/jitsi/jibri/archive/refs/heads/master.tar.gz jitsi_jibri_domain: "{{ jitsi_domain | default(inventory_hostname) }}" jitsi_jibri_auth_domain: "{{ jitsi_auth_domain | default('auth.' ~ jitsi_domain) }}" diff --git a/roles/jitsi_jibri/tasks/cleanup.yml b/roles/jitsi_jibri/tasks/cleanup.yml index 96f4c80..09770b9 100644 --- a/roles/jitsi_jibri/tasks/cleanup.yml +++ b/roles/jitsi_jibri/tasks/cleanup.yml @@ -5,4 +5,6 @@ loop: - "{{ jitsi_root_dir }}/tmp/chromedriver-linux64.zip" - "{{ jitsi_root_dir }}/tmp/chromedriver-linux64" + - "{{ jitsi_root_dir }}/tmp/jibri-master.tar.gz" + - "{{ jitsi_root_dir }}/tmp/jibri-master" tags: jitsi diff --git a/roles/jitsi_jibri/tasks/facts.yml b/roles/jitsi_jibri/tasks/facts.yml index c190ee2..b4fd852 100644 --- a/roles/jitsi_jibri/tasks/facts.yml +++ b/roles/jitsi_jibri/tasks/facts.yml @@ -1,5 +1,15 @@ --- + # Detect jibri version, if already installed +- block: + - import_tasks: ../includes/webapps_set_install_mode.yml + vars: + - root_dir: "{{ jitsi_root_dir }}" + - version: "{{ jitsi_jibri_version }}" + - version_file: ansible_jibri_version + - set_fact: jitsi_jibri_install_mode={{ install_mode }} + tags: jitsi + - name: Generate a random pass for jibri block: - import_tasks: ../includes/get_rand_pass.yml @@ -18,11 +28,6 @@ when: jitsi_jibri_recorder_xmpp_pass is not defined tags: jitsi -- name: Check if jibri is built - stat: path={{ jitsi_root_dir }}/jibri/jibri.jar - register: jitsi_jibri_jar - tags: jitsi - - name: Check if ChromeDriver is installed stat: path=/usr/local/bin/chromedriver register: jitsi_jibri_chromedriver_bin diff --git a/roles/jitsi_jibri/tasks/install.yml b/roles/jitsi_jibri/tasks/install.yml index 5444ff8..73fa8a4 100644 --- a/roles/jitsi_jibri/tasks/install.yml +++ b/roles/jitsi_jibri/tasks/install.yml @@ -3,7 +3,7 @@ - name: Install dependencies package: name: - - java-11-openjdk + - java-17-openjdk - git - ffmpeg - curl @@ -23,51 +23,14 @@ state: latest tags: jitsi -- name: Detect exact JRE version - command: rpm -q java-11-openjdk - changed_when: False - register: jitsi_jre11_version - tags: jitsi - -- name: Select JRE 11 as default version - alternatives: - name: "{{ item.name }}" - link: "{{ item.link }}" - path: "{{ item.path }}" - loop: - - name: java - link: /usr/bin/java - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }}/bin/java - - name: javac - link: /usr/bin/javac - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }}/bin/javac - - name: jre_openjdk - link: /usr/lib/jvm/jre-openjdk - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }} - - name: java_sdk_openjdk - link: /usr/lib/jvm/java-openjdk - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }} - tags: jitsi - - # If the repo changed since the last run, we rebuild and restart the bridge -- name: Clone jibri repo - git: - repo: "{{ jitsi_jibri_git_url }}" - dest: "{{ jitsi_root_dir }}/src/jibri" - force: True - depth: 1 - single_branch: True - become_user: "{{ jitsi_jibri_user }}" - register: jitsi_jibri_git - tags: jitsi - - name: Get Chrome version command: rpm -q google-chrome-stable --qf %{VERSION} register: jitsi_jibri_chrome_version changed_when: false tags: jitsi -- name: Install or update ChromeDriver + # Ensure we're using the ChromeDriver corresponding to our current Chrome version +- when: not jitsi_jibri_chromedriver_bin.stat.exists or jitsi_jibri_chromedriver_current_version.stdout != jitsi_jibri_chrome_version.stdout block: - name: Download ChromeDriver get_url: @@ -78,37 +41,54 @@ unarchive: src: "{{ jitsi_root_dir }}/tmp/chromedriver-linux64.zip" dest: "{{ jitsi_root_dir }}/tmp" - remote_src: True + remote_src: true - name: Move ChromeDriver bin copy: src: "{{ jitsi_root_dir }}/tmp/chromedriver-linux64/chromedriver" dest: /usr/local/bin/chromedriver mode: 755 - remote_src: True + remote_src: true - when: not jitsi_jibri_chromedriver_bin.stat.exists or jitsi_jibri_chromedriver_current_version.stdout != jitsi_jibri_chrome_version.stdout tags: jitsi -- name: Install or update jibri +- when: jitsi_jibri_install_mode != 'none' block: + + - name: Download Jitsi Jibri archive + get_url: + url: "{{ jitsi_jibri_archive_url }}" + dest: "{{ jitsi_root_dir }}/tmp/" + become_user: "{{ jitsi_user }}" + + - name: Extract Jitsi Jibri archive + unarchive: + src: "{{ jitsi_root_dir }}/tmp/jibri-master.tar.gz" + dest: "{{ jitsi_root_dir }}/tmp/" + remote_src: true + become_user: "{{ jitsi_user }}" + - name: Build jibri command: /opt/maven/apache-maven/bin/mvn package -DskipTests -Dassembly.skipAssembly=false args: - chdir: "{{ jitsi_root_dir }}/src/jibri" - become_user: "{{ jitsi_jibri_user }}" + chdir: "{{ jitsi_root_dir }}/tmp/jibri-master" + environment: + JAVA_HOME: /usr/lib/jvm/java-17 + become_user: "{{ jitsi_user }}" - - name: Move jibri to its final directory + - name: Install Jitsi Jibri JAR copy: - src: "{{ jitsi_root_dir }}/src/jibri/target/jibri-8.0-SNAPSHOT-jar-with-dependencies.jar" + src: "{{ jitsi_root_dir }}/tmp/jibri-master/target/jibri-8.0-SNAPSHOT-jar-with-dependencies.jar" dest: "{{ jitsi_root_dir }}/jibri/jibri.jar" - remote_src: True + remote_src: true notify: restart jitsi-jibri - when: (jitsi_jibri_git.changed and jitsi_jibri_manage_upgrade) or not jitsi_jibri_jar.stat.exists + - name: Write installed version + copy: content={{ jitsi_jibri_version }} dest={{ jitsi_root_dir }}/meta/ansible_jibri_version + tags: jitsi -- name: Deploy systemd units +- name: Install systemd unit template: src=jitsi-{{ item }}.j2 dest=/etc/systemd/system/jitsi-{{ item }} loop: - jibri.service diff --git a/roles/jitsi_jibri/templates/jitsi-jibri.service.j2 b/roles/jitsi_jibri/templates/jitsi-jibri.service.j2 index 06aed1e..6a86495 100644 --- a/roles/jitsi_jibri/templates/jitsi-jibri.service.j2 +++ b/roles/jitsi_jibri/templates/jitsi-jibri.service.j2 @@ -12,6 +12,8 @@ PrivateTmp=true Restart=always StartLimitInterval=0 RestartSec=30 +Environment=JAVA_HOME=/usr/lib/jvm/java-17 +Environment=PATH=/usr/lib/jvm/java-17/bin:/usr/local/sbin:/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin ExecStart=/bin/java -Dconfig.file={{ jitsi_root_dir }}/etc/jibri/jibri.conf -jar {{ jitsi_root_dir }}/jibri/jibri.jar SyslogIdentifier=jibri diff --git a/roles/jitsi_videobridge/defaults/main.yml b/roles/jitsi_videobridge/defaults/main.yml index 77871c9..b69078c 100644 --- a/roles/jitsi_videobridge/defaults/main.yml +++ b/roles/jitsi_videobridge/defaults/main.yml @@ -3,10 +3,9 @@ jitsi_root_dir: /opt/jitsi jitsi_user: jitsi -jitsi_videobridge_git_url: https://github.com/jitsi/jitsi-videobridge.git - -# Should ansible manage upgrades or only initial install -jitsi_videobridge_manage_upgrade: "{{ jitsi_manage_upgrade | default(True) }}" +jitsi_videobridge_version: "{{ jitsi_version | default('9584') }}" +jitsi_videobridge_archive_url: https://github.com/jitsi/jitsi-videobridge/archive/refs/tags/stable/jitsi-meet_{{ jitsi_videobridge_version }}.tar.gz +jitsi_videobridge_archive_sha256: 004eaba010c5833620b2f548a2ffd02fcdc2de0171360b2848ff9f9452873145 jitsi_videobridge_rtp_port: 10000 jitsi_videobridge_src_ip: diff --git a/roles/jitsi_videobridge/tasks/cleanup.yml b/roles/jitsi_videobridge/tasks/cleanup.yml index f44fad9..c860bc4 100644 --- a/roles/jitsi_videobridge/tasks/cleanup.yml +++ b/roles/jitsi_videobridge/tasks/cleanup.yml @@ -4,5 +4,6 @@ file: path={{ item }} state=absent loop: - "{{ jitsi_root_dir }}/tmp/jitsi-videobridge-2.3-SNAPSHOT" - - "{{ jitsi_root_dir }}/src/videobridge/target" + - "{{ jitsi_root_dir }}/tmp/jitsi-videobridge-stable-jitsi-meet_{{ jitsi_videobridge_version }}.tar.gz" + - "{{ jitsi_root_dir }}/tmp/jitsi-videobridge-stable-jitsi-meet_{{ jitsi_videobridge_version }}" tags: jitsi diff --git a/roles/jitsi_videobridge/tasks/facts.yml b/roles/jitsi_videobridge/tasks/facts.yml index d3f070f..edd9991 100644 --- a/roles/jitsi_videobridge/tasks/facts.yml +++ b/roles/jitsi_videobridge/tasks/facts.yml @@ -1,5 +1,15 @@ --- + # Detect jicofo version, if already installed +- block: + - import_tasks: ../includes/webapps_set_install_mode.yml + vars: + - root_dir: "{{ jitsi_root_dir }}" + - version: "{{ jitsi_videobridge_version }}" + - version_file: ansible_videobridge_version + - set_fact: jitsi_videobridge_install_mode={{ install_mode }} + tags: jitsi + - name: Generate a random pass for videobridge block: - import_tasks: ../includes/get_rand_pass.yml @@ -9,7 +19,3 @@ when: jitsi_videobridge_xmpp_pass is not defined tags: jitsi -- name: Check if videobridge is built - stat: path={{ jitsi_root_dir }}/videobridge/jvb.sh - register: jitsi_videobridge_script - tags: jitsi diff --git a/roles/jitsi_videobridge/tasks/install.yml b/roles/jitsi_videobridge/tasks/install.yml index 1fe303a..447302a 100644 --- a/roles/jitsi_videobridge/tasks/install.yml +++ b/roles/jitsi_videobridge/tasks/install.yml @@ -1,75 +1,55 @@ --- - name: Install dependencies - yum: + package: name: - - java-11-openjdk-devel + - java-17-openjdk-devel - git tags: jitsi -- name: Detect exact JRE version - command: rpm -q java-11-openjdk - changed_when: False - register: jitsi_jre11_version - tags: jitsi - -- name: Select JRE 11 as default version - alternatives: - name: "{{ item.name }}" - link: "{{ item.link }}" - path: "{{ item.path }}" - loop: - - name: java - link: /usr/bin/java - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }}/bin/java - - name: javac - link: /usr/bin/javac - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }}/bin/javac - - name: jre_openjdk - link: /usr/lib/jvm/jre-openjdk - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }} - - name: java_sdk_openjdk - link: /usr/lib/jvm/java-openjdk - path: /usr/lib/jvm/{{ jitsi_jre11_version.stdout | trim }} - tags: jitsi - - # If the repo changed since the last run, we rebuild and restart the bridge -- name: Clone videobridge repo - git: - repo: "{{ jitsi_videobridge_git_url }}" - dest: "{{ jitsi_root_dir }}/src/videobridge" - force: True - depth: 1 - single_branch: True - become_user: "{{ jitsi_user }}" - register: jitsi_videobridge_git - tags: jitsi - -- name: Install or update videobridge +- when: jitsi_videobridge_install_mode != 'none' block: - - name: Build videobridge + - name: Download Jitsi Videobridge archive + get_url: + url: "{{ jitsi_videobridge_archive_url }}" + dest: "{{ jitsi_root_dir }}/tmp" + checksum: sha256:{{ jitsi_videobridge_archive_sha256 }} + become_user: "{{ jitsi_user }}" + + - name: Extract Jitsi Videobridge archive + unarchive: + src: "{{ jitsi_root_dir }}/tmp/jitsi-videobridge-stable-jitsi-meet_{{ jitsi_videobridge_version }}.tar.gz" + dest: "{{ jitsi_root_dir }}/tmp" + remote_src: true + become_user: "{{ jitsi_user }}" + + - name: Build Jitsi Videobridge command: /opt/maven/apache-maven/bin/mvn package -DskipTests -Dassembly.skipAssembly=false args: - chdir: "{{ jitsi_root_dir }}/src/videobridge" + chdir: "{{ jitsi_root_dir }}/tmp/jitsi-videobridge-stable-jitsi-meet_{{ jitsi_videobridge_version }}" + environment: + JAVA_HOME: /usr/lib/jvm/java-17 become_user: "{{ jitsi_user }}" - name: Extract videobridge archive unarchive: - src: "{{ jitsi_root_dir }}/src/videobridge/jvb/target/jitsi-videobridge-2.3-SNAPSHOT-archive.zip" + src: "{{ jitsi_root_dir }}/tmp/jitsi-videobridge-stable-jitsi-meet_{{ jitsi_videobridge_version }}/jvb/target/jitsi-videobridge-2.3-SNAPSHOT-archive.zip" dest: "{{ jitsi_root_dir }}/tmp/" - remote_src: True + remote_src: true - name: Move videobridge to its final directory synchronize: src: "{{ jitsi_root_dir }}/tmp/jitsi-videobridge-2.3-SNAPSHOT/" dest: "{{ jitsi_root_dir }}/videobridge/" - recursive: True - delete: True - compress: False + recursive: true + delete: true + compress: false delegate_to: "{{ inventory_hostname }}" notify: restart jitsi-videobridge - when: (jitsi_videobridge_git.changed and jitsi_videobridge_manage_upgrade) or not jitsi_videobridge_script.stat.exists + - name: Write installed version + copy: content={{ jitsi_videobridge_version }} dest={{ jitsi_root_dir }}/meta/ansible_videobridge_version + tags: jitsi - name: Deploy systemd unit @@ -78,3 +58,7 @@ notify: restart jitsi-videobridge tags: jitsi +- name: Reload systemd + systemd: daemon_reload=true + when: jitsi_videobridge_unit.changed + tags: jitsi diff --git a/roles/jitsi_videobridge/templates/jitsi-videobridge.service.j2 b/roles/jitsi_videobridge/templates/jitsi-videobridge.service.j2 index 47a2de8..40254df 100644 --- a/roles/jitsi_videobridge/templates/jitsi-videobridge.service.j2 +++ b/roles/jitsi_videobridge/templates/jitsi-videobridge.service.j2 @@ -6,6 +6,8 @@ After=network.target Type=simple SuccessExitStatus=143 EnvironmentFile={{ jitsi_root_dir }}/etc/videobridge/videobridge.conf +Environment=JAVA_HOME=/usr/lib/jvm/java-17 +Environment=PATH=/usr/lib/jvm/java-17/bin:/usr/local/sbin:/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin User={{ jitsi_user }} Group={{ jitsi_user }} PrivateTmp=true diff --git a/roles/prosody/defaults/main.yml b/roles/prosody/defaults/main.yml index 5dce63c..33578f3 100644 --- a/roles/prosody/defaults/main.yml +++ b/roles/prosody/defaults/main.yml @@ -17,62 +17,8 @@ prosody_admin_users: [] # 3rd party modules to install prosody_base_modules: - name: mod_auth_ldap - - name: util.lib - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/util.lib.lua - - name: mod_auth_jitsi-anonymous - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_auth_jitsi-anonymous.lua - - name: mod_end_conference - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_end_conference.lua - - name: mod_room_metadata - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_room_metadata.lua - - name: mod_room_metadata_component - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_room_metadata_component.lua - - name: mod_muc_hide_all - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_muc_hide_all.lua - - name: mod_room_destroy - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_room_destroy.lua - - name: mod_presence_identity - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_presence_identity.lua - - name: luajwtjitsi.lib - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/luajwtjitsi.lib.lua - - name: mod_auth_token - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_auth_token.lua - - name: mod_speakerstats - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_speakerstats.lua - - name: mod_speakerstats_component - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_speakerstats_component.lua - - name: mod_turncredentials - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_turncredentials.lua - - name: mod_conference_duration - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_conference_duration.lua - - name: mod_conference_duration_component - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_conference_duration_component.lua - - name: mod_client_proxy - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_client_proxy.lua - - name: mod_roster_command - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_roster_command.lua - - name: mod_muc_lobby_rooms - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_muc_lobby_rooms.lua - - name: mod_muc_breakout_rooms - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_muc_breakout_rooms.lua - - name: mod_muc_rate_limit - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_muc_rate_limit.lua - - name: mod_muc_meeting_id - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_muc_meeting_id.lua - - name: mod_muc_domain_mapper - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_muc_domain_mapper.lua - - name: mod_jitsi_session - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_jitsi_session.lua - - name: mod_external_services - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_external_services.lua - - name: mod_av_moderation - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_av_moderation.lua - - name: mod_av_moderation_component - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_av_moderation_component.lua - - name: mod_polls - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_polls.lua - - name: mod_limits_exception - url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_limits_exception.lua + #- name: mod_limits_exception + # url: https://raw.githubusercontent.com/jitsi/jitsi-meet/master/resources/prosody-plugins/mod_limits_exception.lua prosody_extra_modules: [] prosody_modules: "{{ (prosody_base_modules + prosody_extra_modules) | unique }}" diff --git a/roles/prosody/tasks/install.yml b/roles/prosody/tasks/install.yml index ab68bab..4c0802f 100644 --- a/roles/prosody/tasks/install.yml +++ b/roles/prosody/tasks/install.yml @@ -24,16 +24,6 @@ notify: restart prosody tags: prosody -- name: Install additional modules - copy: - src: "{{ item }}" - dest: /opt/prosody/modules/ - notify: restart prosody - loop: - - mod_participant_metadata.lua - - token - tags: prosody - - name: Remove useless unit override file: path=/etc/systemd/system/prosody.service.d/99-ansible.conf state=absent register: prosody_unit @@ -45,9 +35,3 @@ when: prosody_unit.changed tags: prosody -- name: Allow prosody to query LDAP servers - seboolean: name={{ item }} state=True persistent=True - loop: - - authlogin_nsswitch_use_ldap - when: ansible_selinux.status == 'enabled' - tags: prosody diff --git a/roles/prosody/tasks/selinux.yml b/roles/prosody/tasks/selinux.yml index 63a2382..81a2503 100644 --- a/roles/prosody/tasks/selinux.yml +++ b/roles/prosody/tasks/selinux.yml @@ -26,3 +26,10 @@ semodule -i /etc/selinux/targeted/local/prosody-ansible.pp when: prosody_selinux_policy.changed tags: prosody + +- name: Allow prosody to query LDAP and HTTP servers + seboolean: name={{ item }} state=true persistent=true + loop: + - authlogin_nsswitch_use_ldap + - nis_enabled + tags: prosody