diff --git a/README.md b/README.md index 672af06..6356c23 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ Extends the capabilities of mod_muc_max_occupants by allowing different max occupancy based on the room name or subdomain. +- [proxybased external services](proxybased_external_services/) + + extends `external_services` module to allow redirection of different clients to different services based on an HTTP header in the requests. + - [secure domain lobby bypass](secure_domain_lobby_bypass/) Enables some users to bypass lobby based on the authentiation. diff --git a/proxybased_external_services/README.md b/proxybased_external_services/README.md new file mode 100644 index 0000000..ff910be --- /dev/null +++ b/proxybased_external_services/README.md @@ -0,0 +1,57 @@ +# proxybased_external_services +This module is dervied from jitsi's original `external_services` module and allows you to set the host of the services via an http header. \ +This is useful when you want to redirect different clients to different services. \ +The module currently supports WebSocket and Bosh connections. + +The module should **not** be enabled together with `external_services`. If both modules are enabled at the same time, unexpected behaviour may occur. + +## Installation +- Copy this script to the Prosody plugins folder. It's the following folder on + Debian: + + ```bash + cd /usr/share/jitsi-meet/prosody-plugins/ + wget -O mod_proxybased_external_services.lua https://raw.githubusercontent.com/jitsi-contrib/prosody-plugins/main/proxybased_external_services/mod_proxybased_external_services.lua + ``` + +- Enable module in your prosody config. + + _/etc/prosody/conf.d/meet.mydomain.com.cfg.lua_ + + ```lua + Component "conference.meet.mydomain.com" "muc" + modules_enabled = { + ... + ... + -- "external_services"; + "proxybased_external_services"; + } + ``` + +- Restart Prosody + + ```bash + systemctl restart prosody.service + ``` + +## Configuration +The configuration is like the original `external_services` module. All that has been added is the new `proxybased_external_service_host_header` attribute, which defines a header from which the host for the services is taken. If the header cannot be found in a request, the host from the service configuration will be used as a default. \ +The default header used is `Turn-Server`. +```lua +proxybased_external_service_secret = ""; +proxybased_external_service_host_header = "Turn-Server" +-- 'some-turn-server' is the default host used when the `Turn-Server` header could not be found in a request +proxybased_external_services = { + { type = "turns", host = "some-turn-server", port = 443, transport = "tcp", secret = true, ttl = 86400, algorithm = "turn" } +}; +``` + +## Example HAProxy configuration +The following example shows how an HA proxy sitting in front of Prosody can be configured if internal and external clients are to be rooted to different turn servers. + +```haproxy +# Turn Settings for external clients +http-request set-header Turn-Server external-turn1.example.de if { hdr_ip(x-forwarded-for) 0.0.0.0/0 } +# Turn Settings for internal clients +http-request set-header Turn-Server internal-turn1.example.de if { hdr_ip(x-forwarded-for) 10.0.0.0/8 } +``` diff --git a/proxybased_external_services/mod_proxybased_external_services.lua b/proxybased_external_services/mod_proxybased_external_services.lua new file mode 100644 index 0000000..5ce86a9 --- /dev/null +++ b/proxybased_external_services/mod_proxybased_external_services.lua @@ -0,0 +1,324 @@ +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("proxybased_external_service_host", module.host); +local default_port = module:get_option_number("proxybased_external_service_port"); +local default_secret = module:get_option_string("proxybased_external_service_secret"); +local default_ttl = module:get_option_number("proxybased_external_service_ttl", 86400); + +local configured_services = module:get_option_array("proxybased_external_services", {}); + +local access = module:get_option_set("proxybased_external_service_access", {}); +local host_header = module:get_option_string( + "proxybased_external_service_host_header", + "Turn-Server" +):gsub("%-", "_"):lower() + +-- 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; +} + +-- Gets the http headers from the event if the connection is via websocket. +function get_headers_ws(event) + if event.origin.websocket_request == nil then + module:log("warn", "Unable to get turn host from HTTP headers: origin.websocket_request is nil"); + end + + local headers = event.origin.websocket_request.headers; + + if headers == nil then + module:log("warn", "Unable to get turn host from HTTP headers: Unable to find headers in websocket request"); + return nil; + end + + return headers +end + +-- Gets the http headers from the event if the connection is via bosh. +function get_headers_bosh(event) + if event.origin.conn == nil then + module:log("warn", "Unable to get turn host from HTTP headers: origin.conn is nil"); + return; + end + + if event.origin.conn._http_open_response == nil then + module:log("warn", "Unable to get turn host from HTTP headers: origin.conn._http_open_response is nil"); + return; + end + + if event.origin.conn._http_open_response.request == nil then + module:log("warn", "Unable to get turn host from HTTP headers: origin.conn._http_open_response.request is nil"); + return; + end + + local headers = event.origin.conn._http_open_response.request.headers; + + if headers == nil then + module:log("warn", "Unable to get turn host from HTTP headers: Unable to find headers in bosh request"); + return nil; + end + + return headers +end + +function get_host_from_http_headers(event) + local headers + if event.origin.websocket_request ~= nil then + module:log("debug", "Detected websocket request"); + headers = get_headers_ws(event); + elseif event.origin.bosh_processing == true then + module:log("debug", "Detected bosh request"); + headers = get_headers_bosh(event); + else + module:log("warn", "Unable to get turn host from HTTP headers: Unsuported connection type"); + + return nil + end + + local host = headers[host_header]; + + if host == nil then + module:log("warn", "Unable to get turn host from HTTP headers: No '"..host_header.."' header found"); + return nil; + end + + if type(host) ~= "string" then + module:log("warn", "Unable to get turn host from HTTP headers: Header '"..host_header.."' is not of type string"); + return nil; + end + + local ip = headers.x_forwarded_for; + if ip == nil then + ip = event.origin.ip; + end + + module:log("debug", "Using host '"..host.."' for origin with ip '"..ip.."'"); + + return host; +end + +function get_services(event) + local extras = module:get_host_items("proxybased_external_service"); + local services = ( configured_services + extras ) / prepare; + + setmetatable(services, services_mt); + + local overwrite_host = get_host_from_http_headers(event); + + if overwrite_host ~= nil then + for _, service in ipairs(services) do + service.host = overwrite_host + end + end + + 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(event); + + local requested_type = action.attr.type; + if requested_type then + services:filter(function(item) + return item.type == requested_type; + end); + end + + module:fire_event("proxybased_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(event); + 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("proxybased_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); + +module:log("info", "Loaded module");