File

mod_external_services/mod_external_services.lua @ 4421:94805a7e7b30

mod_invites: rework CLI parsing to support groups To make this sensible, the code had to move from rather simple parsing to something which looks more like getopt or your typical shell script.
author Jonas Schäfer <jonas@wielicki.name>
date Sun, 31 Jan 2021 19:16:36 +0100
parent 4075:4841cf3fded5
child 4665:f0ffa8cf3ce6
line wrap: on
line source


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 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;
}

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 reply = st.reply(stanza):tag("services", { xmlns = action.attr.xmlns });
	local extras = module:get_host_items("external_service");
	local services = ( configured_services + extras ) / prepare;

	local requested_type = action.attr.type;
	if requested_type then
		services:filter(function(item)
			return item.type == requested_type;
		end);
	end

	setmetatable(services, services_mt);

	module:fire_event("external_service/services", {
			origin = origin;
			stanza = stanza;
			reply = reply;
			requested_type = requested_type;
			services = services;
		});

	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

	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"));
		return true;
	end

	local reply = st.reply(stanza):tag("credentials", { xmlns = action.attr.xmlns });
	local extras = module:get_host_items("external_service");
	local services = ( configured_services + extras ) / prepare;
	services:filter(function (item)
		return item.restricted;
	end)

	local requested_credentials = {};
	for service in action:childtags("service") do
		table.insert(requested_credentials, {
				type = service.attr.type;
				host = service.attr.host;
				port = tonumber(service.attr.port);
			});
	end

	setmetatable(services, services_mt);
	setmetatable(requested_credentials, services_mt);

	module:fire_event("external_service/credentials", {
			origin = origin;
			stanza = stanza;
			reply = reply;
			requested_credentials = requested_credentials;
			services = services;
		});

	for req_srv in action:childtags("service") do
		for _, srv in ipairs(services) do
			if srv.type == req_srv.attr.type and srv.host == req_srv.attr.host
				and not req_srv.attr.port or srv.port == tonumber(req_srv.attr.port) then
				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
		end
	end

	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);