Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proxybased_external_services: module to enable dynamic redirects to services based on an http header #32

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions proxybased_external_services/README.md
Original file line number Diff line number Diff line change
@@ -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 = "<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 }
```
324 changes: 324 additions & 0 deletions proxybased_external_services/mod_proxybased_external_services.lua
Original file line number Diff line number Diff line change
@@ -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");